Backup: Work in progress on Frame Extractor and general updates

This commit is contained in:
DJP 2025-12-10 17:37:05 -05:00
parent b9d8da41af
commit a0c8722aa5
43 changed files with 5533 additions and 1052 deletions

1
.gitignore vendored
View file

@ -13,7 +13,6 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/

View file

@ -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)

View file

@ -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"""

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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('#', '')}")

View file

@ -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={

View file

@ -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())

135
backend/debug_topaz_real.py Normal file
View file

@ -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())

1
backend/test_image.jpg Normal file
View file

@ -0,0 +1 @@
fake image content

BIN
backend/test_image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 KiB

View file

@ -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() {
</label>
<select
value={outputFormat}
onChange={(e) => setOutputFormat(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setOutputFormat(e.target.value)}
className="select-field"
>
{outputFormats.map((format) => (
@ -201,7 +222,7 @@ export default function VoiceToTextPage() {
type="checkbox"
id="translate"
checked={translate}
onChange={(e) => setTranslate(e.target.checked)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTranslate(e.target.checked)}
className="w-4 h-4 rounded border-gray-600 bg-forge-dark text-forge-yellow focus:ring-forge-yellow"
/>
<label htmlFor="translate" className="text-gray-300">
@ -212,7 +233,7 @@ export default function VoiceToTextPage() {
{translate && (
<select
value={targetLanguage}
onChange={(e) => setTargetLanguage(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setTargetLanguage(e.target.value)}
className="select-field"
>
{targetLanguages.filter((l) => l.value).map((lang) => (

View file

@ -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<Asset | null>(null);
const [uploading, setUploading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<string, any> = { 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 (
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
@ -203,7 +316,7 @@ export default function MyFilesPage() {
type="text"
placeholder="Search files..."
value={search}
onChange={(e) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
setPage(1);
}}
@ -273,6 +386,79 @@ export default function MyFilesPage() {
</div>
</div>
{/* Batch Actions Toolbar */}
{selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-forge-dark border border-gray-700 rounded-xl shadow-2xl p-2 flex items-center gap-2 z-40 animate-in slide-in-from-bottom-4">
<div className="px-3 text-sm font-medium text-white border-r border-gray-700">
{selectedIds.size} Selected
</div>
<button
onClick={() => handleBatchAction('download')}
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
title="Download All"
>
<Download className="w-4 h-4" />
</button>
<div className="h-6 w-px bg-gray-700 mx-1" />
<div className="h-6 w-px bg-gray-700 mx-1" />
{/* App Actions Dropdown */}
{getCommonFileType() !== 'mixed' && getCommonFileType() !== null && (
<div className="relative">
<button
onClick={() => setShowActionMenu(!showActionMenu)}
className="flex items-center gap-2 px-3 py-1.5 bg-forge-yellow text-black rounded-lg hover:bg-yellow-400 font-medium text-sm transition-colors"
>
Send to App <ChevronDown className={`w-3 h-3 transition-transform ${showActionMenu ? 'rotate-180' : ''}`} />
</button>
{showActionMenu && (
<>
{/* Backdrop to close menu */}
<div className="fixed inset-0 z-10" onClick={() => setShowActionMenu(false)} />
<div className="absolute bottom-full mb-2 left-0 w-48 bg-forge-dark border border-gray-700 rounded-lg shadow-xl py-1 z-20 animate-in fade-in zoom-in-95 duration-100">
{getCommonFileType() === 'image' && (
<>
<button onClick={() => { handleBatchAction('upscale_image'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Upscale Image</button>
<button onClick={() => { handleBatchAction('remove_bg'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Remove Background</button>
<div className="h-px bg-gray-700 my-1" />
<button onClick={() => { handleBatchAction('img_to_video'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Image to Video</button>
<button onClick={() => { handleBatchAction('alt_text'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Generate Alt Text</button>
</>
)}
{getCommonFileType() === 'video' && (
<>
<button onClick={() => { handleBatchAction('upscale_video'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Upscale Video</button>
<button onClick={() => { handleBatchAction('subtitles'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Generate Subtitles</button>
<div className="h-px bg-gray-700 my-1" />
<button onClick={() => { handleBatchAction('extract_frame'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Extract Frame</button>
</>
)}
{getCommonFileType() === 'audio' && (
<button onClick={() => { handleBatchAction('transcribe'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Transcribe Audio</button>
)}
</div>
</>
)}
</div>
)}
<div className="h-6 w-px bg-gray-700 mx-1" />
<button
onClick={() => handleBatchAction('delete')}
className="p-2 text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
title="Delete Selected"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
{/* Content */}
{loading ? (
<div className="flex items-center justify-center h-64">
@ -297,15 +483,34 @@ export default function MyFilesPage() {
>
{/* Thumbnail */}
<div
className="aspect-square relative cursor-pointer"
onClick={() => 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);
}}
>
<div
className="absolute top-0 left-0 p-2 z-20"
onClick={(e) => {
e.stopPropagation();
toggleSelection(asset.id);
}}
>
<div className={`w-5 h-5 rounded border ${selectedIds.has(asset.id) ? 'bg-forge-yellow border-forge-yellow' : 'bg-black/50 border-white/50 hover:bg-white/20'} flex items-center justify-center transition-colors`}>
{selectedIds.has(asset.id) && <CheckSquare className="w-3 h-3 text-black" />}
</div>
</div>
{asset.thumbnail_url || asset.file_type === 'image' ? (
<img
src={`/api/v1/assets/${asset.id}/download`}
alt={asset.filename}
className="w-full h-full object-cover"
onError={(e) => {
onError={(e: React.SyntheticEvent<HTMLImageElement, Event>) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
@ -318,7 +523,7 @@ export default function MyFilesPage() {
{/* Hover Actions */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
onClick={(e) => {
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setSelectedAsset(asset);
}}
@ -327,13 +532,13 @@ export default function MyFilesPage() {
<Eye className="w-4 h-4 text-white" />
</button>
<button
onClick={(e) => handleDownload(asset, e)}
onClick={(e: React.MouseEvent) => handleDownload(asset, e)}
className="p-2 bg-white/20 rounded-full hover:bg-white/30"
>
<Download className="w-4 h-4 text-white" />
</button>
<button
onClick={(e) => handleDelete(asset, e)}
onClick={(e: React.MouseEvent) => handleDelete(asset, e)}
className="p-2 bg-red-500/50 rounded-full hover:bg-red-500/70"
>
<Trash2 className="w-4 h-4 text-white" />
@ -371,6 +576,9 @@ export default function MyFilesPage() {
<tr key={asset.id} className="hover:bg-forge-gray/50">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<button onClick={() => toggleSelection(asset.id)} className="text-gray-500 hover:text-white">
{selectedIds.has(asset.id) ? <CheckSquare className="w-4 h-4 text-forge-yellow" /> : <Square className="w-4 h-4" />}
</button>
<Icon className={clsx('w-5 h-5', colorClass)} />
<span className="text-white">{asset.filename}</span>
</div>
@ -389,13 +597,13 @@ export default function MyFilesPage() {
<Eye className="w-4 h-4" />
</button>
<button
onClick={(e) => handleDownload(asset, e)}
onClick={(e: React.MouseEvent) => handleDownload(asset, e)}
className="p-1 text-gray-400 hover:text-forge-yellow"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={(e) => handleDelete(asset, e)}
onClick={(e: React.MouseEvent) => handleDelete(asset, e)}
className="p-1 text-gray-400 hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
@ -441,7 +649,7 @@ export default function MyFilesPage() {
>
<div
className="bg-forge-dark rounded-xl border border-gray-800 max-w-4xl max-h-[90vh] overflow-auto"
onClick={(e) => e.stopPropagation()}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
{/* Preview Content */}
<div className="p-4">

View file

@ -1,8 +1,9 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { toast } from 'react-hot-toast';
import { ImagePlus, Download, Sparkles, Pencil, X, Loader2 } from 'lucide-react';
import { ImagePlus, Download, Sparkles, Pencil, X, Loader2, Maximize, Film } from 'lucide-react';
import JobProgress from '@/components/JobProgress';
import ProviderControls from '@/components/ProviderControls';
import { modulesApi, assetsApi, capabilitiesApi } from '@/lib/api';
@ -11,6 +12,8 @@ import { ProviderConfig } from '@/types/providers';
export default function ImageGeneratePage() {
const { addJob, updateJob } = useStore();
const router = useRouter();
const searchParams = useSearchParams();
// Provider config state
const [capabilities, setCapabilities] = useState<Record<string, ProviderConfig> | null>(null);
@ -54,6 +57,17 @@ export default function ImageGeneratePage() {
loadCapabilities();
}, []);
// Handle URL parameters
useEffect(() => {
const urlPrompt = searchParams.get('prompt');
if (urlPrompt) {
setPrompt(urlPrompt);
}
// Check for reference asset for editing/variations if we support it via URL
// (Optional: handle assetId if needed)
}, [searchParams]);
// Initialize default values for provider
const initializeDefaults = (config: ProviderConfig) => {
if (!config) {
@ -387,26 +401,40 @@ export default function ImageGeneratePage() {
key={image.id}
className="bg-forge-dark rounded-xl overflow-hidden border border-gray-800 group"
>
<div className="relative w-full" style={{ paddingBottom: '100%' }}>
<div className="relative w-full group">
<img
src={`/api/v1/assets/${image.id}/download`}
alt="Generated"
className="absolute inset-0 w-full h-full object-contain bg-black"
className="w-full h-auto object-contain bg-black/20"
/>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2 p-4">
{supportsEditing && (
<button
onClick={() => handleStartEdit(image)}
className="bg-purple-600 hover:bg-purple-700 text-white py-2 px-4 rounded-lg font-medium transition-colors flex items-center gap-2"
className="bg-purple-600 hover:bg-purple-700 text-white py-1.5 px-3 rounded-lg font-medium text-sm transition-colors flex items-center gap-2 w-full justify-center"
title="Edit with Nano Banana"
>
<Pencil className="w-4 h-4" />
Edit
</button>
)}
<button
onClick={() => router.push(`/image/upscale?assetId=${image.id}`)}
className="bg-blue-600 hover:bg-blue-700 text-white py-1.5 px-3 rounded-lg font-medium text-sm transition-colors flex items-center gap-2 w-full justify-center"
>
<Maximize className="w-4 h-4" />
Upscale
</button>
<button
onClick={() => router.push(`/video/generate?assetId=${image.id}`)}
className="bg-green-600 hover:bg-green-700 text-white py-1.5 px-3 rounded-lg font-medium text-sm transition-colors flex items-center gap-2 w-full justify-center"
>
<Film className="w-4 h-4" />
Video
</button>
<button
onClick={() => handleDownload(image.id, image.original_filename)}
className="btn-primary flex items-center gap-2"
className="btn-primary py-1.5 px-3 text-sm flex items-center gap-2 w-full justify-center"
>
<Download className="w-4 h-4" />
Download

View file

@ -1,11 +1,12 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { toast } from 'react-hot-toast';
import { Eraser, Download, Sparkles } from 'lucide-react';
import { Eraser, 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 outputFormats = [
@ -14,51 +15,98 @@ const outputFormats = [
{ value: 'tiff', label: 'TIFF (Clipping Path)' },
];
interface QueueItem {
id: string;
file?: File; // Optional because we might load by assetId from URL
assetId?: string;
originalFileName?: string; // To display when only assetId is known
jobId?: string;
status: 'pending' | 'uploading' | 'processing' | 'completed' | 'error';
resultAssetId?: string;
error?: string;
}
export default function RemoveBackgroundPage() {
const { addJob, updateJob } = useStore();
const [file, setFile] = useState<File | null>(null);
const [assetId, setAssetId] = useState<string | null>(null);
const searchParams = useSearchParams();
const [queue, setQueue] = useState<QueueItem[]>([]);
const [outputFormat, setOutputFormat] = useState('png');
const [refineMask, setRefineMask] = useState(true);
const [jobId, setJobId] = useState<string | null>(null);
const [resultImage, setResultImage] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [processing, setProcessing] = useState(false);
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);
useEffect(() => {
const urlAssetId = searchParams.get('assetId');
if (urlAssetId) {
// Add as a pending item if not already in queue
setQueue(prev => {
if (prev.some(item => item.assetId === urlAssetId)) return prev;
return [...prev, {
id: Math.random().toString(36).substring(7),
assetId: urlAssetId,
status: 'pending',
originalFileName: 'Asset from URL' // We might not know the name yet, that's fine
}];
});
toast.success('Asset added to queue from URL');
}
}, [searchParams]);
const handleFileUpload = (files: File[]) => {
const newItems: QueueItem[] = files.map(file => ({
id: Math.random().toString(36).substring(7),
file,
originalFileName: file.name,
status: 'pending'
}));
setQueue(prev => [...prev, ...newItems]);
toast.success(`${files.length} images added to queue`);
};
const handleRemoveBackground = async () => {
if (!assetId) {
toast.error('Please upload an image first');
return;
}
setLoading(true);
setResultImage(null);
const processItem = async (item: QueueItem) => {
if (item.status === 'completed' || item.status === 'processing') return item;
try {
let assetId = item.assetId;
// 1. Upload if needed
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 (err: any) {
if (err.response?.status === 409) {
const existingAssetId = err.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);
assetId = uploadRes.data.id;
} else {
assetId = existingAssetId;
}
} else {
throw err;
}
}
setQueue(prev => prev.map(i => i.id === item.id ? { ...i, assetId, status: 'pending' } : i));
} else if (!assetId && !item.file) {
throw new Error("No file or asset ID provided");
}
// 2. Process
setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'processing' } : i));
// This assumes modulesApi.removeBackground takes { asset_id, output_format, refine_mask }
const response = await modulesApi.removeBackground({
asset_id: assetId,
asset_id: assetId!,
output_format: outputFormat,
refine_mask: refineMask,
});
const job = response.data;
setJobId(job.id);
addJob({
id: job.id,
module: 'background_removal',
@ -67,41 +115,73 @@ export default function RemoveBackgroundPage() {
created_at: job.created_at,
});
toast.success('Background removal started!');
// Poll for completion
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?.length > 0) {
const resultId = currentJob.output_asset_ids[0];
setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'completed', resultAssetId: resultId } : i));
return { ...item, status: 'completed', resultAssetId: resultId };
} else {
throw new Error(currentJob.error_message || 'Job failed');
}
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Failed to start processing');
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');
const limit = 3;
if (job.output_asset_ids?.[0]) {
const asset = await assetsApi.get(job.output_asset_ids[0]);
setResultImage(asset.data);
toast.success('Background removed successfully!');
// We process only pending items. Note that if we just uploaded (item.status is 'pending' but with assetId set), it will process.
// If it was 'uploading', it shouldn't happen here because upload is part of processItem if we do it one by one,
// OR we can separates upload step. The current logic puts upload inside processItem which is fine.
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 handleClearQueue = () => {
setQueue([]);
};
const handleDownload = async () => {
if (!resultImage) return;
const removeItem = (id: string) => {
setQueue(prev => prev.filter(i => i.id !== id));
};
const handleDownload = async (assetId: string) => {
try {
const response = await assetsApi.download(resultImage.id);
const response = await assetsApi.download(assetId);
// We need the filename. If we haven't fetched the asset details, we might guess or try to get it.
// For now let's hope the browser handles it or we fetch header.
// Actually assetsApi.download returns a blob?
// Better logic: fetch asset details to get filename, then download.
// But here let's just use generic name if we can't get it easily, or maybe we fetch asset first?
// To be safe and quick:
const url = window.URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = resultImage.original_filename;
a.download = `removed_bg_${assetId}.png`; // fallback
a.click();
window.URL.revokeObjectURL(url);
} catch (err) {
toast.error('Failed to download image');
toast.error("Download failed");
}
};
@ -117,37 +197,28 @@ export default function RemoveBackgroundPage() {
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Controls */}
<div className="space-y-6">
{/* File Upload */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Controls - Left Column */}
<div className="lg:col-span-1 space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Upload Image
Upload Images
</label>
<FileUpload
onUpload={handleFileUpload}
onUploadMultiple={handleFileUpload}
accept={{ 'image/*': ['.png', '.jpg', '.jpeg', '.webp'] }}
currentFile={file}
onClear={() => {
setFile(null);
setAssetId(null);
}}
label="Upload an image"
label="Upload images (Multiple allowed)"
multiple={true}
/>
{uploading && (
<p className="mt-2 text-sm text-forge-yellow">Uploading...</p>
)}
</div>
{/* Output Format */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Output Format
</label>
<select
value={outputFormat}
onChange={(e) => setOutputFormat(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setOutputFormat(e.target.value)}
className="select-field"
>
{outputFormats.map((format) => (
@ -158,95 +229,118 @@ export default function RemoveBackgroundPage() {
</select>
</div>
{/* Refine Mask */}
<div className="flex items-center gap-3">
<input
type="checkbox"
id="refineMask"
checked={refineMask}
onChange={(e) => setRefineMask(e.target.checked)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRefineMask(e.target.checked)}
className="w-4 h-4 rounded border-gray-600 bg-forge-dark text-forge-yellow focus:ring-forge-yellow"
/>
<label htmlFor="refineMask" className="text-gray-300">
<label htmlFor="refineMask" className="text-gray-300 text-sm">
Refine edges (better quality, slower)
</label>
</div>
{/* Remove Background Button */}
<button
onClick={handleRemoveBackground}
disabled={loading || !assetId || uploading}
className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Sparkles className="w-5 h-5" />
{loading ? 'Processing...' : 'Remove Background'}
</button>
{/* Job Progress */}
{jobId && loading && (
<JobProgress
jobId={jobId}
onComplete={handleJobComplete}
onError={handleJobError}
/>
{queue.length > 0 && (
<div className="pt-4 border-t border-gray-800 space-y-4">
<button
onClick={handleProcessQueue}
disabled={processing || !queue.some(i => i.status === 'pending' || i.status === 'error')}
className="btn-primary w-full flex items-center justify-center gap-2"
>
{processing ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{processing ? 'Processing Queue...' : 'Process All'}
</button>
<button
onClick={handleClearQueue}
disabled={processing}
className="btn-secondary w-full flex items-center justify-center gap-2"
>
<Trash2 className="w-4 h-4" /> Clear Queue
</button>
<div className="text-center text-xs text-gray-500">
{queue.filter(i => i.status === 'completed').length} / {queue.length} completed
</div>
</div>
)}
</div>
{/* Results */}
<div>
<h2 className="text-lg font-semibold text-white mb-4">Result</h2>
{resultImage ? (
<div className="bg-forge-dark rounded-xl overflow-hidden border border-gray-800">
<div
className="relative"
style={{
backgroundImage:
'linear-gradient(45deg, #2a2a2a 25%, transparent 25%), linear-gradient(-45deg, #2a2a2a 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #2a2a2a 75%), linear-gradient(-45deg, transparent 75%, #2a2a2a 75%)',
backgroundSize: '20px 20px',
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
}}
>
<img
src={`/api/v1/assets/${resultImage.id}/download`}
alt="Result"
className="w-full"
/>
</div>
<div className="p-4 border-t border-gray-800">
<div className="flex items-center justify-between">
<div>
<p className="text-white font-medium">{resultImage.original_filename}</p>
<p className="text-sm text-gray-500">
{(resultImage.file_size_bytes / 1024).toFixed(1)} KB
</p>
</div>
<button
onClick={handleDownload}
className="btn-primary flex items-center gap-2"
>
<Download className="w-4 h-4" />
Download
</button>
</div>
</div>
{/* Results / Queue - Right Column */}
<div className="lg:col-span-3 space-y-4">
<h2 className="text-lg font-semibold text-white">Queue</h2>
{queue.length === 0 ? (
<div className="bg-forge-dark rounded-xl border border-gray-800 p-12 flex flex-col items-center justify-center text-gray-500">
<Eraser className="w-12 h-12 mb-4 opacity-50" />
<p>Queue is empty. Upload images to start.</p>
</div>
) : (
<div
className="bg-forge-dark rounded-xl border border-gray-800 aspect-video flex items-center justify-center"
style={{
backgroundImage:
'linear-gradient(45deg, #1a1a1a 25%, transparent 25%), linear-gradient(-45deg, #1a1a1a 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #1a1a1a 75%), linear-gradient(-45deg, transparent 75%, #1a1a1a 75%)',
backgroundSize: '20px 20px',
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
}}
>
<p className="text-gray-500 bg-forge-dark/80 px-4 py-2 rounded">
Result will appear here
</p>
<div className="space-y-4">
{queue.map(item => (
<div key={item.id} className="bg-forge-dark rounded-xl border border-gray-800 p-4 flex gap-4 items-start">
<div className="w-24 h-24 bg-black/40 rounded-lg flex-shrink-0 overflow-hidden relative border border-gray-700">
{/* 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 ? (
<img src={URL.createObjectURL(item.file)} alt="source" className="w-full h-full object-cover" />
) : item.assetId ? (
<img src={`/api/v1/assets/${item.assetId}/download`} alt="source" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-600">No Img</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start mb-2">
<h3 className="text-white font-medium truncate" title={item.originalFileName || item.assetId}>
{item.originalFileName || 'Asset ID: ' + item.assetId}
</h3>
<button onClick={() => removeItem(item.id)} className="text-gray-500 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-4">
{/* Status Badges */}
{item.status === 'pending' && <span className="text-xs font-medium px-2 py-1 bg-gray-800 text-gray-400 rounded">Pending</span>}
{item.status === 'uploading' && <span className="text-xs font-medium px-2 py-1 bg-blue-900/50 text-blue-400 rounded animate-pulse">Uploading...</span>}
{item.status === 'processing' && <span className="text-xs font-medium px-2 py-1 bg-yellow-900/50 text-forge-yellow rounded animate-pulse">Processing...</span>}
{item.status === 'error' && <span className="text-xs font-medium px-2 py-1 bg-red-900/50 text-red-400 rounded">Error: {item.error}</span>}
{item.status === 'completed' && <span className="text-xs font-medium px-2 py-1 bg-green-900/50 text-green-400 rounded">Completed</span>}
</div>
</div>
{/* Result Preview (if completed) */}
{item.status === 'completed' && item.resultAssetId && (
<div className="w-32 h-32 bg-[url('/grid.png')] bg-gray-800 rounded-lg flex-shrink-0 overflow-hidden relative border border-gray-700 bg-checker">
<img
src={`/api/v1/assets/${item.resultAssetId}/download`}
alt="result"
className="w-full h-full object-contain"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center">
<button
onClick={() => handleDownload(item.resultAssetId!)}
className="p-2 bg-forge-yellow rounded-full hover:bg-yellow-400 text-black"
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
<style jsx global>{`
.bg-checker {
background-image: linear-gradient(45deg, #2a2a2a 25%, transparent 25%), linear-gradient(-45deg, #2a2a2a 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #2a2a2a 75%), linear-gradient(-45deg, transparent 75%, #2a2a2a 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
`}</style>
</div>
);
}

View file

@ -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<File | null>(null);
const [assetId, setAssetId] = useState<string | null>(null);
const [queue, setQueue] = useState<QueueItem[]>([]);
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<string | null>(null);
const [upscaledImage, setUpscaledImage] = useState<any>(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() {
</div>
<div>
<h1 className="text-2xl font-bold text-white">Image Upscaler</h1>
<p className="text-gray-500">Enhance image resolution with Topaz Labs AI</p>
<p className="text-gray-500">Enhance multiple images with Topaz Labs AI</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Controls */}
<div className="space-y-6">
{/* File Upload */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Upload Image
</label>
<FileUpload
onUpload={handleFileUpload}
accept={{ 'image/*': ['.png', '.jpg', '.jpeg', '.webp'] }}
currentFile={file}
onClear={() => {
setFile(null);
setAssetId(null);
}}
label="Upload an image to upscale"
/>
{uploading && (
<p className="mt-2 text-sm text-forge-yellow">Uploading...</p>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Scale */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Scale Factor
</label>
<div className="flex gap-2">
{scaleOptions.map((option) => (
<button
key={option.value}
onClick={() => setScale(option.value)}
className={`px-6 py-3 rounded-lg font-medium transition-colors ${scale === option.value
? 'bg-forge-yellow text-black'
: 'bg-forge-dark border border-gray-700 text-gray-300 hover:border-gray-600'
}`}
>
{option.label}
</button>
))}
{/* Left Column: Settings */}
<div className="space-y-6 lg:col-span-1">
<div className="bg-forge-dark p-6 rounded-xl border border-gray-800 space-y-6">
<h3 className="text-lg font-semibold text-white">Batch Settings</h3>
{/* Scale */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Scale Factor
</label>
<div className="flex gap-2">
{scaleOptions.map((option) => (
<button
key={option.value}
onClick={() => setScale(option.value)}
className={`flex-1 py-2 rounded-lg font-medium transition-colors text-sm ${scale === option.value
? 'bg-forge-yellow text-black'
: 'bg-black/40 border border-gray-700 text-gray-300 hover:border-gray-600'
}`}
>
{option.label}
</button>
))}
</div>
</div>
{/* Model */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Upscaling Model
</label>
<select
value={model}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setModel(e.target.value)}
className="select-field w-full"
>
{modelOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Denoise */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Denoise Strength: {denoiseStrength.toFixed(1)}
</label>
<input
type="range"
min={0}
max={1}
step={0.1}
value={denoiseStrength}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDenoiseStrength(parseFloat(e.target.value))}
className="w-full accent-forge-yellow"
/>
</div>
{/* Sharpen */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Sharpen: {sharpen.toFixed(1)}
</label>
<input
type="range"
min={0}
max={1}
step={0.1}
value={sharpen}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSharpen(parseFloat(e.target.value))}
className="w-full accent-forge-yellow"
/>
</div>
</div>
{/* Model */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Upscaling Model
</label>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="select-field"
>
{modelOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Denoise */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Denoise Strength: {denoiseStrength.toFixed(1)}
</label>
<input
type="range"
min={0}
max={1}
step={0.1}
value={denoiseStrength}
onChange={(e) => setDenoiseStrength(parseFloat(e.target.value))}
className="w-full accent-forge-yellow"
/>
</div>
{/* Sharpen */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Sharpen: {sharpen.toFixed(1)}
</label>
<input
type="range"
min={0}
max={1}
step={0.1}
value={sharpen}
onChange={(e) => setSharpen(parseFloat(e.target.value))}
className="w-full accent-forge-yellow"
/>
</div>
{/* Upscale Button */}
<button
onClick={handleUpscale}
disabled={loading || !assetId || uploading}
className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Sparkles className="w-5 h-5" />
{loading ? 'Upscaling...' : 'Upscale Image'}
</button>
{/* Job Progress */}
{jobId && loading && (
<JobProgress
jobId={jobId}
onComplete={handleJobComplete}
onError={handleJobError}
/>
)}
</div>
{/* Results */}
<div>
<h2 className="text-lg font-semibold text-white mb-4">Result</h2>
{upscaledImage ? (
<div className="bg-forge-dark rounded-xl overflow-hidden border border-gray-800">
<div className="relative">
<img
src={`/api/v1/assets/${upscaledImage.id}/download`}
alt="Upscaled"
className="w-full"
/>
{/* Right Column: Queue & Upload */}
<div className="lg:col-span-2 space-y-6">
{/* Upload Area */}
<div>
<FileUpload
onUploadMultiple={handleFileUpload}
accept={{ 'image/*': ['.png', '.jpg', '.jpeg', '.webp'] }}
label="Drag & drop images here (Multiple allowed)"
multiple={true}
/>
</div>
{/* Queue Actions */}
{queue.length > 0 && (
<div className="flex flex-wrap gap-4 items-center justify-between bg-forge-dark p-4 rounded-xl border border-gray-800">
<div className="text-white font-medium">
Queue: {queue.length} items ({queue.filter(i => i.status === 'completed').length} completed)
</div>
<div className="p-4 border-t border-gray-800">
<div className="flex items-center justify-between">
<div>
<p className="text-white font-medium">{upscaledImage.original_filename}</p>
<p className="text-sm text-gray-500">
{upscaledImage.width} x {upscaledImage.height}
</p>
<div className="flex gap-2">
<button
onClick={handleClearQueue}
className="btn-secondary text-sm flex items-center gap-2"
disabled={processing}
>
<Trash2 className="w-4 h-4" /> Clear
</button>
<button
onClick={handleProcessQueue}
disabled={processing || !queue.some(i => i.status === 'pending' || i.status === 'error')}
className="btn-primary text-sm flex items-center gap-2 disabled:opacity-50"
>
{processing ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{processing ? 'Processing...' : 'Process Batch'}
</button>
</div>
</div>
)}
{/* Queue List */}
<div className="space-y-4">
{queue.map(item => (
<div key={item.id} className="bg-forge-dark rounded-xl border border-gray-800 p-4 flex gap-4 items-center">
{/* Thumbnail */}
<div className="w-20 h-20 bg-black/40 rounded-lg flex-shrink-0 overflow-hidden relative border border-gray-800">
<img
src={item.file ? URL.createObjectURL(item.file) : `/api/v1/assets/${item.assetId}/download`}
alt="thumb"
className="w-full h-full object-cover"
/>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h4 className="text-white font-medium truncate">{item.file?.name || item.filename || 'Unknown File'}</h4>
<div className="text-sm mt-1">
{item.status === 'pending' && <span className="text-gray-500">Waiting to start...</span>}
{item.status === 'uploading' && <span className="text-blue-400">Uploading original...</span>}
{item.status === 'processing' && <span className="text-forge-yellow flex items-center gap-2"><RefreshCw className="w-3 h-3 animate-spin" /> Upscaling...</span>}
{item.status === 'completed' && <span className="text-green-400 flex items-center gap-2"><Sparkles className="w-3 h-3" /> Complete</span>}
{item.status === 'error' && <span className="text-red-400">Error: {item.error}</span>}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{item.status === 'completed' && item.result && (
<button
onClick={() => handleDownload(item)}
className="p-2 hover:bg-white/10 rounded text-forge-yellow transition-colors"
title="Download"
>
<Download className="w-5 h-5" />
</button>
)}
<button
onClick={handleDownload}
className="btn-primary flex items-center gap-2"
onClick={() => removeItem(item.id)}
className="p-2 hover:bg-white/10 rounded text-gray-500 hover:text-red-400 transition-colors"
disabled={processing && item.status === 'processing'}
>
<Download className="w-4 h-4" />
Download
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
</div>
) : (
<div className="bg-forge-dark rounded-xl border border-gray-800 aspect-video flex items-center justify-center">
<p className="text-gray-500">Upscaled image will appear here</p>
</div>
)}
))}
{queue.length === 0 && !processing && (
<div className="text-center py-12 text-gray-500">
Add images to start batch upscaling
</div>
)}
</div>
</div>
</div>
</div>

View file

@ -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 (
<html lang="en">
<body className="font-montserrat bg-forge-black min-h-screen">
<body className="font-montserrat bg-forge-black min-h-screen" suppressHydrationWarning={true}>
<Toaster
position="top-right"
toastOptions={{

View file

@ -2,54 +2,111 @@
import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { FileText, Copy, Check, Sparkles } from 'lucide-react';
import { FileText, Copy, Check, Sparkles, Download, 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';
interface QueueItem {
id: string;
file?: File;
filename?: string;
assetId?: string;
jobId?: string;
status: 'pending' | 'uploading' | 'processing' | 'completed' | 'error';
result?: {
shortAlt: string;
longAlt: string;
};
error?: string;
}
export default function AltTextPage() {
const { addJob, updateJob } = useStore();
const [file, setFile] = useState<File | null>(null);
const [assetId, setAssetId] = useState<string | null>(null);
const [jobId, setJobId] = useState<string | null>(null);
const [results, setResults] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const [queue, setQueue] = useState<QueueItem[]>([]);
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() {
</div>
<div>
<h1 className="text-2xl font-bold text-white">Alt Text Generator</h1>
<p className="text-gray-500">Generate accessible alt text for images using GPT-4 Vision</p>
<p className="text-gray-500">Batch generate accessible alt text for images</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="grid grid-cols-1 gap-8">
{/* Upload Section */}
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Upload Image
</label>
<FileUpload
onUpload={handleFileUpload}
accept={{ 'image/*': ['.png', '.jpg', '.jpeg', '.webp', '.gif'] }}
currentFile={file}
onClear={() => {
setFile(null);
setAssetId(null);
setResults(null);
}}
label="Upload an image to analyze"
/>
{uploading && (
<p className="mt-2 text-sm text-forge-yellow">Uploading...</p>
)}
<div className="space-y-4">
<FileUpload
onUploadMultiple={handleFileUpload}
accept={{ 'image/*': ['.png', '.jpg', '.jpeg', '.webp', '.gif'] }}
label="Upload images to analyze (Multiple allowed)"
multiple={true}
/>
</div>
{/* Queue Actions */}
{queue.length > 0 && (
<div className="flex flex-wrap gap-4 items-center justify-between bg-forge-dark p-4 rounded-xl border border-gray-800">
<div className="text-white font-medium">
Queue: {queue.length} items ({queue.filter(i => i.status === 'completed').length} completed)
</div>
<div className="flex gap-2">
<button
onClick={handleClearQueue}
className="btn-secondary text-sm flex items-center gap-2"
disabled={processing}
>
<Trash2 className="w-4 h-4" /> Clear
</button>
<button
onClick={handleProcessQueue}
disabled={processing || !queue.some(i => i.status === 'pending' || i.status === 'error')}
className="btn-primary text-sm flex items-center gap-2 disabled:opacity-50"
>
{processing ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{processing ? 'Processing...' : 'Process Queue'}
</button>
<button
onClick={handleDownloadCSV}
disabled={!queue.some(i => i.status === 'completed')}
className="btn-secondary text-sm flex items-center gap-2 disabled:opacity-50"
>
<Download className="w-4 h-4" /> Download CSV
</button>
</div>
</div>
)}
{/* Preview */}
{assetId && (
<div className="bg-forge-dark rounded-xl border border-gray-800 overflow-hidden">
<img
src={`/api/v1/assets/${assetId}/download`}
alt="Preview"
className="w-full max-h-96 object-contain"
/>
</div>
)}
{/* Queue List */}
<div className="space-y-4">
{queue.map(item => (
<div key={item.id} className="bg-forge-dark rounded-xl border border-gray-800 p-4 flex gap-4 items-start">
{/* Thumbnail */}
<div className="w-24 h-24 bg-black/40 rounded-lg flex-shrink-0 overflow-hidden relative">
<img
src={item.file ? URL.createObjectURL(item.file) : `/api/v1/assets/${item.assetId}/download`}
alt="thumb"
className="w-full h-full object-cover"
/>
</div>
{/* Generate Button */}
<button
onClick={handleGenerate}
disabled={loading || !assetId || uploading}
className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Sparkles className="w-5 h-5" />
{loading ? 'Generating...' : 'Generate Alt Text'}
</button>
{/* Job Progress */}
{jobId && loading && (
<JobProgress
jobId={jobId}
onComplete={handleJobComplete}
onError={handleJobError}
/>
)}
</div>
{/* Results Section */}
<div>
<h2 className="text-lg font-semibold text-white mb-4">Generated Alt Text</h2>
{results ? (
<div className="space-y-4">
{/* Short Alt */}
<div className="bg-forge-dark rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-white font-medium">
Short Version
<span className="text-gray-500 text-sm ml-2">(150 chars max)</span>
</h3>
<button
onClick={() => copyToClipboard(results.shortAlt, 'short')}
className="p-2 text-gray-400 hover:text-forge-yellow transition-colors"
>
{copied === 'short' ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
{/* Content */}
<div className="flex-1 min-w-0 space-y-2">
<div className="flex justify-between items-start">
<h3 className="text-white font-medium truncate">{item.filename || item.file?.name}</h3>
<button onClick={() => removeItem(item.id)} className="text-gray-500 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</button>
</div>
<p className="text-gray-300">{results.shortAlt}</p>
<p className="text-xs text-gray-500 mt-2">
{results.shortAlt?.length || 0} characters
</p>
</div>
{/* Long Alt */}
<div className="bg-forge-dark rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-white font-medium">
Long Version
<span className="text-gray-500 text-sm ml-2">(400 chars max)</span>
</h3>
<button
onClick={() => copyToClipboard(results.longAlt, 'long')}
className="p-2 text-gray-400 hover:text-forge-yellow transition-colors"
>
{copied === 'long' ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
<p className="text-gray-300">{results.longAlt}</p>
<p className="text-xs text-gray-500 mt-2">
{results.longAlt?.length || 0} characters
</p>
</div>
{/* HTML Snippets */}
<div className="bg-forge-gray rounded-xl p-4">
<h3 className="text-white font-medium mb-3">Ready-to-use HTML</h3>
<div className="space-y-3">
<div>
<p className="text-xs text-gray-500 mb-1">Short version:</p>
<code className="block bg-forge-dark p-2 rounded text-sm text-gray-300 overflow-x-auto">
{`<img src="image.jpg" alt="${results.shortAlt}" />`}
</code>
{/* Status / Result */}
{item.status === 'completed' && item.result ? (
<div className="space-y-2 text-sm">
<div className="bg-black/20 p-2 rounded">
<div className="flex justify-between text-gray-400 text-xs mb-1">
<span>Short Alt</span>
<button onClick={() => copyToClipboard(item.result!.shortAlt)}><Copy className="w-3 h-3 hover:text-white" /></button>
</div>
<p className="text-gray-300">{item.result.shortAlt}</p>
</div>
<div className="bg-black/20 p-2 rounded">
<div className="flex justify-between text-gray-400 text-xs mb-1">
<span>Long Alt</span>
<button onClick={() => copyToClipboard(item.result!.longAlt)}><Copy className="w-3 h-3 hover:text-white" /></button>
</div>
<p className="text-gray-300 line-clamp-2">{item.result.longAlt}</p>
</div>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Long version (with aria):</p>
<code className="block bg-forge-dark p-2 rounded text-sm text-gray-300 overflow-x-auto">
{`<img src="image.jpg" alt="${results.shortAlt}" aria-describedby="desc" />\n<p id="desc" class="sr-only">${results.longAlt}</p>`}
</code>
) : (
<div className="text-sm text-gray-400 flex items-center gap-2">
{item.status === 'pending' && <span className="text-gray-500">Waiting...</span>}
{item.status === 'uploading' && <span className="text-blue-400">Uploading...</span>}
{item.status === 'processing' && <span className="text-forge-yellow">Analyzing...</span>}
{item.status === 'error' && <span className="text-red-400">Error: {item.error}</span>}
</div>
</div>
)}
</div>
</div>
) : (
<div className="bg-forge-dark rounded-xl border border-gray-800 p-8 text-center">
<FileText className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500">Alt text will appear here</p>
</div>
)}
))}
</div>
</div>
{/* Accessibility Tips */}
<div className="bg-forge-gray rounded-xl p-6">
<h3 className="text-white font-medium mb-3">Alt Text Best Practices</h3>
<ul className="space-y-2 text-gray-400 text-sm">
<li> Use the short version for simple images in context</li>
<li> Use the long version for complex images or when detail matters</li>
<li> Alt text should describe the purpose, not just the appearance</li>
<li> Avoid redundant phrases like "image of" or "picture of"</li>
<li> Include text visible in the image when relevant</li>
</ul>
</div>
</div>
);
}

View file

@ -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<any>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="max-w-6xl mx-auto space-y-8">
<div className="flex items-center gap-4">
@ -83,9 +116,34 @@ export default function MermaidRendererPage() {
{/* Controls */}
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Mermaid Code
</label>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-300">
Mermaid Code
</label>
<div className="flex gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".mmd,.txt"
className="hidden"
/>
<button
onClick={handleImportClick}
className="text-xs flex items-center gap-1 text-forge-yellow hover:text-white transition-colors"
title="Import .mmd file"
>
<Upload className="w-3 h-3" /> Import
</button>
<button
onClick={handleExportCode}
className="text-xs flex items-center gap-1 text-forge-yellow hover:text-white transition-colors"
title="Export code to .mmd"
>
<FileCode className="w-3 h-3" /> Export
</button>
</div>
</div>
<textarea
value={code}
onChange={(e) => setCode(e.target.value)}

View file

@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'react-hot-toast';
import { Wand2, Copy, Check, Sparkles, RefreshCw } from 'lucide-react';
import { modulesApi } from '@/lib/api';
@ -17,6 +18,7 @@ const styles = [
];
export default function PromptStudioPage() {
const router = useRouter();
const [prompt, setPrompt] = useState('');
const [style, setStyle] = useState('cinematic');
const [enhancedPrompt, setEnhancedPrompt] = useState('');
@ -96,11 +98,10 @@ export default function PromptStudioPage() {
<button
key={s.value}
onClick={() => setStyle(s.value)}
className={`p-3 rounded-lg text-left transition-all ${
style === s.value
? 'bg-forge-yellow/10 border-forge-yellow text-white border'
: 'bg-forge-gray border border-gray-700 text-gray-400 hover:border-gray-600'
}`}
className={`p-3 rounded-lg text-left transition-all ${style === s.value
? 'bg-forge-yellow/10 border-forge-yellow text-white border'
: 'bg-forge-gray border border-gray-700 text-gray-400 hover:border-gray-600'
}`}
>
<p className="font-medium text-sm">{s.label}</p>
<p className="text-xs mt-1 opacity-70">{s.description}</p>
@ -143,6 +144,13 @@ export default function PromptStudioPage() {
<RefreshCw className="w-3 h-3" />
Use as input
</button>
<button
onClick={() => router.push(`/image/generate?prompt=${encodeURIComponent(enhancedPrompt)}`)}
className="text-sm text-blue-400 hover:text-blue-300 flex items-center gap-1"
>
<Sparkles className="w-3 h-3" />
Generate Image
</button>
<button
onClick={() => copyToClipboard(enhancedPrompt, 'enhanced')}
className="p-2 text-gray-400 hover:text-forge-yellow transition-colors"

View file

@ -0,0 +1,224 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { toast } from 'react-hot-toast';
import { FileVideo, Image as ImageIcon, Download, Scissors, ChevronRight, Wand2 } from 'lucide-react';
import FileUpload from '@/components/FileUpload';
import { assetsApi, modulesApi } from '@/lib/api';
export default function FrameExtractorPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [asset, setAsset] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [extracting, setExtracting] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const videoRef = useRef<HTMLVideoElement>(null);
const [extractedFrames, setExtractedFrames] = useState<any[]>([]);
// Load from URL if present
useEffect(() => {
const assetId = searchParams.get('assetId') || searchParams.get('assetIds')?.split(',')[0];
if (assetId) {
loadAsset(assetId);
}
}, [searchParams]);
const loadAsset = async (id: string) => {
setLoading(true);
try {
const res = await assetsApi.get(id);
if (res.data.file_type === 'video') {
setAsset(res.data);
} else {
toast.error('Asset is not a video');
}
} catch (err) {
toast.error('Failed to load asset');
} finally {
setLoading(false);
}
};
const handleFileUpload = async (file: File) => {
setLoading(true);
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');
} finally {
setLoading(false);
}
};
const handleTimeUpdate = () => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
setDuration(videoRef.current.duration || 0);
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 100);
return `${mins}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`;
};
const extractFrame = async () => {
if (!asset || !videoRef.current) return;
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({
asset_id: asset.id,
timestamp: timestamp
});
setExtractedFrames(prev => [res.data, ...prev]);
toast.success('Frame extracted!');
} catch (err) {
console.error(err);
toast.error('Extraction failed');
} finally {
setExtracting(false);
}
};
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;
}
};
return (
<div className="max-w-6xl mx-auto space-y-8 h-[calc(100vh-8rem)] flex flex-col">
<div className="flex items-center gap-4 flex-shrink-0">
<div className="w-12 h-12 bg-forge-yellow/10 rounded-lg flex items-center justify-center">
<Scissors className="w-6 h-6 text-forge-yellow" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Frame Extractor</h1>
<p className="text-gray-500">Extract high-quality frames from video for upscaling</p>
</div>
</div>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-8 min-h-0">
{/* Main Area - Video Player */}
<div className="lg:col-span-2 flex flex-col gap-4 min-h-0">
{!asset ? (
<div className="flex-1 bg-forge-dark rounded-xl border border-gray-800 p-8 flex flex-col items-center justify-center">
<div className="w-full max-w-md">
<FileUpload
onUpload={handleFileUpload}
accept={{ 'video/*': ['.mp4', '.mov', '.webm', '.mkv'] }}
label="Upload video to extract frames"
/>
</div>
</div>
) : (
<div className="flex flex-col gap-4 h-full">
<div className="bg-black rounded-xl overflow-hidden relative flex-1 min-h-[400px] flex items-center justify-center border border-gray-800">
<video
ref={videoRef}
src={`/api/v1/assets/${asset.id}/download`}
className="max-h-full max-w-full"
controls
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleTimeUpdate}
/>
</div>
{/* Controls Bar */}
<div className="bg-forge-dark p-4 rounded-xl border border-gray-800 flex items-center justify-between gap-4">
<div className="text-xl font-mono text-forge-yellow font-bold">
{formatTime(currentTime)}
</div>
<div className="flex-1 text-center text-gray-400 text-sm">
Paused at frame
</div>
<button
onClick={extractFrame}
disabled={extracting}
className="btn-primary flex items-center gap-2 px-6 py-3 text-lg"
>
{extracting ? (
<span className="w-5 h-5 border-2 border-black border-t-transparent rounded-full animate-spin" />
) : (
<ImageIcon className="w-5 h-5" />
)}
Extract Frame
</button>
</div>
</div>
)}
</div>
{/* Sidebar - Extracted Frames */}
<div className="bg-forge-dark rounded-xl border border-gray-800 flex flex-col min-h-0 overflow-hidden">
<div className="p-4 border-b border-gray-800">
<h3 className="font-semibold text-white flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-forge-yellow" />
Extracted Frames ({extractedFrames.length})
</h3>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{extractedFrames.length === 0 ? (
<div className="text-center text-gray-500 py-8">
No frames extracted yet
</div>
) : (
extractedFrames.map((frame, idx) => (
<div key={frame.id} className="bg-forge-gray/30 rounded-lg p-3 border border-gray-700 hover:border-forge-yellow transition-colors group animate-in slide-in-from-right-4 fade-in duration-300">
<div className="aspect-video bg-black/50 rounded mb-2 overflow-hidden relative">
<img src={`/api/v1/assets/${frame.id}/download`} alt="frame" className="w-full h-full object-contain" />
<div className="absolute top-1 right-1 bg-black/60 text-white text-xs px-1.5 py-0.5 rounded">
{formatTime(frame.asset_metadata?.timestamp || 0)}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleAction(frame, 'upscale')}
className="flex-1 bg-forge-yellow/10 text-forge-yellow hover:bg-forge-yellow hover:text-black text-xs py-1.5 rounded flex items-center justify-center gap-1 transition-colors"
title="Upscale Image"
>
<Wand2 className="w-3 h-3" /> Upscale
</button>
<button
onClick={() => handleAction(frame, 'download')}
className="p-1.5 text-gray-400 hover:text-white bg-gray-700/50 hover:bg-gray-700 rounded transition-colors"
title="Download"
>
<Download className="w-3 h-3" />
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { toast } from 'react-hot-toast';
import { Film, Download, Sparkles, FolderOpen, X, Loader2 } from 'lucide-react';
import FileUpload from '@/components/FileUpload';
@ -12,6 +13,8 @@ import { useStore } from '@/lib/store';
import { ProviderConfig } from '@/types/providers';
export default function VideoGeneratePage() {
const router = useRouter();
const searchParams = useSearchParams();
const { addJob, updateJob } = useStore();
// Provider config state
@ -36,6 +39,8 @@ export default function VideoGeneratePage() {
const [referenceAssetIds, setReferenceAssetIds] = useState<string[]>([]);
const [firstFramePreview, setFirstFramePreview] = useState<string | null>(null);
const [lastFramePreview, setLastFramePreview] = useState<string | null>(null);
const [referencePreviews, setReferencePreviews] = useState<string[]>([]);
const [inputPreview, setInputPreview] = useState<string | null>(null);
// Asset library modal
const [showAssetLibrary, setShowAssetLibrary] = useState(false);
@ -69,8 +74,24 @@ export default function VideoGeneratePage() {
};
loadCapabilities();
loadCapabilities();
}, []);
// Handle URL params
useEffect(() => {
const urlAssetId = searchParams.get('assetId');
if (urlAssetId) {
setMode('image');
setAssetId(urlAssetId);
// Optionally fetch asset details here if needed to show filename context
}
const urlPrompt = searchParams.get('prompt');
if (urlPrompt) {
setPrompt(urlPrompt);
}
}, [searchParams]);
// Initialize default values for provider
const initializeDefaults = (config: ProviderConfig) => {
if (!config) {
@ -138,9 +159,30 @@ export default function VideoGeneratePage() {
const response = await assetsApi.upload(uploadedFile);
setAssetId(response.data.id);
toast.success('Image uploaded!');
} catch (err) {
toast.error('Failed to upload image');
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('Image overwritten!');
} catch (retryErr: any) {
toast.error('Failed to overwrite image');
setFile(null);
}
} else {
setAssetId(existingAssetId);
toast('Using existing file', { icon: '' });
}
} else {
toast.error('Failed to upload image');
setFile(null);
}
} finally {
setUploading(false);
}
@ -154,19 +196,26 @@ export default function VideoGeneratePage() {
switch (assetSelectTarget) {
case 'input':
setAssetId(asset.id);
setInputPreview(thumbnailUrl || `/api/v1/assets/${asset.id}/download`);
setFile(null);
break;
case 'first':
setFirstFrameAssetId(asset.id);
setFirstFramePreview(thumbnailUrl);
setFirstFramePreview(thumbnailUrl || `/api/v1/assets/${asset.id}/download`);
break;
case 'last':
setLastFrameAssetId(asset.id);
setLastFramePreview(thumbnailUrl);
setLastFramePreview(thumbnailUrl || `/api/v1/assets/${asset.id}/download`);
break;
case 'reference':
if (referenceAssetIds.length < 4) {
setReferenceAssetIds([...referenceAssetIds, asset.id]);
// We need previews for these too, but state is currently just IDs.
// Let's assume we change referenceAssetIds to objects or fetch thumbnails?
// For simplicity, we can fetch asset details or we just add the URL to a separate state?
// Better: Change referenceAssetIds to referenceAssets state array of objects {id, url}
// But refactoring minimal: Let's use a parallel state `referencePreviews`.
setReferencePreviews(prev => [...prev, thumbnailUrl || `/api/v1/assets/${asset.id}/download`]);
}
break;
}
@ -295,21 +344,19 @@ export default function VideoGeneratePage() {
<div className="flex gap-2">
<button
onClick={() => setMode('text')}
className={`flex-1 px-4 py-3 rounded-lg font-medium transition-colors ${
mode === 'text'
? 'bg-forge-yellow text-black'
: 'bg-forge-dark border border-gray-700 text-gray-300 hover:border-gray-600'
}`}
className={`flex-1 px-4 py-3 rounded-lg font-medium transition-colors ${mode === 'text'
? 'bg-forge-yellow text-black'
: 'bg-forge-dark border border-gray-700 text-gray-300 hover:border-gray-600'
}`}
>
Text to Video
</button>
<button
onClick={() => setMode('image')}
className={`flex-1 px-4 py-3 rounded-lg font-medium transition-colors ${
mode === 'image'
? 'bg-forge-yellow text-black'
: 'bg-forge-dark border border-gray-700 text-gray-300 hover:border-gray-600'
}`}
className={`flex-1 px-4 py-3 rounded-lg font-medium transition-colors ${mode === 'image'
? 'bg-forge-yellow text-black'
: 'bg-forge-dark border border-gray-700 text-gray-300 hover:border-gray-600'
}`}
>
Image to Video
</button>
@ -323,7 +370,7 @@ export default function VideoGeneratePage() {
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setPrompt(e.target.value)}
placeholder={mode === 'text' ? 'Describe the video you want to create...' : 'Describe how the image should animate...'}
className="input-field min-h-[100px] resize-none"
/>
@ -353,8 +400,16 @@ export default function VideoGeneratePage() {
label="Or upload a new image"
/>
{uploading && <p className="mt-2 text-sm text-forge-yellow">Uploading...</p>}
{assetId && !file && (
<p className="mt-2 text-sm text-green-400">Using selected asset from library</p>
{assetId && inputPreview && !file && (
<div className="mt-2 bg-forge-dark border border-gray-700 rounded-lg p-2 flex gap-3 items-center">
<div className="w-12 h-12 bg-black/50 rounded overflow-hidden flex-shrink-0">
<img src={inputPreview} alt="Selected" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-green-400">Using selected asset from library</p>
</div>
<button onClick={() => { setAssetId(null); setInputPreview(null); }} className="p-1 hover:text-white"><X className="w-4 h-4" /></button>
</div>
)}
</div>
)}
@ -365,7 +420,7 @@ export default function VideoGeneratePage() {
<label className="block text-sm font-medium text-gray-300 mb-2">Provider</label>
<select
value={provider}
onChange={(e) => handleProviderChange(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => handleProviderChange(e.target.value)}
className="select-field"
>
{capabilities && Object.entries(capabilities).map(([id, config]) => (
@ -379,7 +434,7 @@ export default function VideoGeneratePage() {
<label className="block text-sm font-medium text-gray-300 mb-2">Model</label>
<select
value={model}
onChange={(e) => handleModelChange(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => handleModelChange(e.target.value)}
className="select-field"
>
{currentConfig?.models.map((m) => (
@ -483,14 +538,18 @@ export default function VideoGeneratePage() {
{referenceAssetIds.length > 0 && (
<div className="flex gap-2 mt-2 flex-wrap">
{referenceAssetIds.map((id, index) => (
<div key={id} className="relative w-16 h-16 bg-forge-dark border border-gray-700 rounded">
<div key={id} className="relative w-16 h-16 bg-forge-dark border border-gray-700 rounded overflow-hidden">
{referencePreviews[index] && <img src={referencePreviews[index]} className="w-full h-full object-cover opacity-50" />}
<button
onClick={() => setReferenceAssetIds(referenceAssetIds.filter((_, i) => i !== index))}
className="absolute -top-1 -right-1 p-0.5 bg-red-500 rounded-full"
onClick={() => {
setReferenceAssetIds(referenceAssetIds.filter((_, i) => i !== index));
setReferencePreviews(referencePreviews.filter((_, i) => i !== index));
}}
className="absolute -top-1 -right-1 p-0.5 bg-red-500 rounded-full z-10"
>
<X className="w-3 h-3 text-white" />
</button>
<span className="absolute inset-0 flex items-center justify-center text-xs text-gray-500">
<span className="absolute inset-0 flex items-center justify-center text-xs text-white font-bold drop-shadow-md">
{index + 1}
</span>
</div>

View file

@ -55,9 +55,30 @@ export default function SubtitlesPage() {
const response = await assetsApi.upload(uploadedFile);
setAssetId(response.data.id);
toast.success('Video uploaded!');
} catch (err) {
toast.error('Failed to upload video');
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('Video overwritten!');
} catch (retryErr: any) {
toast.error('Failed to overwrite video');
setFile(null);
}
} else {
setAssetId(existingAssetId);
toast('Using existing file', { icon: '' });
}
} else {
toast.error('Failed to upload video');
setFile(null);
}
} finally {
setUploading(false);
}
@ -131,7 +152,7 @@ export default function SubtitlesPage() {
a.download = asset.original_filename;
a.click();
window.URL.revokeObjectURL(url);
} catch (err) {
} catch (err: any) {
toast.error('Failed to download file');
}
};
@ -179,7 +200,7 @@ export default function SubtitlesPage() {
</label>
<select
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSourceLanguage(e.target.value)}
className="select-field"
>
{languages.map((lang) => (
@ -195,7 +216,7 @@ export default function SubtitlesPage() {
</label>
<select
value={targetLanguage}
onChange={(e) => setTargetLanguage(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setTargetLanguage(e.target.value)}
className="select-field"
>
{targetLanguages.map((lang) => (
@ -213,7 +234,7 @@ export default function SubtitlesPage() {
type="checkbox"
id="burnSubtitles"
checked={burnSubtitles}
onChange={(e) => setBurnSubtitles(e.target.checked)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBurnSubtitles(e.target.checked)}
className="w-4 h-4 rounded border-gray-600 bg-forge-dark text-forge-yellow focus:ring-forge-yellow"
/>
<label htmlFor="burnSubtitles" className="text-gray-300">

View file

@ -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, FileVideo } 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 = [
@ -14,81 +14,140 @@ const scaleOptions = [
];
const modelOptions = [
{ value: 'Proteus', label: 'Proteus (General)' },
{ value: 'Artemis High Quality', label: 'Artemis High Quality' },
{ value: 'Artemis Medium Quality', label: 'Artemis Medium Quality' },
{ value: 'Artemis Low Quality', label: 'Artemis Low Quality' },
{ value: 'Gaia High Quality', label: 'Gaia High Quality (CG/Anime)' },
{ value: 'Gaia CG', label: 'Gaia CG' },
{ value: 'Theia Detail', label: 'Theia Detail' },
{ value: 'Theia Fidelity', label: 'Theia Fidelity' },
{ value: 'Nyx', label: 'Nyx (Denoise)' },
{ value: 'Nyx Fast', label: 'Nyx Fast' },
{ value: 'Dione DV', label: 'Dione DV (Interlaced)' },
{ value: 'Dione TV', label: 'Dione TV (Interlaced)' },
{ value: 'Iris', label: 'Iris (Face Enhancement)' },
{ value: 'prob-3', label: 'Proteus (General)' },
{ value: 'ahq-12', label: 'Artemis High Quality' },
{ value: 'amq-13', label: 'Artemis Medium Quality' },
{ value: 'alq-13', label: 'Artemis Low Quality' },
{ value: 'ghq-5', label: 'Gaia High Quality (CG/Anime)' },
{ value: 'gcg-5', label: 'Gaia CG' },
{ value: 'thd-3', label: 'Theia Detail' },
{ value: 'thf-4', label: 'Theia Fidelity' },
{ value: 'nyx-1', label: 'Nyx (Denoise)' },
{ value: 'nyx-fast', label: 'Nyx Fast' },
{ value: 'ddv-3', label: 'Dione DV (Interlaced)' },
{ value: 'dtv-4', label: 'Dione TV (Interlaced)' },
{ value: 'iris-1', label: 'Iris (Face Enhancement)' },
{ value: 'Auto', label: 'Auto' },
];
interface QueueItem {
id: string;
file?: File;
assetId?: string;
filename?: string;
jobId?: string;
outputAssetId?: string;
status: 'pending' | 'uploading' | 'processing' | 'completed' | 'error';
error?: string;
result?: any;
}
export default function VideoUpscalePage() {
const router = useRouter();
const searchParams = useSearchParams();
const { addJob, updateJob } = useStore();
const [mounted, setMounted] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [assetId, setAssetId] = useState<string | null>(null);
const [queue, setQueue] = useState<QueueItem[]>([]);
const [processing, setProcessing] = useState(false);
// Settings
const [scale, setScale] = useState(2);
const [model, setModel] = useState('Auto');
const [denoiseStrength, setDenoiseStrength] = useState(0.3);
const [jobId, setJobId] = useState<string | null>(null);
// New States
const [denoiseStrength, setDenoiseStrength] = useState(0.3); // Map to something or ignore?
const [fps, setFps] = useState('');
const [sharpening, setSharpening] = useState(15);
const [recoverDetail, setRecoverDetail] = useState(20);
const [addNoise, setAddNoise] = useState(0);
const [faceEnhancement, setFaceEnhancement] = useState(false);
const [upscaledVideo, setUpscaledVideo] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
// Check for assetIds (batch) or assetId (single) in URL
const assetIdsParam = searchParams.get('assetIds');
const singleAssetId = searchParams.get('assetId');
const handleFileUpload = async (uploadedFile: File) => {
setFile(uploadedFile);
setUploading(true);
if ((assetIdsParam || singleAssetId) && assetsApi) {
const idsToFetch = assetIdsParam ? assetIdsParam.split(',') : [singleAssetId!];
try {
const response = await assetsApi.upload(uploadedFile);
setAssetId(response.data.id);
toast.success('Video uploaded!');
} catch (err) {
toast.error('Failed to upload video');
setFile(null);
} finally {
setUploading(false);
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];
});
router.replace('/video/upscale');
if (idsToFetch.length > 0) toast.success(`${idsToFetch.length} videos added from library`);
}).catch((err: any) => {
console.error("Failed to load assets", err);
toast.error("Failed to load some assets from URL");
});
}
}, [searchParams]);
if (!mounted) return null;
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} videos added to queue`);
};
const handleUpscale = async () => {
if (!assetId) {
toast.error('Please upload a video first');
return;
}
setLoading(true);
setUpscaledVideo(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 && 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));
}
if (!assetId) throw new Error("No asset ID");
// 2. Start Upscale Job
setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'processing' } : i));
const response = await modulesApi.upscaleVideo({
asset_id: assetId,
scale,
model,
denoise_strength: denoiseStrength, // Maps to nothing in backend? Need to fix parameter mapping if needed, or remove. Keeping for compliance but logic changed.
sharpening,
recover_detail: recoverDetail,
add_noise: addNoise,
@ -97,7 +156,6 @@ export default function VideoUpscalePage() {
});
const job = response.data;
setJobId(job.id);
addJob({
id: job.id,
module: 'video_upscaling',
@ -106,39 +164,73 @@ export default function VideoUpscalePage() {
created_at: job.created_at,
});
toast.success('Video upscaling started!');
// Poll locally
let currentJob = job;
while (currentJob.status !== 'completed' && currentJob.status !== 'failed') {
await new Promise(resolve => setTimeout(resolve, 5000)); // Polling slower for video
const pollRes = await jobsApi.get(job.id);
if (pollRes?.data) currentJob = pollRes.data;
else break;
}
if (currentJob.status === 'completed' && currentJob.output_asset_ids?.[0]) {
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]);
setUpscaledVideo(asset.data);
toast.success('Video upscaled successfully!');
// Sequential processing
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 removeItem = (id: string) => {
setQueue(prev => prev.filter(i => i.id !== id));
};
const handleDownload = async () => {
if (!upscaledVideo) return;
const handleClearQueue = () => {
setQueue([]);
};
const handleDownload = async (item: QueueItem) => {
if (!item.result) return;
try {
const response = await assetsApi.download(upscaledVideo.id);
const url = window.URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = upscaledVideo.original_filename;
a.click();
window.URL.revokeObjectURL(url);
const url = `/api/v1/assets/${item.result.id}/download`;
const link = document.createElement('a');
link.href = url;
link.download = item.result.original_filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (err) {
toast.error('Failed to download video');
}
@ -152,130 +244,59 @@ export default function VideoUpscalePage() {
</div>
<div>
<h1 className="text-2xl font-bold text-white">Video Upscaler</h1>
<p className="text-gray-500">Enhance video resolution with Topaz Labs AI</p>
<p className="text-gray-500">Enhance multiple videos with Topaz Labs AI</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Controls */}
<div className="space-y-6">
{/* File Upload */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Upload Video
</label>
<FileUpload
onUpload={handleFileUpload}
accept={{ 'video/*': ['.mp4', '.mov', '.avi', '.webm'] }}
currentFile={file}
onClear={() => {
setFile(null);
setAssetId(null);
}}
label="Upload a video to upscale"
/>
{uploading && (
<p className="mt-2 text-sm text-forge-yellow">Uploading...</p>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Scale */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Scale Factor
</label>
<div className="flex gap-2">
{scaleOptions.map((option) => (
<button
key={option.value}
onClick={() => setScale(option.value)}
className={`px-6 py-3 rounded-lg font-medium transition-colors ${scale === option.value
? 'bg-forge-yellow text-black'
: 'bg-forge-dark border border-gray-700 text-gray-300 hover:border-gray-600'
}`}
>
{option.label}
</button>
))}
</div>
</div>
{/* Left: Settings */}
<div className="space-y-6 lg:col-span-1">
<div className="bg-forge-dark p-6 rounded-xl border border-gray-800 space-y-6">
<h3 className="text-lg font-semibold text-white">Batch Settings</h3>
{/* Model */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Upscaling Model
</label>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="select-field"
>
{modelOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Denoise */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Denoise Strength: {denoiseStrength.toFixed(1)}
</label>
<input
type="range"
min={0}
max={100}
step={1}
value={denoiseStrength * 100}
onChange={(e) => setDenoiseStrength(parseFloat(e.target.value) / 100)}
className="w-full accent-forge-yellow"
/>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Sharpening */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Sharpening
</label>
<input
type="range"
min={0}
max={100}
value={sharpening}
onChange={(e) => setSharpening(parseInt(e.target.value))}
className="w-full accent-forge-yellow"
/>
<label className="block text-sm font-medium text-gray-300 mb-2">Scale Factor</label>
<div className="flex gap-2">
{scaleOptions.map((option) => (
<button
key={option.value}
onClick={() => setScale(option.value)}
className={`flex-1 py-2 rounded-lg font-medium transition-colors text-sm ${scale === option.value ? 'bg-forge-yellow text-black' : 'bg-black/40 border border-gray-700 text-gray-300 hover:border-gray-600'}`}
>
{option.label}
</button>
))}
</div>
</div>
{/* Recover Detail */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Recover Detail
</label>
<input
type="range"
min={0}
max={100}
value={recoverDetail}
onChange={(e) => setRecoverDetail(parseInt(e.target.value))}
className="w-full accent-forge-yellow"
/>
</div>
</div>
{/* FPS & Noise */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Target FPS
</label>
<select
value={fps}
onChange={(e) => setFps(e.target.value)}
className="select-field"
>
<label className="block text-sm font-medium text-gray-300 mb-2">Model</label>
<select value={model} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setModel(e.target.value)} className="select-field w-full">
{modelOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Sharpening: {sharpening}</label>
<input type="range" min={0} max={100} value={sharpening} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSharpening(parseInt(e.target.value))} className="w-full accent-forge-yellow" />
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Recover Detail: {recoverDetail}</label>
<input type="range" min={0} max={100} value={recoverDetail} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRecoverDetail(parseInt(e.target.value))} className="w-full accent-forge-yellow" />
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Add Noise: {addNoise}</label>
<input type="range" min={0} max={100} value={addNoise} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAddNoise(parseInt(e.target.value))} className="w-full accent-forge-yellow" />
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Target FPS</label>
<select value={fps} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFps(e.target.value)} className="select-field w-full">
<option value="">Original</option>
<option value="24">24 FPS</option>
<option value="30">30 FPS</option>
@ -283,92 +304,66 @@ export default function VideoUpscalePage() {
<option value="120">120 FPS</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Add Noise
</label>
<input
type="range"
min={0}
max={100}
value={addNoise}
onChange={(e) => setAddNoise(parseInt(e.target.value))}
className="w-full accent-forge-yellow"
/>
<div className="flex items-center gap-2">
<input type="checkbox" checked={faceEnhancement} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFaceEnhancement(e.target.checked)} className="rounded border-gray-700 bg-forge-dark text-forge-yellow" />
<label className="text-sm text-gray-300">Face Enhancement</label>
</div>
</div>
{/* Face Enhancement & Logic */}
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={faceEnhancement}
onChange={(e) => setFaceEnhancement(e.target.checked)}
className="rounded border-gray-700 bg-forge-dark text-forge-yellow focus:ring-forge-yellow"
/>
<label className="text-sm text-gray-300">
Face Enhancement (Iris Model)
</label>
</div>
{/* Upscale Button */}
<button
onClick={handleUpscale}
disabled={loading || !assetId || uploading}
className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Sparkles className="w-5 h-5" />
{loading ? 'Upscaling...' : 'Upscale Video'}
</button>
{/* Job Progress */}
{jobId && loading && (
<JobProgress
jobId={jobId}
onComplete={handleJobComplete}
onError={handleJobError}
/>
)}
{/* Note about processing time */}
<p className="text-sm text-gray-500">
Note: Video upscaling can take several minutes depending on the video length and resolution.
</p>
</div>
{/* Results */}
<div>
<h2 className="text-lg font-semibold text-white mb-4">Result</h2>
{upscaledVideo ? (
<div className="bg-forge-dark rounded-xl overflow-hidden border border-gray-800">
<video
src={`/api/v1/assets/${upscaledVideo.id}/download`}
controls
className="w-full"
/>
<div className="p-4 border-t border-gray-800">
<div className="flex items-center justify-between">
<div>
<p className="text-white font-medium">{upscaledVideo.original_filename}</p>
<p className="text-sm text-gray-500">
{upscaledVideo.width}x{upscaledVideo.height} {(upscaledVideo.file_size_bytes / 1024 / 1024).toFixed(1)} MB
</p>
</div>
<button
onClick={handleDownload}
className="btn-primary flex items-center gap-2"
>
<Download className="w-4 h-4" />
Download
</button>
</div>
{/* Right: Queue */}
<div className="lg:col-span-2 space-y-6">
<FileUpload
onUploadMultiple={handleFileUpload}
accept={{ 'video/*': ['.mp4', '.mov', '.avi', '.webm'] }}
label="Drag & drop videos here (Multiple allowed)"
multiple={true}
/>
{queue.length > 0 && (
<div className="flex flex-wrap gap-4 items-center justify-between bg-forge-dark p-4 rounded-xl border border-gray-800">
<div className="text-white font-medium">Queue: {queue.length} items ({queue.filter(i => i.status === 'completed').length} completed)</div>
<div className="flex gap-2">
<button onClick={handleClearQueue} className="btn-secondary text-sm flex items-center gap-2" disabled={processing}><Trash2 className="w-4 h-4" /> Clear</button>
<button onClick={handleProcessQueue} disabled={processing || !queue.some(i => i.status === 'pending' || i.status === 'error')} className="btn-primary text-sm flex items-center gap-2 disabled:opacity-50">
{processing ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{processing ? 'Processing...' : 'Process Batch'}
</button>
</div>
</div>
) : (
<div className="bg-forge-dark rounded-xl border border-gray-800 aspect-video flex items-center justify-center">
<p className="text-gray-500">Upscaled video will appear here</p>
</div>
)}
<div className="space-y-4">
{queue.map(item => (
<div key={item.id} className="bg-forge-dark rounded-xl border border-gray-800 p-4 flex gap-4 items-center">
<div className="w-20 h-20 bg-black/40 rounded-lg flex-shrink-0 flex items-center justify-center border border-gray-800">
<FileVideo className="w-8 h-8 text-gray-500" />
</div>
<div className="flex-1 min-w-0">
<h4 className="text-white font-medium truncate">{item.file?.name || item.filename || 'Unknown File'}</h4>
<div className="text-sm mt-1">
{item.status === 'pending' && <span className="text-gray-500">Waiting...</span>}
{item.status === 'uploading' && <span className="text-blue-400">Uploading...</span>}
{item.status === 'processing' && <span className="text-forge-yellow flex items-center gap-2"><RefreshCw className="w-3 h-3 animate-spin" /> Upscaling...</span>}
{item.status === 'completed' && <span className="text-green-400 flex items-center gap-2"><Sparkles className="w-3 h-3" /> Complete</span>}
{item.status === 'error' && <span className="text-red-400">Error: {item.error}</span>}
</div>
</div>
<div className="flex items-center gap-2">
{item.status === 'completed' && item.result && (
<button onClick={() => handleDownload(item)} className="p-2 hover:bg-white/10 rounded text-forge-yellow transition-colors"><Download className="w-5 h-5" /></button>
)}
<button onClick={() => removeItem(item.id)} className="p-2 hover:bg-white/10 rounded text-gray-500 hover:text-red-400 transition-colors" disabled={processing && item.status === 'processing'}><Trash2 className="w-5 h-5" /></button>
</div>
</div>
))}
{queue.length === 0 && !processing && (
<div className="text-center py-12 text-gray-500">Add videos to start batch upscaling</div>
)}
</div>
</div>
</div>
</div>

View file

@ -3,6 +3,7 @@
import { usePathname } from 'next/navigation';
import Sidebar from '@/components/Sidebar';
import Header from '@/components/Header';
import RecentAssets from '@/components/RecentAssets';
// Pages that should not have the app shell (sidebar/header)
const FULL_SCREEN_PAGES = ['/login', '/signup'];
@ -22,9 +23,12 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
<RecentAssets />
</div>
</div>
</div>
);

View file

@ -6,12 +6,14 @@ import { Upload, X, FileImage, FileVideo, FileAudio, File } from 'lucide-react';
import { clsx } from 'clsx';
interface FileUploadProps {
onUpload: (file: File) => void;
onUpload?: (file: File) => void;
onUploadMultiple?: (files: File[]) => void;
accept?: Record<string, string[]>;
maxSize?: number;
label?: string;
currentFile?: File | null;
onClear?: () => void;
multiple?: boolean;
}
const fileIcons: Record<string, any> = {
@ -22,11 +24,13 @@ const fileIcons: Record<string, any> = {
export default function FileUpload({
onUpload,
onUploadMultiple,
accept,
maxSize = 100 * 1024 * 1024, // 100MB default
label = 'Upload a file',
currentFile,
onClear,
multiple = false,
}: FileUploadProps) {
const [error, setError] = useState<string | null>(null);
@ -47,17 +51,43 @@ export default function FileUpload({
}
if (acceptedFiles.length > 0) {
onUpload(acceptedFiles[0]);
if (onUploadMultiple) {
onUploadMultiple(acceptedFiles);
} else if (onUpload) {
onUpload(acceptedFiles[0]);
}
}
},
[onUpload, maxSize]
[onUpload, onUploadMultiple, maxSize]
);
// Helper to validate mime type against accept prop
const checkType = (file: File) => {
if (!accept) return true;
const fileType = file.type;
const fileExt = `.${file.name.split('.').pop()?.toLowerCase()}`;
// Iterate over accept keys (image/*, etc)
for (const [mime, exts] of Object.entries(accept)) {
if (mime === '*/*') return true;
// Check wildcard
if (mime.endsWith('/*')) {
const baseMime = mime.split('/')[0];
if (fileType.startsWith(baseMime + '/')) return true;
}
// Check exact mime
if (fileType === mime) return true;
// Check extensions
if (exts && exts.some(ext => ext.toLowerCase() === fileExt)) return true;
}
return false;
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept,
maxSize,
multiple: false,
multiple: multiple || !!onUploadMultiple,
});
const getFileIcon = (file: File) => {
@ -95,7 +125,46 @@ export default function FileUpload({
}
return (
<div>
<div
onDragOver={(e) => {
// Needed to allow dropping non-files (like our JSON asset)
if (e.dataTransfer.types.includes('application/json')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
}}
onDrop={async (e) => {
// Check if we dropped our custom asset
const jsonData = e.dataTransfer.getData('application/json');
if (jsonData) {
try {
const data = JSON.parse(jsonData);
if (data.type === 'forge-asset' && data.url) {
e.preventDefault();
e.stopPropagation();
const res = await fetch(data.url);
const blob = await res.blob();
const file = new File([blob], data.filename, { type: blob.type });
// Validate type
if (!checkType(file)) {
setError('Invalid file type for this tool');
return;
}
if (onUploadMultiple) {
onUploadMultiple([file]);
} else if (onUpload) {
onUpload(file);
}
}
} catch (err) {
// Not our JSON, ignore
}
}
}}
>
<div
{...getRootProps()}
className={clsx(

View file

@ -29,7 +29,7 @@ export default function Header() {
{/* Breadcrumb / Title area */}
<div>
<h1 className="text-lg font-semibold text-white">Welcome to FORGE AI</h1>
<p className="text-sm text-gray-500">Creative tools powered by AI</p>
<p className="text-sm text-gray-500">Creative tools powered by AI to forge your AI skills</p>
</div>
{/* Right side */}

View file

@ -0,0 +1,263 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { assetsApi } from '@/lib/api';
import { Clock, FileImage, FileVideo, FileAudio, FileText, ChevronRight, GripVertical, ChevronLeft, ChevronLast, ChevronFirst } from 'lucide-react';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
const RECENT_LIMIT = 10;
export default function RecentAssets() {
const router = useRouter();
const [assets, setAssets] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isCollapsed, setIsCollapsed] = useState(false);
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);
const fetchRecentAssets = async () => {
try {
const response = await assetsApi.list({ limit: RECENT_LIMIT, sort: 'created_at', order: 'desc' });
setAssets(response.data);
} catch (err) {
console.error("Failed to fetch recent assets", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRecentAssets();
const interval = setInterval(fetchRecentAssets, 30000);
return () => clearInterval(interval);
}, []);
const renderPreview = (asset: any) => {
if (asset.mime_type.startsWith('image/')) {
return (
<div className="w-16 h-16 rounded overflow-hidden bg-black/20">
<img src={`/api/v1/assets/${asset.id}/download`} alt="preview" className="w-full h-full object-cover" />
</div>
);
}
if (asset.mime_type.startsWith('video/')) {
return (
<div className="w-16 h-16 rounded overflow-hidden relative bg-black">
<video src={`/api/v1/assets/${asset.id}/download`} className="w-full h-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<FileVideo className="w-6 h-6 text-white" />
</div>
</div>
);
}
if (asset.mime_type.startsWith('audio/')) {
return <div className="w-16 h-16 flex items-center justify-center bg-green-500/20 rounded text-green-400"><FileAudio className="w-8 h-8" /></div>;
}
return <div className="w-16 h-16 flex items-center justify-center bg-gray-500/20 rounded text-gray-400"><FileText className="w-8 h-8" /></div>;
};
const handleClick = (asset: any) => {
if (asset.mime_type.startsWith('image/')) {
router.push(`/image/upscale?assetId=${asset.id}`);
} else if (asset.mime_type.startsWith('video/')) {
router.push(`/video/upscale?assetId=${asset.id}`);
} else if (asset.mime_type.startsWith('audio/')) {
router.push(`/audio/voice-to-text?assetId=${asset.id}`);
} else {
router.push(`/files?assetId=${asset.id}`);
}
};
const handleAction = (e: React.MouseEvent, asset: any, action: string) => {
e.stopPropagation();
setSelectedAssetId(null);
switch (action) {
case 'upscale_image':
router.push(`/image/upscale?assetId=${asset.id}`);
break;
case 'img_to_video':
router.push(`/video/generate?assetId=${asset.id}`);
break;
case 'remove_bg':
router.push(`/image/remove-bg?assetId=${asset.id}`);
break;
case 'upscale_video':
router.push(`/video/upscale?assetId=${asset.id}`);
break;
case 'subtitles':
router.push(`/video/subtitles?assetId=${asset.id}`);
break;
case 'transcribe':
router.push(`/audio/voice-to-text?assetId=${asset.id}`);
break;
case 'alt_text':
router.push(`/text/alt-text?assetId=${asset.id}`);
break;
default:
router.push(`/files?assetId=${asset.id}`);
}
};
const toggleMenu = (e: React.MouseEvent, assetId: string) => {
e.stopPropagation();
if (selectedAssetId === assetId) {
setSelectedAssetId(null);
} else {
setSelectedAssetId(assetId);
}
};
const handleDragEnd = (result: any) => {
if (!result.destination) return;
const items = Array.from(assets);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setAssets(items);
};
if (loading && assets.length === 0) {
return (
<div className={`bg-forge-dark border-l border-gray-800 flex flex-col transition-all duration-300 ${isCollapsed ? 'w-16 items-center py-4' : 'w-80 p-4'}`}>
<div className="animate-pulse bg-gray-800/50 w-8 h-8 rounded" />
</div>
);
}
return (
<div className={`bg-forge-dark border-l border-gray-800 flex flex-col flex-shrink-0 transition-all duration-300 ${isCollapsed ? 'w-14' : 'w-80'}`}>
<div className={`p-4 border-b border-gray-800 flex items-center ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
{!isCollapsed && (
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Clock className="w-4 h-4 text-forge-yellow" />
Recent
</h3>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="text-gray-400 hover:text-white transition-colors"
>
{isCollapsed ? <ChevronFirst className="w-5 h-5" /> : <ChevronLast className="w-5 h-5" />}
</button>
</div>
{!isCollapsed && (
<div className="flex-1 overflow-y-auto p-4">
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="recent-assets">
{(provided: any) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-3"
>
{assets.map((asset, index) => (
<Draggable key={asset.id} draggableId={asset.id} index={index}>
{(provided: any, snapshot: any) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
draggable={true}
onDragStart={(e) => {
// Standard HTML5 drag start
// We pass a JSON string with asset details
const dragData = JSON.stringify({
id: asset.id,
url: `/api/v1/assets/${asset.id}/download`,
filename: asset.original_filename,
type: 'forge-asset'
});
e.dataTransfer.setData('application/json', dragData);
e.dataTransfer.setData('text/plain', dragData); // Fallback
// If dragging an image, try to set the drag image
if (asset.mime_type.startsWith('image/')) {
// Optional: Set custom drag image if needed, but browser default is usually okay for img tags
// But here we are dragging a div.
// e.dataTransfer.setDragImage(e.currentTarget.querySelector('img'), 0, 0);
}
}}
onClick={() => handleClick(asset)}
className={`group relative bg-forge-gray/30 hover:bg-forge-gray rounded-xl p-3 border border-transparent hover:border-gray-700 transition-all cursor-pointer ${snapshot.isDragging ? 'shadow-xl ring-2 ring-forge-yellow border-forge-yellow z-50' : ''
}`}
>
<div className="flex items-center gap-3">
<div className="text-gray-500 group-hover:text-gray-300">
<GripVertical className="w-4 h-4" />
</div>
<div className="flex-shrink-0">
{renderPreview(asset)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-200 truncate font-medium">{asset.original_filename}</p>
<p className="text-xs text-gray-500 truncate">
{(asset.file_size_bytes / 1024).toFixed(1)} KB
</p>
</div>
<button
onClick={(e) => toggleMenu(e, asset.id)}
className="p-1 hover:bg-gray-700 rounded transition-colors self-center"
>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${selectedAssetId === asset.id ? 'rotate-90 text-forge-yellow' : ''}`} />
</button>
</div>
{/* Context Menu */}
{selectedAssetId === asset.id && (
<div className="absolute top-full right-0 mt-1 w-56 bg-forge-dark border border-gray-700 rounded-lg shadow-xl z-50 py-1">
{asset.mime_type.startsWith('image/') && (
<>
<button onClick={(e) => handleAction(e, asset, 'upscale_image')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Upscale Image</button>
<button onClick={(e) => handleAction(e, asset, 'img_to_video')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Generate Video</button>
<button onClick={(e) => handleAction(e, asset, 'remove_bg')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Remove Background</button>
<button onClick={(e) => handleAction(e, asset, 'alt_text')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Generate Alt Text</button>
</>
)}
{asset.mime_type.startsWith('video/') && (
<>
<button onClick={(e) => handleAction(e, asset, 'upscale_video')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Upscale Video</button>
<button onClick={(e) => handleAction(e, asset, 'subtitles')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Generate Subtitles</button>
</>
)}
{asset.mime_type.startsWith('audio/') && (
<button onClick={(e) => handleAction(e, asset, 'transcribe')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Transcribe Audio</button>
)}
</div>
)}
</div>
)}
</Draggable>
))}
{provided.placeholder}
{assets.length === 0 && (
<p className="text-center text-gray-500 text-sm py-4">
No recent activity
</p>
)}
</div>
)}
</Droppable>
</DragDropContext>
</div>
)}
{isCollapsed && (
<div className="flex-1 flex flex-col items-center pt-4 gap-4">
{assets.slice(0, 5).map(asset => (
<div key={asset.id} className="w-10 h-10 rounded-lg overflow-hidden opacity-50 hover:opacity-100 transition-opacity cursor-pointer" title={asset.original_filename} onClick={() => setIsCollapsed(false)}>
{asset.mime_type.startsWith('image/') ? (
<img src={`/api/v1/assets/${asset.id}/download`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gray-800 flex items-center justify-center">
<Clock className="w-4 h-4 text-gray-500" />
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View file

@ -93,8 +93,8 @@ export default function Sidebar() {
{/* Logo */}
<div className="p-4 border-b border-gray-800">
<Link href="/" className="flex items-center gap-3">
<div className="w-10 h-10 bg-forge-yellow rounded-lg flex items-center justify-center">
<Sparkles className="w-6 h-6 text-black" />
<div className="w-10 h-10 flex items-center justify-center">
<img src="/THE_FORGE_LOGO.png" alt="Forge AI" className="w-full h-full object-contain" />
</div>
{!sidebarCollapsed && (
<span className="text-xl font-bold text-white">FORGE AI</span>

126
frontend/lib/api.ts Normal file
View file

@ -0,0 +1,126 @@
import axios from 'axios';
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || '/api/v1',
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // Send cookies with requests
});
// Request interceptor - cookies are sent automatically with withCredentials
api.interceptors.request.use((config) => {
// Token is sent via cookie, no need to add Authorization header
return config;
});
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// Jobs API
export const jobsApi = {
create: (data: any) => api.post('/jobs/', data),
get: (id: string) => api.get(`/jobs/${id}`),
list: (params?: any) => api.get('/jobs/', { params }),
cancel: (id: string) => api.post(`/jobs/${id}/cancel`),
};
// Assets API
export const assetsApi = {
upload: (file: File, projectId?: string, isTemporary: boolean = false, overwrite: boolean = false) => {
const formData = new FormData();
formData.append('file', file);
if (projectId) formData.append('project_id', projectId);
formData.append('is_temporary', String(isTemporary));
formData.append('overwrite', String(overwrite));
return api.post('/assets/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
get: (id: string) => api.get(`/assets/${id}`),
list: (params?: any) => api.get('/assets/', { params }),
download: (id: string) => api.get(`/assets/${id}/download`, { responseType: 'blob' }),
delete: (id: string) => api.delete(`/assets/${id}`),
};
// Modules API
export const modulesApi = {
// Image
generateImage: (data: any) => api.post('/modules/image/generate', data),
upscaleImage: (data: any) => api.post('/modules/image/upscale', data),
removeBackground: (data: any) => api.post('/modules/image/remove-background', data),
// Video
generateVideo: (data: any) => api.post('/modules/video/generate', data),
upscaleVideo: (data: any) => api.post('/modules/video/upscale', data),
extractFrame: (data: { asset_id: string; timestamp: number }) => api.post('/modules/video/extract-frame', data),
processSubtitles: (data: any) => api.post('/modules/video/subtitles', data),
// Audio
voiceToText: (data: any) => api.post('/modules/audio/voice-to-text', data),
textToSpeech: (data: any) => api.post('/modules/audio/text-to-speech', data),
speechToSpeech: (data: any) => api.post('/modules/audio/speech-to-speech', data),
generateSoundEffect: (data: any) => api.post('/modules/audio/sound-effects', data),
getSoundEffectFormats: () => api.get('/modules/audio/sound-effects/formats'),
getVoices: () => api.get('/modules/voices'),
// Text
generateAltText: (data: any) => api.post('/modules/text/alt-text', data),
enhancePrompt: (data: any) => api.post('/modules/text/enhance-prompt', data),
// Mermaid
generateMermaid: (data: any) => api.post('/modules/text/mermaid/generate', data),
renderMermaid: (data: any) => api.post('/modules/text/mermaid/render', data),
getMermaidTemplates: () => api.get('/modules/text/mermaid/templates'),
getMermaidTemplate: (type: string) => api.get(`/modules/text/mermaid/templates/${type}`),
// Markdown
convertMarkdown: (data: any) => api.post('/modules/text/markdown/convert', data),
generateMarkdown: (data: any) => api.post('/modules/text/markdown/generate', data),
// Utils
getModels: (provider: string) => api.get(`/modules/models/${provider}`),
getImageProviders: () => api.get('/modules/image/providers'),
getVideoProviders: () => api.get('/modules/video/providers'),
};
// Capabilities API
export const capabilitiesApi = {
getImageProviders: () => api.get('/modules/capabilities/image'),
getVideoProviders: () => api.get('/modules/capabilities/video'),
getImageProvider: (providerId: string) => api.get(`/modules/capabilities/image/${providerId}`),
getVideoProvider: (providerId: string) => api.get(`/modules/capabilities/video/${providerId}`),
};
// Users API
export const usersApi = {
me: () => api.get('/users/me'),
updateProfile: (data: any) => api.put('/users/me', data),
getUsage: () => api.get('/users/me/usage'),
};
// Auth API
export const authApi = {
signup: (data: { email: string; password: string; display_name: string }) =>
api.post('/auth/signup', data),
login: (data: { email: string; password: string }) =>
api.post('/auth/login', data),
logout: () => api.post('/auth/logout'),
me: () => api.get('/auth/me'),
updateProfile: (data: { display_name?: string; avatar_url?: string }) =>
api.patch('/auth/me', data),
changePassword: (data: { current_password: string; new_password: string }) =>
api.post('/auth/me/change-password', data),
verify: () => api.get('/auth/verify'),
};
export default api;

60
frontend/lib/auth.ts Normal file
View file

@ -0,0 +1,60 @@
import { useStore } from './store';
export type UserRole = 'user' | 'admin' | 'super_admin';
interface User {
id: string;
email: string;
name: string;
role: UserRole;
avatar_url?: string;
}
// Check if user has admin access
export function isAdmin(user: User | null): boolean {
if (!user) return false;
return user.role === 'admin' || user.role === 'super_admin';
}
// Check if user has super admin access
export function isSuperAdmin(user: User | null): boolean {
if (!user) return false;
return user.role === 'super_admin';
}
// Check if user can access a specific feature
export function canAccess(user: User | null, feature: string): boolean {
if (!user) return false;
const adminOnlyFeatures = [
'admin_panel',
'usage_reporting',
'user_management',
'api_keys_management',
'system_settings',
'audit_logs',
];
if (adminOnlyFeatures.includes(feature)) {
return isAdmin(user);
}
return true;
}
// Hook for checking admin status
export function useIsAdmin(): boolean {
const { user } = useStore();
return isAdmin(user as User | null);
}
// Hook for checking super admin status
export function useIsSuperAdmin(): boolean {
const { user } = useStore();
return isSuperAdmin(user as User | null);
}
// Admin access check function - use AdminGuard component instead of HOC
export function requireAdmin(user: User | null): boolean {
return isAdmin(user);
}

120
frontend/lib/store.ts Normal file
View file

@ -0,0 +1,120 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type UserRole = 'user' | 'admin' | 'super_admin';
interface User {
id: string;
email: string;
name: string;
role: UserRole;
avatar_url?: string;
}
export interface Job {
id: string;
module: string;
status: string;
progress: number;
created_at: string;
completed_at?: string;
output_asset_ids?: string[];
error_message?: string;
input_data?: Record<string, any>;
api_provider?: string;
api_model?: string;
}
interface Asset {
id: string;
original_filename: string;
file_type: string;
mime_type: string;
file_size_bytes: number;
created_at: string;
}
interface AppState {
user: User | null;
token: string | null;
activeJobs: Job[];
recentAssets: Asset[];
sidebarCollapsed: boolean;
// Actions
setUser: (user: User | null) => void;
setToken: (token: string | null) => void;
addJob: (job: Job) => void;
updateJob: (id: string, updates: Partial<Job>) => void;
removeJob: (id: string) => void;
clearCompletedJobs: () => void;
setRecentAssets: (assets: Asset[]) => void;
toggleSidebar: () => void;
logout: () => void;
}
export const useStore = create<AppState>()(
persist(
(set) => ({
user: null,
token: null,
activeJobs: [],
recentAssets: [],
sidebarCollapsed: false,
setUser: (user) => set({ user }),
setToken: (token) => {
// Note: Actual authentication is via httpOnly cookie
// This is just a marker for the UI state
set({ token });
},
addJob: (job) => set((state) => {
// Prevent duplicate jobs
if (state.activeJobs.some((j) => j.id === job.id)) {
return state;
}
return {
activeJobs: [job, ...state.activeJobs].slice(0, 50),
};
}),
updateJob: (id, updates) => set((state) => ({
activeJobs: state.activeJobs.map((job) =>
job.id === id ? { ...job, ...updates } : job
),
})),
removeJob: (id) => set((state) => ({
activeJobs: state.activeJobs.filter((job) => job.id !== id),
})),
clearCompletedJobs: () => set((state) => ({
activeJobs: state.activeJobs.filter(
(job) => job.status === 'queued' || job.status === 'processing'
),
})),
setRecentAssets: (assets) => set({ recentAssets: assets }),
toggleSidebar: () => set((state) => ({
sidebarCollapsed: !state.sidebarCollapsed,
})),
logout: () => {
// Clear all state (cookie is cleared by backend on /auth/logout)
set({ user: null, token: null, activeJobs: [], recentAssets: [] });
},
}),
{
name: 'forge-ai-storage',
partialize: (state) => ({
user: state.user,
token: state.token,
sidebarCollapsed: state.sidebarCollapsed,
activeJobs: state.activeJobs.slice(0, 20), // Persist last 20 jobs
}),
}
)
);

2656
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -9,17 +9,18 @@
"lint": "next lint"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.460.0",
"next": "15.3.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"axios": "^1.7.7",
"zustand": "^5.0.1",
"lucide-react": "^0.460.0",
"react-dropzone": "^14.2.9",
"react-hot-toast": "^2.4.1",
"date-fns": "^4.1.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.4"
"tailwind-merge": "^2.5.4",
"zustand": "^5.0.1"
},
"devDependencies": {
"@types/node": "^22.9.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 B

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1 @@
This is a test file for duplicate upload.