Backup: Work in progress on Frame Extractor and general updates
1
.gitignore
vendored
|
|
@ -13,7 +13,6 @@ dist/
|
|||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
102
backend/app/services/frame_extractor.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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('#', '')}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
fake image content
|
||||
BIN
backend/test_image.png
Normal file
|
After Width: | Height: | Size: 915 KiB |
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
224
frontend/app/video/extract/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
263
frontend/components/RecentAssets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
BIN
frontend/public/THE_FORGE_LOGO.png
Normal file
|
After Width: | Height: | Size: 435 KiB |
BIN
frontend/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 255 B |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 301 B |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
frontend/public/test_duplicate.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
This is a test file for duplicate upload.
|
||||