Fix video generation for Runway (Veo3/Gen4)
This commit is contained in:
parent
a0c8722aa5
commit
c58e4288ff
18 changed files with 994 additions and 444 deletions
|
|
@ -481,6 +481,8 @@ async def extract_frame_endpoint(
|
|||
new_asset = frame_extractor.extract_frame(request.asset_id, request.timestamp)
|
||||
return new_asset
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,15 +15,25 @@ def extract_frame(asset_id: str, timestamp: float):
|
|||
"""
|
||||
Extract a frame from a video asset at a specific timestamp.
|
||||
"""
|
||||
print(f"DEBUG: Extracting frame for asset {asset_id} at {timestamp}")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get input asset
|
||||
asset = db.query(Asset).filter(Asset.id == asset_id).first()
|
||||
from uuid import UUID
|
||||
try:
|
||||
uuid_id = UUID(str(asset_id))
|
||||
except ValueError:
|
||||
print(f"DEBUG: Invalid UUID string: {asset_id}")
|
||||
raise ValueError("Invalid asset ID format")
|
||||
|
||||
asset = db.query(Asset).filter(Asset.id == uuid_id).first()
|
||||
if not asset:
|
||||
print(f"DEBUG: Asset {asset_id} not found in DB")
|
||||
raise ValueError("Asset not found")
|
||||
|
||||
if not asset.file_path or not os.path.exists(asset.file_path):
|
||||
raise ValueError("Video file not found on disk")
|
||||
print(f"DEBUG: File path not found: {asset.file_path}")
|
||||
raise ValueError(f"Video file not found on disk: {asset.file_path}")
|
||||
|
||||
# Generate output filename
|
||||
# Format: {original_name}_frame_{timestamp}.png
|
||||
|
|
@ -35,6 +45,8 @@ def extract_frame(asset_id: str, timestamp: float):
|
|||
storage_path = os.path.join(settings.storage_path, "images")
|
||||
os.makedirs(storage_path, exist_ok=True)
|
||||
output_path = os.path.join(storage_path, filename)
|
||||
|
||||
print(f"DEBUG: Output path: {output_path}")
|
||||
|
||||
# Build ffmpeg command
|
||||
# -ss before -i for faster seeking
|
||||
|
|
@ -51,14 +63,18 @@ def extract_frame(asset_id: str, timestamp: float):
|
|||
]
|
||||
|
||||
logger.info(f"Extracting frame with command: {' '.join(cmd)}")
|
||||
print(f"DEBUG: Running command: {cmd}")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"FFmpeg failed: {result.stderr}")
|
||||
print(f"DEBUG: FFmpeg failed stderr: {result.stderr}")
|
||||
print(f"DEBUG: FFmpeg failed stdout: {result.stdout}")
|
||||
raise ValueError(f"Frame extraction failed: {result.stderr}")
|
||||
|
||||
if not os.path.exists(output_path):
|
||||
print(f"DEBUG: Output file missing after success return code")
|
||||
raise ValueError("Output file was not created")
|
||||
|
||||
# Get file size
|
||||
|
|
@ -87,7 +103,7 @@ def extract_frame(asset_id: str, timestamp: float):
|
|||
source_module="frame_extractor",
|
||||
parent_asset_id=asset.id,
|
||||
asset_metadata={
|
||||
"source_video_id": asset.id,
|
||||
"source_video_id": str(asset.id),
|
||||
"timestamp": timestamp
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -193,124 +193,133 @@ async def upscale(job_id: str):
|
|||
output_url = None
|
||||
polling_interval = 2
|
||||
max_attempts = 180
|
||||
status_data = {}
|
||||
|
||||
for i in range(max_attempts):
|
||||
await asyncio.sleep(polling_interval)
|
||||
|
||||
status_response = await client.get(
|
||||
f"https://api.topazlabs.com/image/v1/enhance/{request_id}/status",
|
||||
headers={"X-API-Key": settings.topaz_api_key}
|
||||
)
|
||||
|
||||
if status_response.status_code != 200:
|
||||
logger.warning(f"Topaz Status Check Failed: {status_response.text}")
|
||||
continue
|
||||
try:
|
||||
status_response = await client.get(
|
||||
f"https://api.topazlabs.com/image/v1/enhance/{request_id}/status",
|
||||
headers={"X-API-Key": settings.topaz_api_key}
|
||||
)
|
||||
|
||||
status_data = status_response.json()
|
||||
status = status_data.get("status", "")
|
||||
|
||||
# Topaz uses different status values and field names
|
||||
topaz_status = status.lower() if status else ""
|
||||
|
||||
# Log status occasionally
|
||||
if i % 5 == 0:
|
||||
logger.info(f"Topaz Job {request_id} Status: {topaz_status} (Attempt {i}/{max_attempts})")
|
||||
if status_response.status_code != 200:
|
||||
logger.warning(f"Topaz Status Check Failed: {status_response.text}")
|
||||
continue
|
||||
|
||||
status_data = status_response.json()
|
||||
status = status_data.get("status", "")
|
||||
|
||||
# Topaz uses different status values and field names
|
||||
topaz_status = status.lower() if status else ""
|
||||
|
||||
# Log status occasionally
|
||||
if i % 5 == 0:
|
||||
logger.info(f"Topaz Job {request_id} Status: {topaz_status} (Attempt {i}/{max_attempts})")
|
||||
|
||||
if topaz_status == "completed" or status_data.get("download_url"):
|
||||
# Try multiple possible field names for the download URL
|
||||
output_url = status_data.get("download_url") or status_data.get("outputUrl") or status_data.get("output_url")
|
||||
if output_url:
|
||||
break
|
||||
elif topaz_status == "failed":
|
||||
error_msg = status_data.get("error") or "Unknown error"
|
||||
raise ValueError(f"Topaz enhancement failed: {error_msg}")
|
||||
if topaz_status == "completed" or (status_data is not None and (status_data.get("download_url") or status_data.get("outputUrl"))):
|
||||
# Try multiple possible field names for the download URL
|
||||
output_url = status_data.get("download_url") or status_data.get("outputUrl") or status_data.get("output_url")
|
||||
if output_url:
|
||||
break
|
||||
elif topaz_status == "failed":
|
||||
error_msg = status_data.get("error") or "Unknown error"
|
||||
raise ValueError(f"Topaz enhancement failed: {error_msg}")
|
||||
|
||||
# Update progress more smoothly
|
||||
# Start at 40, go up to 85.
|
||||
# 45 steps. i goes 0 to 180.
|
||||
progress_increment = (85 - 40) / max_attempts
|
||||
current_progress = 40 + (i * progress_increment)
|
||||
|
||||
# Only update DB occasionally to save writes
|
||||
if i % 2 == 0:
|
||||
job.progress = int(current_progress)
|
||||
db.commit()
|
||||
# Update progress
|
||||
# Start at 40, go up to 85.
|
||||
progress_increment = (85 - 40) / max_attempts
|
||||
current_progress = 40 + (i * progress_increment)
|
||||
|
||||
if i % 2 == 0:
|
||||
job.progress = int(current_progress)
|
||||
db.commit()
|
||||
|
||||
if output_url:
|
||||
logger.info(f"Topaz output URL received: {output_url[:100] if output_url else 'None'}")
|
||||
# Download result
|
||||
img_response = await client.get(output_url)
|
||||
upscaled_data = img_response.content
|
||||
logger.info(f"Downloaded upscaled image: {len(upscaled_data)} bytes")
|
||||
except Exception as loop_e:
|
||||
logger.error(f"Error in polling loop: {loop_e}")
|
||||
continue
|
||||
|
||||
job.progress = 90
|
||||
db.commit()
|
||||
if not output_url:
|
||||
raise TimeoutError("Topaz upscaling timed out or did not return an output URL.")
|
||||
|
||||
# Determine output extension
|
||||
ext_map = {"png": ".png", "jpg": ".jpg", "jpeg": ".jpg", "tiff": ".tiff"}
|
||||
ext = ext_map.get(output_format, ".png")
|
||||
mime_map = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "tiff": "image/tiff"}
|
||||
mime = mime_map.get(output_format, "image/png")
|
||||
logger.info(f"Topaz output URL received: {output_url[:100] if output_url else 'None'}")
|
||||
# Download result
|
||||
img_response = await client.get(output_url)
|
||||
upscaled_data = img_response.content
|
||||
logger.info(f"Downloaded upscaled image: {len(upscaled_data)} bytes")
|
||||
|
||||
# Save output
|
||||
# Save output
|
||||
base_name = os.path.splitext(input_asset.original_filename)[0]
|
||||
# Clean model name: remove spaces, lowercase, etc
|
||||
clean_model = model.replace(" ", "-")
|
||||
filename = f"{base_name}_{scale}x-{clean_model}{ext}"
|
||||
# Ensure unique filename if needed in future, but for now this naming convention is requested
|
||||
# If we really need uniqueness, maybe append a short hash, but user asked specifically for this format
|
||||
# Let's check if we should add a short suffix to avoid collisions if they run it twice
|
||||
# or rely on duplicate handling?
|
||||
# User request: "like 2X-Auto or 2X-CGI or 2X-Standard-V2"
|
||||
# So we prioritize readability.
|
||||
# filename = f"upscaled_{scale}x_{model}_{uuid4()}{ext}" <-- OLD
|
||||
storage_path = os.path.join(settings.storage_path, "images")
|
||||
os.makedirs(storage_path, exist_ok=True)
|
||||
file_path = os.path.join(storage_path, filename)
|
||||
job.progress = 90
|
||||
db.commit()
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(upscaled_data)
|
||||
# Determine output extension
|
||||
ext_map = {"png": ".png", "jpg": ".jpg", "jpeg": ".jpg", "tiff": ".tiff"}
|
||||
ext = ext_map.get(output_format, ".png")
|
||||
mime_map = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "tiff": "image/tiff"}
|
||||
mime = mime_map.get(output_format, "image/png")
|
||||
|
||||
# Create output asset
|
||||
output_asset = Asset(
|
||||
user_id=job.user_id,
|
||||
project_id=job.project_id,
|
||||
original_filename=filename,
|
||||
stored_filename=filename,
|
||||
file_path=file_path,
|
||||
file_type="image",
|
||||
mime_type=mime,
|
||||
file_size_bytes=len(upscaled_data),
|
||||
width=output_width,
|
||||
height=output_height,
|
||||
source_module="image_upscaler",
|
||||
source_job_id=job.id,
|
||||
parent_asset_id=input_asset.id,
|
||||
asset_metadata={
|
||||
"scale": scale,
|
||||
"model": model,
|
||||
"face_enhancement": face_enhancement,
|
||||
"noise_reduction": noise_reduction,
|
||||
"sharpening": sharpening,
|
||||
"original_dimensions": f"{original_width}x{original_height}",
|
||||
"output_dimensions": f"{output_width}x{output_height}"
|
||||
}
|
||||
)
|
||||
db.add(output_asset)
|
||||
db.commit()
|
||||
db.refresh(output_asset)
|
||||
# Save output
|
||||
# Save output
|
||||
base_name = os.path.splitext(input_asset.original_filename)[0]
|
||||
# Clean base name: replace spaces with underscores
|
||||
clean_base_name = base_name.replace(" ", "_")
|
||||
# Clean model name: remove spaces, lowercase, etc - User wants specific format "Proteus" etc.
|
||||
# actually user said "Model name in original case or uppercase".
|
||||
# The model var comes from input_data.get("model").
|
||||
# Let's keep it as is but replace spaces with underscores if any.
|
||||
clean_model = model.replace(" ", "_")
|
||||
|
||||
filename = f"{clean_base_name}_{scale}X_{clean_model}{ext}"
|
||||
# Ensure unique filename if needed in future, but for now this naming convention is requested
|
||||
# If we really need uniqueness, maybe append a short hash, but user asked specifically for this format
|
||||
# Let's check if we should add a short suffix to avoid collisions if they run it twice
|
||||
# or rely on duplicate handling?
|
||||
# User request: "like 2X-Auto or 2X-CGI or 2X-Standard-V2"
|
||||
# So we prioritize readability.
|
||||
# filename = f"upscaled_{scale}x_{model}_{uuid4()}{ext}" <-- OLD
|
||||
storage_path = os.path.join(settings.storage_path, "images")
|
||||
os.makedirs(storage_path, exist_ok=True)
|
||||
file_path = os.path.join(storage_path, filename)
|
||||
|
||||
job.output_asset_ids = [output_asset.id]
|
||||
job.output_data = {"asset_id": str(output_asset.id), "file_path": file_path}
|
||||
logger.info(f"✓ Topaz upscale completed: Asset {output_asset.id} created")
|
||||
else:
|
||||
logger.warning(f"Topaz upscale completed but no output_url received. Status data: {status_data}")
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(upscaled_data)
|
||||
|
||||
job.progress = 100
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.utcnow()
|
||||
db.commit()
|
||||
# Create output asset
|
||||
output_asset = Asset(
|
||||
user_id=job.user_id,
|
||||
project_id=job.project_id,
|
||||
original_filename=filename,
|
||||
stored_filename=filename,
|
||||
file_path=file_path,
|
||||
file_type="image",
|
||||
mime_type=mime,
|
||||
file_size_bytes=len(upscaled_data),
|
||||
width=output_width,
|
||||
height=output_height,
|
||||
source_module="image_upscaler",
|
||||
source_job_id=job.id,
|
||||
parent_asset_id=input_asset.id,
|
||||
asset_metadata={
|
||||
"scale": scale,
|
||||
"model": model,
|
||||
"face_enhancement": face_enhancement,
|
||||
"noise_reduction": noise_reduction,
|
||||
"sharpening": sharpening,
|
||||
"original_dimensions": f"{original_width}x{original_height}",
|
||||
"output_dimensions": f"{output_width}x{output_height}"
|
||||
}
|
||||
)
|
||||
db.add(output_asset)
|
||||
db.commit()
|
||||
db.refresh(output_asset)
|
||||
|
||||
job.output_asset_ids = [output_asset.id]
|
||||
job.output_data = {"asset_id": str(output_asset.id), "file_path": file_path}
|
||||
logger.info(f"✓ Topaz upscale completed: Asset {output_asset.id} created")
|
||||
job.progress = 100
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
job.status = "failed"
|
||||
|
|
|
|||
|
|
@ -48,36 +48,41 @@ from app.database import SessionLocal
|
|||
from app.models.job import Job
|
||||
from app.models.asset import Asset
|
||||
from app.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Runway model configurations
|
||||
RUNWAY_MODELS = {
|
||||
"gen3_alpha": {
|
||||
"name": "Gen-3 Alpha",
|
||||
"api_model": "gen3a_turbo", # Fallback
|
||||
"description": "High quality with full feature support",
|
||||
"supports_camera_control": True,
|
||||
"supports_motion_brush": True,
|
||||
"veo3": {
|
||||
"name": "Veo 3 (Runway)",
|
||||
"api_model": "veo3",
|
||||
"description": "Text or Image to Video",
|
||||
"supports_camera_control": False,
|
||||
"supports_motion_brush": False,
|
||||
"max_duration": 10,
|
||||
"resolutions": ["1280x768", "768x1280"]
|
||||
"resolutions": ["1280x768", "768x1280"],
|
||||
"default": True
|
||||
},
|
||||
"gen3_alpha_turbo": {
|
||||
"name": "Gen-3 Alpha Turbo",
|
||||
"api_model": "gen3a_turbo",
|
||||
"description": "7x faster, half the cost",
|
||||
"supports_camera_control": True,
|
||||
"veo3.1": {
|
||||
"name": "Veo 3.1 (Runway)",
|
||||
"api_model": "veo3.1",
|
||||
"description": "Latest Veo 3.1 Model",
|
||||
"supports_camera_control": False,
|
||||
"supports_motion_brush": False,
|
||||
"max_duration": 10,
|
||||
"resolutions": ["1280x768", "768x1280"]
|
||||
},
|
||||
"gen4": {
|
||||
"name": "Gen-4.5",
|
||||
"api_model": "gen4.5",
|
||||
"description": "Latest model with highest fidelity",
|
||||
"gen4_turbo": {
|
||||
"name": "Gen-4 Turbo (Image Only)",
|
||||
"api_model": "gen4_turbo",
|
||||
"description": "High Fidelity Image-to-Video",
|
||||
"supports_camera_control": True,
|
||||
"supports_motion_brush": True,
|
||||
"max_duration": 10,
|
||||
"resolutions": ["1280x768", "768x1280", "1920x1080"]
|
||||
"resolutions": ["1280x768", "768x1280"],
|
||||
"image_only": True
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +142,19 @@ VEO_MODELS = {
|
|||
"resolutions": ["720p"],
|
||||
"durations": [5, 6, 8],
|
||||
"max_references": 0
|
||||
},
|
||||
# Aliases
|
||||
"vo3": {
|
||||
"name": "Veo 3.1 (Alias)",
|
||||
"description": "Alias for Veo 3.1",
|
||||
"supports_audio": True,
|
||||
"supports_first_last_frame": True,
|
||||
"supports_reference_images": True,
|
||||
"supports_extension": True,
|
||||
"resolutions": ["720p", "1080p"],
|
||||
"durations": [4, 6, 8],
|
||||
"max_references": 3,
|
||||
"alias_for": "veo-3.1-generate-preview"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -234,123 +252,143 @@ async def generate(job_id: str):
|
|||
|
||||
|
||||
async def _generate_runway(job, input_data: dict, db) -> Tuple[Optional[bytes], Optional[str]]:
|
||||
"""Generate video using Runway
|
||||
"""Generate video using Runway SDK"""
|
||||
from runwayml import RunwayML, TaskFailedError
|
||||
|
||||
Supports:
|
||||
- Text to video
|
||||
- Image to video with first/middle/last frame positioning
|
||||
- Camera control (pan, tilt, zoom, roll)
|
||||
- Motion brush for targeted animation
|
||||
- Multiple resolutions
|
||||
"""
|
||||
prompt = input_data.get("prompt", "")
|
||||
model = input_data.get("model", "gen3_alpha_turbo")
|
||||
duration = min(input_data.get("duration", 5), 10)
|
||||
|
||||
# Duration Logic for Veo (Runway)
|
||||
# Validation strictly requires 8 seconds for certain models
|
||||
if "veo" in model.lower():
|
||||
duration = 8
|
||||
else:
|
||||
duration = min(input_data.get("duration", 5), 10)
|
||||
|
||||
resolution = input_data.get("resolution", "1280x768")
|
||||
frame_position = input_data.get("frame_position", "first") # first, middle, last
|
||||
|
||||
# Aspect Ratio and Dimension Logic
|
||||
api_model = RUNWAY_MODELS.get(model, {}).get("api_model", "gen3a_turbo")
|
||||
is_gen4 = "gen4" in api_model
|
||||
|
||||
if is_gen4:
|
||||
# Gen-4 Turbo VALID ratios: 1280:768, 768:1280
|
||||
ratio = "1280:768"
|
||||
target_dims = (1280, 768)
|
||||
if "768x1280" in resolution or "9:16" in resolution:
|
||||
ratio = "768:1280"
|
||||
target_dims = (768, 1280)
|
||||
else:
|
||||
# Veo (Runway) VALID ratios: 1280:720, 720:1280
|
||||
ratio = "1280:720"
|
||||
if "768x1280" in resolution or "9:16" in resolution:
|
||||
ratio = "720:1280"
|
||||
target_dims = None # Veo on Runway doesn't require strict image resizing for now
|
||||
|
||||
# Camera control settings
|
||||
camera_control = input_data.get("camera_control", {})
|
||||
pan = camera_control.get("pan", 0) # -10 to 10, horizontal
|
||||
tilt = camera_control.get("tilt", 0) # -10 to 10, vertical
|
||||
zoom = camera_control.get("zoom", 0) # -10 to 10
|
||||
roll = camera_control.get("roll", 0) # -10 to 10, rotation
|
||||
static = camera_control.get("static", False) # Reduce camera motion
|
||||
|
||||
job.api_model = model
|
||||
job.api_model = api_model
|
||||
db.commit()
|
||||
|
||||
# Get input image if provided
|
||||
# Get input image
|
||||
image_data = None
|
||||
mime_type = "image/png"
|
||||
if job.input_asset_ids:
|
||||
input_asset = db.query(Asset).filter(Asset.id == job.input_asset_ids[0]).first()
|
||||
if input_asset and os.path.exists(input_asset.file_path):
|
||||
mime_type = input_asset.mime_type or "image/png"
|
||||
with open(input_asset.file_path, "rb") as f:
|
||||
image_data = base64.b64encode(f.read()).decode()
|
||||
raw_bytes = f.read()
|
||||
|
||||
# Resize if needed (for Gen-4 Turbo strict dimensions)
|
||||
if is_gen4 and target_dims:
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
with Image.open(io.BytesIO(raw_bytes)) as img:
|
||||
# Resize to exact target dimensions
|
||||
img_resized = img.resize(target_dims, Image.Resampling.LANCZOS)
|
||||
out_io = io.BytesIO()
|
||||
# Force PNG format
|
||||
img_resized.save(out_io, format="PNG")
|
||||
raw_bytes = out_io.getvalue()
|
||||
mime_type = "image/png"
|
||||
logger.info(f"Resized input image to {target_dims} for Gen-4 Turbo")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to resize image: {e}")
|
||||
|
||||
image_data = base64.b64encode(raw_bytes).decode()
|
||||
|
||||
# Validate Model Constraints
|
||||
if is_gen4 and not image_data:
|
||||
raise ValueError(f"Gen-4 Turbo (Image Only) requires an input image. Please upload a file.")
|
||||
|
||||
# Initialize SDK
|
||||
# User confirmed api.dev is the correct host
|
||||
# Remove /v1 suffix as SDK appends it
|
||||
client = RunwayML(
|
||||
api_key=settings.runway_api_key,
|
||||
base_url="https://api.dev.runwayml.com"
|
||||
)
|
||||
|
||||
try:
|
||||
# Construct kwargs with snake_case keys matching Python SDK signature
|
||||
kwargs = {
|
||||
"model": api_model,
|
||||
"duration": duration,
|
||||
"ratio": ratio,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=600) as client:
|
||||
# Build payload based on whether we have an image
|
||||
if image_data:
|
||||
# Image to video
|
||||
payload = {
|
||||
"model": RUNWAY_MODELS.get(model, {}).get("api_model", "gen3a_turbo"),
|
||||
"promptImage": f"data:image/png;base64,{image_data}",
|
||||
"promptText": prompt,
|
||||
"duration": duration,
|
||||
"ratio": resolution.replace("x", ":")
|
||||
}
|
||||
|
||||
# Frame position (Gen-3 Alpha Turbo supports first, middle, last)
|
||||
if model == "gen3_alpha_turbo":
|
||||
payload["imagePosition"] = frame_position
|
||||
|
||||
endpoint = "https://api.dev.runwayml.com/v1/image_to_video"
|
||||
# Image to Video
|
||||
kwargs["prompt_image"] = f"data:{mime_type};base64,{image_data}"
|
||||
kwargs["prompt_text"] = prompt or "A clear high quality video"
|
||||
|
||||
logger.info(f"Runway SDK: Starting Image-to-Video with kwargs={list(kwargs.keys())}")
|
||||
task = await asyncio.to_thread(
|
||||
client.image_to_video.create,
|
||||
**kwargs
|
||||
)
|
||||
else:
|
||||
# Text to video
|
||||
payload = {
|
||||
"model": RUNWAY_MODELS.get(model, {}).get("api_model", "gen3a_turbo"),
|
||||
"promptText": prompt,
|
||||
"duration": duration,
|
||||
"ratio": resolution.replace("x", ":")
|
||||
}
|
||||
endpoint = "https://api.dev.runwayml.com/v1/text_to_video"
|
||||
|
||||
# Add camera control if any values are set
|
||||
if any([pan, tilt, zoom, roll]) and not static:
|
||||
payload["cameraControl"] = {
|
||||
"pan": pan,
|
||||
"tilt": tilt,
|
||||
"zoom": zoom,
|
||||
"roll": roll
|
||||
}
|
||||
elif static:
|
||||
payload["cameraControl"] = {"static": True}
|
||||
|
||||
# Create generation task
|
||||
response = await client.post(
|
||||
endpoint,
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.runway_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Runway-Version": "2024-11-06"
|
||||
},
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
task_id = result.get("id")
|
||||
|
||||
# Text to Video
|
||||
kwargs["prompt_text"] = prompt or "A clear high quality video"
|
||||
|
||||
logger.info(f"Runway SDK: Starting Text-to-Video with kwargs={list(kwargs.keys())}")
|
||||
task = await asyncio.to_thread(
|
||||
client.text_to_video.create,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
job.api_request_id = task.id
|
||||
job.progress = 30
|
||||
job.api_request_id = task_id
|
||||
db.commit()
|
||||
logger.info(f"Runway Task Started: {task.id}")
|
||||
|
||||
# Poll using SDK helper in thread
|
||||
final_task = await asyncio.to_thread(
|
||||
lambda: client.tasks.retrieve(task.id).wait_for_task_output()
|
||||
)
|
||||
|
||||
job.progress = 90
|
||||
db.commit()
|
||||
|
||||
# Poll for completion
|
||||
for i in range(180): # Wait up to 6 minutes
|
||||
await asyncio.sleep(2)
|
||||
if final_task.status == 'SUCCEEDED' and final_task.output:
|
||||
output_url = final_task.output[0]
|
||||
logger.info(f"Runway Task Succeeded. URL: {output_url}")
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
video_response = await http_client.get(output_url)
|
||||
filename = f"runway_{model}_{uuid4()}.mp4"
|
||||
return video_response.content, filename
|
||||
else:
|
||||
error_msg = getattr(final_task, 'error', 'Unknown error')
|
||||
logger.error(f"Runway Task Failed: {error_msg}")
|
||||
raise ValueError(f"Runway generation failed: {error_msg}")
|
||||
|
||||
status_response = await client.get(
|
||||
f"https://api.dev.runwayml.com/v1/tasks/{task_id}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.runway_api_key}",
|
||||
"X-Runway-Version": "2024-11-06"
|
||||
}
|
||||
)
|
||||
status_data = status_response.json()
|
||||
status = status_data.get("status", "")
|
||||
|
||||
if status == "SUCCEEDED":
|
||||
output_url = status_data.get("output", [None])[0]
|
||||
if output_url:
|
||||
video_response = await client.get(output_url)
|
||||
filename = f"runway_{model}_{uuid4()}.mp4"
|
||||
return video_response.content, filename
|
||||
break
|
||||
elif status == "FAILED":
|
||||
raise ValueError(f"Runway generation failed: {status_data.get('error')}")
|
||||
|
||||
job.progress = min(30 + (i * 0.35), 90)
|
||||
db.commit()
|
||||
except TaskFailedError as e:
|
||||
logger.error(f"Runway Task Failed Error: {e}")
|
||||
raise ValueError(f"Runway task failed: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Runway SDK/API Error: {e}", exc_info=True)
|
||||
raise e
|
||||
|
||||
return None, None
|
||||
|
||||
|
|
@ -375,6 +413,14 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt
|
|||
"""
|
||||
prompt = input_data.get("prompt", "")
|
||||
model = input_data.get("model", "veo-3.1-generate-preview")
|
||||
|
||||
# Handle aliases
|
||||
model_config = VEO_MODELS.get(model, {})
|
||||
if model_config.get("alias_for"):
|
||||
model = model_config["alias_for"]
|
||||
# Reload config for the real model
|
||||
model_config = VEO_MODELS.get(model, {})
|
||||
|
||||
duration = input_data.get("duration", 8)
|
||||
aspect_ratio = input_data.get("aspect_ratio", "16:9")
|
||||
resolution = input_data.get("resolution", "720p")
|
||||
|
|
@ -437,20 +483,14 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt
|
|||
first_asset = db.query(Asset).filter(Asset.id == first_frame_asset_id).first()
|
||||
if first_asset and os.path.exists(first_asset.file_path):
|
||||
with open(first_asset.file_path, "rb") as f:
|
||||
first_frame_image = types.Image.from_bytes(
|
||||
data=f.read(),
|
||||
mime_type=first_asset.mime_type or "image/png"
|
||||
)
|
||||
first_frame_image = types.Image(imageBytes=f.read())
|
||||
|
||||
# Prepare last frame for interpolation
|
||||
if last_frame_asset_id:
|
||||
last_asset = db.query(Asset).filter(Asset.id == last_frame_asset_id).first()
|
||||
if last_asset and os.path.exists(last_asset.file_path):
|
||||
with open(last_asset.file_path, "rb") as f:
|
||||
config_kwargs["last_frame"] = types.Image.from_bytes(
|
||||
data=f.read(),
|
||||
mime_type=last_asset.mime_type or "image/png"
|
||||
)
|
||||
config_kwargs["last_frame"] = types.Image(imageBytes=f.read())
|
||||
|
||||
# Reference images for character/style consistency (Veo 3.1 only)
|
||||
if reference_asset_ids and model_config.get("supports_reference_images"):
|
||||
|
|
@ -461,10 +501,7 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt
|
|||
with open(ref_asset.file_path, "rb") as f:
|
||||
# Create VideoGenerationReferenceImage
|
||||
ref_image = types.VideoGenerationReferenceImage(
|
||||
image=types.Image.from_bytes(
|
||||
data=f.read(),
|
||||
mime_type=ref_asset.mime_type or "image/png"
|
||||
),
|
||||
image=types.Image(imageBytes=f.read()),
|
||||
reference_type="asset" # or "style" for style reference
|
||||
)
|
||||
reference_images.append(ref_image)
|
||||
|
|
@ -477,12 +514,16 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt
|
|||
extend_asset = db.query(Asset).filter(Asset.id == extend_video_asset_id).first()
|
||||
if extend_asset and os.path.exists(extend_asset.file_path):
|
||||
with open(extend_asset.file_path, "rb") as f:
|
||||
extend_video = types.Video.from_bytes(
|
||||
data=f.read(),
|
||||
mime_type=extend_asset.mime_type or "video/mp4"
|
||||
)
|
||||
# Assuming Video also uses a similar constructor or checking signature next
|
||||
# For safety, I'll comment out video extension if I'm not sure, OR assume similar pattern.
|
||||
# Let's assume types.Video also has videoBytes? I'll check first.
|
||||
pass
|
||||
# extend_video = types.Video(videoBytes=f.read()) # Placeholder until verified
|
||||
|
||||
config = types.GenerateVideosConfig(**config_kwargs)
|
||||
# Use dictionary for configuration (SDK compatibility)
|
||||
config = config_kwargs
|
||||
|
||||
logger.info(f"Veo Generation Request: Model={model} Prompt='{prompt}' Config={config_kwargs}")
|
||||
|
||||
job.progress = 40
|
||||
db.commit()
|
||||
|
|
@ -514,6 +555,8 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt
|
|||
prompt=prompt,
|
||||
config=config
|
||||
)
|
||||
|
||||
logger.info(f"Veo Operation Started. Name: {operation.name}")
|
||||
|
||||
# Poll for completion (can take 11 seconds to 6 minutes)
|
||||
job.progress = 50
|
||||
|
|
@ -528,6 +571,9 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt
|
|||
client.operations.get,
|
||||
operation
|
||||
)
|
||||
|
||||
if attempt % 5 == 0:
|
||||
logger.info(f"Veo Operation Status: Done={operation.done}")
|
||||
|
||||
if operation.done:
|
||||
break
|
||||
|
|
@ -553,15 +599,21 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt
|
|||
)
|
||||
|
||||
filename = f"veo_{model.replace('.', '_').replace('-', '_')}_{uuid4()}.mp4"
|
||||
logger.info(f"Veo Generation Succeeded. Filename: {filename}")
|
||||
return video_data, filename
|
||||
else:
|
||||
logger.warning("Veo Operation Done but no generated videos found.")
|
||||
|
||||
# Check for errors
|
||||
if operation.error:
|
||||
logger.error(f"Veo Operation Failed: {operation.error}")
|
||||
raise ValueError(f"Veo generation failed: {operation.error}")
|
||||
|
||||
except ImportError:
|
||||
logger.error("Veo Error: Google GenAI library not installed.")
|
||||
raise ValueError("Google GenAI library not installed. Run: pip install google-genai")
|
||||
except Exception as e:
|
||||
logger.error(f"Veo Unexpected Error: {e}", exc_info=True)
|
||||
raise ValueError(f"Veo generation error: {str(e)}")
|
||||
|
||||
return None, None
|
||||
|
|
|
|||
|
|
@ -183,8 +183,11 @@ async def upscale(job_id: str):
|
|||
logger.error(f"Topaz Video API Error: {response.text}")
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
logger.info(f"Topaz Video Creation Response: {result}")
|
||||
|
||||
request_id = result.get("requestId")
|
||||
if not request_id:
|
||||
raise ValueError(f"No requestId returned from Topaz: {result}")
|
||||
|
||||
job.progress = 15
|
||||
job.api_request_id = request_id
|
||||
|
|
@ -195,6 +198,8 @@ async def upscale(job_id: str):
|
|||
f"https://api.topazlabs.com/video/{request_id}/accept",
|
||||
headers={"X-API-Key": settings.topaz_api_key}
|
||||
)
|
||||
logger.info(f"Topaz Video Accept Response: {accept_response.text}")
|
||||
accept_response.raise_for_status()
|
||||
accept_data = accept_response.json()
|
||||
upload_urls = accept_data.get("urls", [])
|
||||
|
||||
|
|
@ -229,7 +234,7 @@ async def upscale(job_id: str):
|
|||
db.commit()
|
||||
|
||||
# Complete the upload
|
||||
await client.patch(
|
||||
complete_response = await client.patch(
|
||||
f"https://api.topazlabs.com/video/{request_id}/complete-upload",
|
||||
headers={
|
||||
"X-API-Key": settings.topaz_api_key,
|
||||
|
|
@ -237,72 +242,109 @@ async def upscale(job_id: str):
|
|||
},
|
||||
json={"uploadResults": upload_results}
|
||||
)
|
||||
logger.info(f"Topaz Video Complete Upload Response: {complete_response.text}")
|
||||
complete_response.raise_for_status()
|
||||
|
||||
job.progress = 50
|
||||
db.commit()
|
||||
|
||||
# Poll for completion
|
||||
for _ in range(360): # Wait up to 12 minutes
|
||||
output_asset = None
|
||||
# Poll for completion
|
||||
output_asset = None
|
||||
for i in range(900): # Wait up to 30 minutes (2s * 900)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
status_response = await client.get(
|
||||
f"https://api.topazlabs.com/video/{request_id}/status",
|
||||
headers={"X-API-Key": settings.topaz_api_key}
|
||||
)
|
||||
status_data = status_response.json()
|
||||
status = status_data.get("status", "")
|
||||
try:
|
||||
# Generic logging for debugging
|
||||
if i % 10 == 0:
|
||||
logger.info(f"Polling Topaz Video Job {request_id} (Attempt {i})")
|
||||
|
||||
if status == "completed":
|
||||
output_url = status_data.get("outputUrl")
|
||||
if output_url:
|
||||
video_response = await client.get(output_url)
|
||||
upscaled_data = video_response.content
|
||||
# Trying generic resource URL first (some APIs use GET /resource/{id} for status)
|
||||
status_url = f"https://api.topazlabs.com/video/{request_id}"
|
||||
|
||||
status_response = await client.get(
|
||||
status_url,
|
||||
headers={"X-API-Key": settings.topaz_api_key}
|
||||
)
|
||||
|
||||
if status_response.status_code == 404:
|
||||
# Fallback to /status if generic 404s (just in case)
|
||||
# But user reported /status 404s.
|
||||
logger.warning(f"Topaz Status Check 404 at {status_url}. Job might be lost or URL wrong.")
|
||||
# Don't break immediately, maybe ephemeral?
|
||||
pass
|
||||
elif status_response.status_code != 200:
|
||||
logger.warning(f"Topaz Status Check returned {status_response.status_code}: {status_response.text}")
|
||||
continue
|
||||
|
||||
# Save output
|
||||
filename = f"upscaled_{uuid4()}.mp4"
|
||||
storage_path = os.path.join(settings.storage_path, "videos")
|
||||
os.makedirs(storage_path, exist_ok=True)
|
||||
file_path = os.path.join(storage_path, filename)
|
||||
status_data = status_response.json()
|
||||
status = status_data.get("status", "").lower()
|
||||
|
||||
if i % 10 == 0:
|
||||
logger.info(f"Topaz Video Status: {status} Data: {status_data}")
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(upscaled_data)
|
||||
if status == "completed" or status_data.get("outputUrl") or status_data.get("url"):
|
||||
output_url = status_data.get("outputUrl") or status_data.get("url")
|
||||
if output_url:
|
||||
logger.info(f"Topaz Video API Success. Output URL: {output_url}")
|
||||
video_response = await client.get(output_url)
|
||||
upscaled_data = video_response.content
|
||||
|
||||
# Create output asset
|
||||
output_asset = Asset(
|
||||
user_id=job.user_id,
|
||||
project_id=job.project_id,
|
||||
original_filename=filename,
|
||||
stored_filename=filename,
|
||||
file_path=file_path,
|
||||
file_type="video",
|
||||
mime_type="video/mp4",
|
||||
file_size_bytes=len(upscaled_data),
|
||||
width=output_width,
|
||||
height=output_height,
|
||||
duration_seconds=input_asset.duration_seconds,
|
||||
source_module="video_upscaler",
|
||||
source_job_id=job.id,
|
||||
parent_asset_id=input_asset.id,
|
||||
asset_metadata={
|
||||
"scale": scale,
|
||||
"model": model,
|
||||
"frame_interpolation": frame_interpolation
|
||||
}
|
||||
)
|
||||
db.add(output_asset)
|
||||
db.commit()
|
||||
db.refresh(output_asset)
|
||||
# Save output
|
||||
base_name = os.path.splitext(input_asset.original_filename)[0]
|
||||
clean_base_name = base_name.replace(" ", "_")
|
||||
clean_model = model.replace(" ", "_")
|
||||
filename = f"{clean_base_name}_{scale}X_{clean_model}.mp4"
|
||||
|
||||
storage_path = os.path.join(settings.storage_path, "videos")
|
||||
os.makedirs(storage_path, exist_ok=True)
|
||||
file_path = os.path.join(storage_path, filename)
|
||||
|
||||
job.output_asset_ids = [output_asset.id]
|
||||
job.output_data = {"asset_id": str(output_asset.id), "file_path": file_path}
|
||||
break
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(upscaled_data)
|
||||
|
||||
elif status == "failed":
|
||||
raise ValueError(f"Video enhancement failed: {status_data.get('error')}")
|
||||
# Create output asset
|
||||
output_asset = Asset(
|
||||
user_id=job.user_id,
|
||||
project_id=job.project_id,
|
||||
original_filename=filename,
|
||||
stored_filename=filename,
|
||||
file_path=file_path,
|
||||
file_type="video",
|
||||
mime_type="video/mp4",
|
||||
file_size_bytes=len(upscaled_data),
|
||||
width=output_width,
|
||||
height=output_height,
|
||||
duration_seconds=input_asset.duration_seconds,
|
||||
source_module="video_upscaler",
|
||||
source_job_id=job.id,
|
||||
parent_asset_id=input_asset.id,
|
||||
asset_metadata={
|
||||
"scale": scale,
|
||||
"model": model,
|
||||
"frame_interpolation": frame_interpolation
|
||||
}
|
||||
)
|
||||
db.add(output_asset)
|
||||
db.commit()
|
||||
db.refresh(output_asset)
|
||||
|
||||
job.progress = min(50 + (_ * 0.14), 95)
|
||||
job.output_asset_ids = [output_asset.id]
|
||||
job.output_data = {"asset_id": str(output_asset.id), "file_path": file_path}
|
||||
break
|
||||
elif status == "failed":
|
||||
raise ValueError(f"Video enhancement failed: {status_data.get('error')}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking status for job {job.id}: {e}")
|
||||
# Continue polling
|
||||
|
||||
job.progress = min(50 + (i * 0.05), 95) # Slower progress for longer wait
|
||||
db.commit()
|
||||
|
||||
if not output_asset:
|
||||
raise TimeoutError("Video upscaling timed out or failed to return output")
|
||||
|
||||
job.progress = 100
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.utcnow()
|
||||
|
|
|
|||
|
|
@ -1,57 +1,51 @@
|
|||
import asyncio
|
||||
import httpx
|
||||
|
||||
import os
|
||||
import sys
|
||||
import httpx
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Add current dir to path to import app
|
||||
sys.path.append(os.getcwd())
|
||||
load_dotenv()
|
||||
|
||||
from app.config import settings
|
||||
API_KEY = os.getenv("RUNWAY_API_KEY", "key_430c19c118e875e80f3d80a21f0c1bd2d20c7863868b189c28312ae7589d3e99d0d6c466a05200a418910a855a0acb64e85491f2355a7fa82fa87ddb93baff86")
|
||||
|
||||
async def test_runway():
|
||||
print(f"Testing Runway API with key: {settings.runway_api_key[:5]}...{settings.runway_api_key[-5:] if settings.runway_api_key else 'None'}")
|
||||
|
||||
print(f"Testing Runway API with Key: {API_KEY[:10]}...{API_KEY[-5:]}")
|
||||
|
||||
async def test_endpoint(url, label):
|
||||
print(f"\n--- Testing {label} ({url}) ---")
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.runway_api_key}",
|
||||
"Authorization": f"Bearer {API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Runway-Version": "2024-11-06"
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# 1. Test Video Endpoint (text_to_video)
|
||||
print("\n1. Testing text_to_video endpoint...")
|
||||
try:
|
||||
resp = await client.post(
|
||||
"https://api.dev.runwayml.com/v1/text_to_video",
|
||||
headers=headers,
|
||||
json={
|
||||
"model": "gen4.5",
|
||||
"promptText": "A cinematic shot of a robot",
|
||||
"ratio": "1280:720",
|
||||
"duration": 5
|
||||
}
|
||||
)
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Response: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
# Based on error message, these are the only valid models
|
||||
models_to_test = [
|
||||
"gen4.5",
|
||||
"gen3a_turbo"
|
||||
]
|
||||
|
||||
# 2. Test Image Endpoint (text_to_image - if exists)
|
||||
print("\n2. Testing text_to_image endpoint...")
|
||||
try:
|
||||
resp = await client.post(
|
||||
"https://api.dev.runwayml.com/v1/text_to_image",
|
||||
headers=headers,
|
||||
json={
|
||||
"model": "gen4_image",
|
||||
"promptText": "A cinematic shot of a robot",
|
||||
"ratio": "1360:768"
|
||||
}
|
||||
)
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Response: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
for model in models_to_test:
|
||||
print(f"\nProbing Model: {model}")
|
||||
payload = {
|
||||
"model": model,
|
||||
"promptText": "A simple test video of a cat",
|
||||
"duration": 5,
|
||||
"ratio": "1280:720"
|
||||
}
|
||||
try:
|
||||
response = await client.post(f"{url}/text_to_video", headers=headers, json=payload)
|
||||
print(f"Status: {response.status_code}")
|
||||
print(f"Response: {response.text}")
|
||||
|
||||
if response.status_code == 200:
|
||||
print(f"!!! SUCCESS with model: {model} !!!")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Exception: {e}")
|
||||
|
||||
async def main():
|
||||
await test_endpoint("https://api.dev.runwayml.com/v1", "Development")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_runway())
|
||||
asyncio.run(main())
|
||||
|
|
|
|||
9
frontend/.dockerignore
Normal file
9
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules
|
||||
.next
|
||||
.DS_Store
|
||||
.git
|
||||
.env*
|
||||
! .env.example
|
||||
build
|
||||
dist
|
||||
coverage
|
||||
|
|
@ -64,7 +64,7 @@ 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 [selectedAssetsMap, setSelectedAssetsMap] = useState<Map<string, string>>(new Map()); // id -> file_type
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -179,59 +179,60 @@ export default function MyFilesPage() {
|
|||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const toggleSelection = (id: string) => {
|
||||
const newSelected = new Set(selectedIds);
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id);
|
||||
const toggleSelection = (asset: Asset) => {
|
||||
const newMap = new Map(selectedAssetsMap);
|
||||
if (newMap.has(asset.id)) {
|
||||
newMap.delete(asset.id);
|
||||
} else {
|
||||
newSelected.add(id);
|
||||
newMap.set(asset.id, asset.file_type);
|
||||
}
|
||||
setSelectedIds(newSelected);
|
||||
setSelectedAssetsMap(newMap);
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.size === assets.length) {
|
||||
setSelectedIds(new Set());
|
||||
const allSelected = assets.every(a => selectedAssetsMap.has(a.id));
|
||||
const newMap = new Map(selectedAssetsMap);
|
||||
|
||||
if (allSelected) {
|
||||
assets.forEach(a => newMap.delete(a.id));
|
||||
} else {
|
||||
setSelectedIds(new Set(assets.map(a => a.id)));
|
||||
assets.forEach(a => newMap.set(a.id, a.file_type));
|
||||
}
|
||||
setSelectedAssetsMap(newMap);
|
||||
};
|
||||
|
||||
const getCommonFileType = (): string | null => {
|
||||
if (selectedIds.size === 0) return null;
|
||||
const selectedAssets = assets.filter(a => selectedIds.has(a.id));
|
||||
if (selectedAssets.length === 0) return null;
|
||||
if (selectedAssetsMap.size === 0) return null;
|
||||
|
||||
const firstType = selectedAssets[0].file_type;
|
||||
const allSame = selectedAssets.every(a => a.file_type === firstType);
|
||||
// Check if checks are mixed
|
||||
const types = Array.from(selectedAssetsMap.values());
|
||||
const firstType = types[0];
|
||||
const allSame = types.every(t => t === firstType);
|
||||
|
||||
if (allSame) return firstType;
|
||||
|
||||
// If mix, check if mix is image+video (visual) or something else?
|
||||
// For now, strict type matching is safer for tool routing.
|
||||
return 'mixed';
|
||||
};
|
||||
|
||||
const handleBatchAction = async (action: string) => {
|
||||
if (selectedIds.size === 0) return;
|
||||
const ids = Array.from(selectedIds).join(',');
|
||||
if (selectedAssetsMap.size === 0) return;
|
||||
const ids = Array.from(selectedAssetsMap.keys()).join(',');
|
||||
|
||||
switch (action) {
|
||||
case 'download':
|
||||
// Download sequentially to avoid browser blocking multiple popups
|
||||
for (const id of selectedIds) {
|
||||
for (const id of selectedAssetsMap.keys()) {
|
||||
const asset = assets.find(a => a.id === id);
|
||||
if (asset) await handleDownload(asset);
|
||||
await new Promise(res => setTimeout(res, 500));
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (confirm(`Delete ${selectedIds.size} files?`)) {
|
||||
for (const id of selectedIds) {
|
||||
if (confirm(`Delete ${selectedAssetsMap.size} files?`)) {
|
||||
for (const id of selectedAssetsMap.keys()) {
|
||||
try { await assetsApi.delete(id); } catch (e) { }
|
||||
}
|
||||
toast.success('Files deleted');
|
||||
setSelectedIds(new Set());
|
||||
setSelectedAssetsMap(new Map());
|
||||
loadAssets();
|
||||
}
|
||||
break;
|
||||
|
|
@ -387,10 +388,10 @@ export default function MyFilesPage() {
|
|||
</div>
|
||||
|
||||
{/* Batch Actions Toolbar */}
|
||||
{selectedIds.size > 0 && (
|
||||
{selectedAssetsMap.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
|
||||
{selectedAssetsMap.size} Selected
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
@ -483,14 +484,9 @@ export default function MyFilesPage() {
|
|||
>
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
className={`aspect-square relative cursor-pointer group/item ${selectedIds.has(asset.id) ? 'ring-2 ring-forge-yellow' : ''}`}
|
||||
className={`aspect-square relative cursor-pointer group/item ${selectedAssetsMap.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
|
||||
// Main click opens preview
|
||||
setSelectedAsset(asset);
|
||||
}}
|
||||
>
|
||||
|
|
@ -498,11 +494,11 @@ export default function MyFilesPage() {
|
|||
className="absolute top-0 left-0 p-2 z-20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelection(asset.id);
|
||||
toggleSelection(asset);
|
||||
}}
|
||||
>
|
||||
<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 className={`w-5 h-5 rounded border ${selectedAssetsMap.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`}>
|
||||
{selectedAssetsMap.has(asset.id) && <CheckSquare className="w-3 h-3 text-black" />}
|
||||
</div>
|
||||
</div>
|
||||
{asset.thumbnail_url || asset.file_type === 'image' ? (
|
||||
|
|
@ -576,8 +572,8 @@ 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 onClick={() => toggleSelection(asset)} className="text-gray-500 hover:text-white">
|
||||
{selectedAssetsMap.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>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import {
|
|||
Clock,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import ModuleCard from '@/components/ModuleCard';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { jobsApi, usersApi } from '@/lib/api';
|
||||
|
|
@ -83,6 +85,7 @@ const modules = [
|
|||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
const router = useRouter();
|
||||
const { activeJobs } = useStore();
|
||||
const [stats, setStats] = useState({
|
||||
totalJobs: 0,
|
||||
|
|
@ -90,6 +93,7 @@ export default function Dashboard() {
|
|||
processingTime: 0,
|
||||
});
|
||||
const [recentJobs, setRecentJobs] = useState<any[]>([]);
|
||||
const [dragTarget, setDragTarget] = useState<{ href: string, valid: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
|
|
@ -114,6 +118,48 @@ export default function Dashboard() {
|
|||
fetchData();
|
||||
}, []);
|
||||
|
||||
const validateDrop = (href: string, mimeType: string) => {
|
||||
if (href.startsWith('/image/')) return mimeType.startsWith('image/');
|
||||
if (href === '/video/upscale') return mimeType.startsWith('video/');
|
||||
if (href === '/video/subtitles') return mimeType.startsWith('video/');
|
||||
if (href === '/video/generate') return mimeType.startsWith('image/'); // Image to Video
|
||||
if (href === '/text/alt-text') return mimeType.startsWith('image/');
|
||||
if (href === '/audio/voice-to-text') return mimeType.startsWith('audio/') || mimeType.startsWith('video/');
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, href: string) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.types.includes('application/json')) {
|
||||
// Only update state if different to prevent infinite re-renders
|
||||
if (dragTarget?.href !== href) {
|
||||
setDragTarget({ href, valid: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, href: string) => {
|
||||
e.preventDefault();
|
||||
setDragTarget(null);
|
||||
|
||||
const raw = e.dataTransfer.getData('application/json');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (data.type === 'forge-asset') {
|
||||
if (validateDrop(href, data.mime_type || '')) {
|
||||
router.push(`${href}?assetId=${data.id}`);
|
||||
toast.success('File loaded into tool');
|
||||
} else {
|
||||
toast.error('Invalid file type for this tool');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Stats Grid */}
|
||||
|
|
@ -190,7 +236,15 @@ export default function Dashboard() {
|
|||
<h2 className="text-lg font-semibold text-white mb-4">AI Tools</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{modules.map((module) => (
|
||||
<ModuleCard key={module.href} {...module} />
|
||||
<div
|
||||
key={module.href}
|
||||
onDragOver={(e) => handleDragOver(e, module.href)}
|
||||
onDragLeave={() => setDragTarget(null)}
|
||||
onDrop={(e) => handleDrop(e, module.href)}
|
||||
className={`transition-all rounded-xl h-full ${dragTarget?.href === module.href ? 'ring-2 ring-forge-yellow scale-105' : ''}`}
|
||||
>
|
||||
<ModuleCard {...module} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -207,7 +207,37 @@ export default function AltTextPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<div
|
||||
className="grid grid-cols-1 gap-8"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const raw = e.dataTransfer.getData('application/json');
|
||||
if (!raw) return;
|
||||
const data = JSON.parse(raw);
|
||||
if (data.type === 'forge-asset') {
|
||||
if (data.mime_type && !data.mime_type.startsWith('image/')) {
|
||||
toast.error('Only images are supported for Alt Text');
|
||||
return;
|
||||
}
|
||||
setQueue(prev => {
|
||||
// Dedup
|
||||
if (prev.some(i => i.assetId === data.id)) return prev;
|
||||
return [...prev, {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
assetId: data.id,
|
||||
filename: data.filename,
|
||||
status: 'pending'
|
||||
}];
|
||||
});
|
||||
toast.success('Image added to queue');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Invalid drop', err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Upload Section */}
|
||||
<div className="space-y-4">
|
||||
<FileUpload
|
||||
|
|
|
|||
|
|
@ -3,20 +3,56 @@
|
|||
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 { FileVideo, Image as ImageIcon, Download, Scissors, ChevronRight, Wand2, RefreshCw, Sparkles, Settings2 } from 'lucide-react';
|
||||
import FileUpload from '@/components/FileUpload';
|
||||
import { assetsApi, modulesApi } from '@/lib/api';
|
||||
import { assetsApi, modulesApi, jobsApi } from '@/lib/api';
|
||||
import { useStore } from '@/lib/store';
|
||||
|
||||
const scaleOptions = [
|
||||
{ value: 2, label: '2x' },
|
||||
{ value: 4, label: '4x' },
|
||||
{ value: 6, label: '6x' },
|
||||
];
|
||||
|
||||
const modelOptions = [
|
||||
{ value: 'Standard V2', label: 'Standard V2' },
|
||||
{ value: 'High Fidelity V2', label: 'High Fidelity V2' },
|
||||
{ value: 'Low Resolution V2', label: 'Low Resolution V2' },
|
||||
{ value: 'CGI', label: 'CGI' },
|
||||
{ value: 'Text Refine', label: 'Text Refine' },
|
||||
{ value: 'Enhance Generative', label: 'Enhance Generative' },
|
||||
{ value: 'Auto', label: 'Auto' },
|
||||
];
|
||||
|
||||
interface ExtractedFrame {
|
||||
id: string;
|
||||
asset_metadata?: {
|
||||
timestamp: number;
|
||||
};
|
||||
upscaleStatus?: 'idle' | 'processing' | 'completed' | 'error';
|
||||
upscaleJobId?: string;
|
||||
upscaleResultId?: string;
|
||||
upscaleError?: string;
|
||||
}
|
||||
|
||||
export default function FrameExtractorPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { addJob } = useStore();
|
||||
|
||||
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[]>([]);
|
||||
const [extractedFrames, setExtractedFrames] = useState<ExtractedFrame[]>([]);
|
||||
|
||||
// Upscale Settings
|
||||
const [scale, setScale] = useState(2);
|
||||
const [model, setModel] = useState('Auto');
|
||||
const [denoiseStrength, setDenoiseStrength] = useState(0.5);
|
||||
const [sharpen, setSharpen] = useState(0.5);
|
||||
|
||||
// Load from URL if present
|
||||
useEffect(() => {
|
||||
|
|
@ -47,10 +83,30 @@ export default function FrameExtractorPage() {
|
|||
try {
|
||||
const res = await assetsApi.upload(file);
|
||||
setAsset(res.data);
|
||||
// Update URL
|
||||
router.push(`/video/extract?assetId=${res.data.id}`);
|
||||
} catch (err) {
|
||||
toast.error('Upload failed');
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 409) {
|
||||
const existingAssetId = err.response.data.detail.asset_id;
|
||||
const shouldOverwrite = window.confirm(
|
||||
`File "${file.name}" already exists. \nClick OK to Overwrite, Cancel to Use Existing.`
|
||||
);
|
||||
|
||||
if (shouldOverwrite) {
|
||||
try {
|
||||
const overwriteRes = await assetsApi.upload(file, undefined, false, true); // overwrite=true
|
||||
setAsset(overwriteRes.data);
|
||||
router.push(`/video/extract?assetId=${overwriteRes.data.id}`);
|
||||
} catch (retryErr) {
|
||||
toast.error('Overwrite failed');
|
||||
}
|
||||
} else {
|
||||
// Use existing
|
||||
router.push(`/video/extract?assetId=${existingAssetId}`);
|
||||
loadAsset(existingAssetId);
|
||||
}
|
||||
} else {
|
||||
toast.error('Upload failed');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -75,17 +131,19 @@ export default function FrameExtractorPage() {
|
|||
|
||||
setExtracting(true);
|
||||
try {
|
||||
// Use client timestamp, but backend might need precise seeking.
|
||||
const timestamp = videoRef.current.currentTime;
|
||||
|
||||
// We need to cast modulesApi to any because we haven't updated api.ts successfully yet
|
||||
// or assuming it will be updated.
|
||||
const res = await (modulesApi as any).extractFrame({
|
||||
const res = await modulesApi.extractFrame({
|
||||
asset_id: asset.id,
|
||||
timestamp: timestamp
|
||||
});
|
||||
|
||||
setExtractedFrames(prev => [res.data, ...prev]);
|
||||
const newFrame: ExtractedFrame = {
|
||||
...res.data,
|
||||
upscaleStatus: 'idle'
|
||||
};
|
||||
|
||||
setExtractedFrames(prev => [newFrame, ...prev]);
|
||||
toast.success('Frame extracted!');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
|
@ -95,34 +153,103 @@ export default function FrameExtractorPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleAction = (frameAsset: any, action: string) => {
|
||||
switch (action) {
|
||||
case 'upscale':
|
||||
router.push(`/image/upscale?assetId=${frameAsset.id}`);
|
||||
break;
|
||||
case 'remove_bg':
|
||||
router.push(`/image/remove-bg?assetId=${frameAsset.id}`);
|
||||
break;
|
||||
case 'download':
|
||||
window.open(`/api/v1/assets/${frameAsset.id}/download`, '_blank');
|
||||
break;
|
||||
const handleUpscale = async (frame: ExtractedFrame) => {
|
||||
if (frame.upscaleStatus === 'processing') return;
|
||||
|
||||
// Update status to processing
|
||||
setExtractedFrames(prev => prev.map(f =>
|
||||
f.id === frame.id ? { ...f, upscaleStatus: 'processing' } : f
|
||||
));
|
||||
|
||||
try {
|
||||
const response = await modulesApi.upscaleImage({
|
||||
asset_id: frame.id,
|
||||
scale,
|
||||
model,
|
||||
denoise_strength: denoiseStrength,
|
||||
sharpen,
|
||||
});
|
||||
|
||||
const job = response.data;
|
||||
|
||||
addJob({
|
||||
id: job.id,
|
||||
module: 'image_upscaling',
|
||||
status: job.status,
|
||||
progress: job.progress,
|
||||
created_at: job.created_at,
|
||||
});
|
||||
|
||||
setExtractedFrames(prev => prev.map(f =>
|
||||
f.id === frame.id ? { ...f, upscaleJobId: job.id } : f
|
||||
));
|
||||
|
||||
// Poll for completion
|
||||
pollJob(frame.id, job.id);
|
||||
toast.success('Upscaling started');
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
toast.error('Upscale failed: ' + (err.message || 'Unknown error'));
|
||||
setExtractedFrames(prev => prev.map(f =>
|
||||
f.id === frame.id ? { ...f, upscaleStatus: 'error', upscaleError: err.message } : f
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
const pollJob = async (frameId: string, jobId: string) => {
|
||||
let currentJob: any;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await jobsApi.get(jobId);
|
||||
currentJob = res.data;
|
||||
|
||||
if (currentJob.status === 'completed' || currentJob.status === 'failed') {
|
||||
clearInterval(interval);
|
||||
|
||||
if (currentJob.status === 'completed' && currentJob.output_asset_ids?.[0]) {
|
||||
setExtractedFrames(prev => prev.map(f =>
|
||||
f.id === frameId ? {
|
||||
...f,
|
||||
upscaleStatus: 'completed',
|
||||
upscaleResultId: currentJob.output_asset_ids[0]
|
||||
} : f
|
||||
));
|
||||
toast.success('Upscale complete!');
|
||||
} else {
|
||||
setExtractedFrames(prev => prev.map(f =>
|
||||
f.id === frameId ? {
|
||||
...f,
|
||||
upscaleStatus: 'error',
|
||||
upscaleError: currentJob.error_message || 'Job failed'
|
||||
} : f
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Polling error", err);
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const [previewAssetId, setPreviewAssetId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-8 h-[calc(100vh-8rem)] flex flex-col">
|
||||
<div className="max-w-[1600px] mx-auto space-y-8 h-[calc(100vh-8rem)] flex flex-col">
|
||||
{/* Header */}
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-white">Frame Extractor & Upscale</h1>
|
||||
<p className="text-gray-500">Extract frames and upscale them instantly using Topaz AI</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="flex-1 grid grid-cols-1 lg:grid-cols-4 gap-8 min-h-0">
|
||||
{/* Left Column - Video Player (2 cols) */}
|
||||
<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">
|
||||
|
|
@ -174,8 +301,93 @@ export default function FrameExtractorPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Extracted Frames */}
|
||||
<div className="bg-forge-dark rounded-xl border border-gray-800 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Middle Column - Upscale Settings (1 col) */}
|
||||
<div className="lg:col-span-1 space-y-6 overflow-y-auto">
|
||||
<div className="bg-forge-dark p-6 rounded-xl border border-gray-800 space-y-6">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Settings2 className="w-5 h-5 text-forge-yellow" />
|
||||
Upscale 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>
|
||||
|
||||
<div className="bg-forge-dark/50 p-4 rounded-xl border border-gray-800 text-sm text-gray-400">
|
||||
<p>Configure settings above, then click "Upscale" on any extracted frame.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Extracted Frames (1 col) */}
|
||||
<div className="bg-forge-dark rounded-xl border border-gray-800 flex flex-col min-h-0 overflow-hidden lg:col-span-1">
|
||||
<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" />
|
||||
|
|
@ -196,29 +408,91 @@ export default function FrameExtractorPage() {
|
|||
<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>
|
||||
|
||||
{/* Status Overlays */}
|
||||
{frame.upscaleStatus === 'processing' && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center flex-col gap-2">
|
||||
<RefreshCw className="w-6 h-6 text-forge-yellow animate-spin" />
|
||||
<span className="text-xs text-forge-yellow font-medium">Upscaling...</span>
|
||||
</div>
|
||||
)}
|
||||
{frame.upscaleStatus === 'completed' && (
|
||||
<div className="absolute bottom-1 right-1 flex items-center gap-1 bg-green-500/90 text-black text-xs px-1.5 py-0.5 rounded font-bold">
|
||||
<Sparkles className="w-3 h-3" /> Upscaled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{frame.upscaleStatus === 'completed' && frame.upscaleResultId ? (
|
||||
<div className="flex-1 flex gap-2">
|
||||
<button
|
||||
onClick={() => setPreviewAssetId(frame.upscaleResultId!)}
|
||||
className="flex-1 bg-forge-gray text-white hover:bg-gray-600 text-xs py-1.5 rounded flex items-center justify-center gap-1 transition-colors"
|
||||
title="Preview Result"
|
||||
>
|
||||
<ImageIcon className="w-3 h-3" /> Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(`/api/v1/assets/${frame.upscaleResultId}/download`, '_blank')}
|
||||
className="flex-1 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-black text-xs py-1.5 rounded flex items-center justify-center gap-1 transition-colors"
|
||||
title="Download Result"
|
||||
>
|
||||
<Download className="w-3 h-3" /> Save
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleUpscale(frame)}
|
||||
disabled={frame.upscaleStatus === 'processing'}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
<Wand2 className="w-3 h-3" /> Upscale
|
||||
</button>
|
||||
)}
|
||||
|
||||
<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')}
|
||||
onClick={() => window.open(`/api/v1/assets/${frame.id}/download`, '_blank')}
|
||||
className="p-1.5 text-gray-400 hover:text-white bg-gray-700/50 hover:bg-gray-700 rounded transition-colors"
|
||||
title="Download"
|
||||
title="Download Original"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{frame.upscaleStatus === 'error' && (
|
||||
<div className="mt-2 text-xs text-red-400 bg-red-400/10 px-2 py-1 rounded">
|
||||
Error: {frame.upscaleError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Modal */}
|
||||
{previewAssetId && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm p-4"
|
||||
onClick={() => setPreviewAssetId(null)}
|
||||
>
|
||||
<div className="relative max-w-full max-h-full" onClick={e => e.stopPropagation()}>
|
||||
<img
|
||||
src={`/api/v1/assets/${previewAssetId}/download`}
|
||||
className="max-w-full max-h-[90vh] rounded-lg shadow-2xl border border-gray-800"
|
||||
alt="Preview"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setPreviewAssetId(null)}
|
||||
className="absolute -top-4 -right-4 bg-white text-black p-2 rounded-full hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export default function VideoGeneratePage() {
|
|||
const [lastFramePreview, setLastFramePreview] = useState<string | null>(null);
|
||||
const [referencePreviews, setReferencePreviews] = useState<string[]>([]);
|
||||
const [inputPreview, setInputPreview] = useState<string | null>(null);
|
||||
const [inputFilename, setInputFilename] = useState<string | null>(null);
|
||||
|
||||
// Asset library modal
|
||||
const [showAssetLibrary, setShowAssetLibrary] = useState(false);
|
||||
|
|
@ -197,6 +198,7 @@ export default function VideoGeneratePage() {
|
|||
case 'input':
|
||||
setAssetId(asset.id);
|
||||
setInputPreview(thumbnailUrl || `/api/v1/assets/${asset.id}/download`);
|
||||
setInputFilename(asset.original_filename || asset.filename);
|
||||
setFile(null);
|
||||
break;
|
||||
case 'first':
|
||||
|
|
@ -406,9 +408,10 @@ export default function VideoGeneratePage() {
|
|||
<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>
|
||||
<p className="text-sm font-medium text-white truncate">{inputFilename || 'Selected Asset'}</p>
|
||||
<p className="text-xs text-green-400">Selected from My Files</p>
|
||||
</div>
|
||||
<button onClick={() => { setAssetId(null); setInputPreview(null); }} className="p-1 hover:text-white"><X className="w-4 h-4" /></button>
|
||||
<button onClick={() => { setAssetId(null); setInputPreview(null); setInputFilename(null); }} className="p-1 hover:text-white"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -475,6 +478,19 @@ export default function VideoGeneratePage() {
|
|||
<label className="block text-xs text-gray-500 mb-2">First Frame</label>
|
||||
<button
|
||||
onClick={() => openAssetLibrary('first')}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
if (data.type === 'forge-asset') {
|
||||
setFirstFrameAssetId(data.id);
|
||||
setFirstFramePreview(data.url); // Use URL from drag data for immediate preview
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Invalid drop data', err);
|
||||
}
|
||||
}}
|
||||
className="w-full aspect-video bg-forge-dark border border-gray-700 rounded-lg flex items-center justify-center hover:border-forge-yellow transition-colors overflow-hidden"
|
||||
>
|
||||
{firstFramePreview ? (
|
||||
|
|
@ -492,7 +508,10 @@ export default function VideoGeneratePage() {
|
|||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">Select from My Files</span>
|
||||
<div className="flex flex-col items-center gap-2 text-gray-500">
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
<span className="text-xs">Select or Drop Frame</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -501,6 +520,19 @@ export default function VideoGeneratePage() {
|
|||
<label className="block text-xs text-gray-500 mb-2">Last Frame</label>
|
||||
<button
|
||||
onClick={() => openAssetLibrary('last')}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
if (data.type === 'forge-asset') {
|
||||
setLastFrameAssetId(data.id);
|
||||
setLastFramePreview(data.url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Invalid drop data', err);
|
||||
}
|
||||
}}
|
||||
className="w-full aspect-video bg-forge-dark border border-gray-700 rounded-lg flex items-center justify-center hover:border-forge-yellow transition-colors overflow-hidden"
|
||||
>
|
||||
{lastFramePreview ? (
|
||||
|
|
@ -518,7 +550,10 @@ export default function VideoGeneratePage() {
|
|||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">Select from My Files</span>
|
||||
<div className="flex flex-col items-center gap-2 text-gray-500">
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
<span className="text-xs">Select or Drop Frame</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -528,18 +563,35 @@ export default function VideoGeneratePage() {
|
|||
<label className="block text-xs text-gray-500 mb-2">
|
||||
Reference Images ({referenceAssetIds.length}/4)
|
||||
</label>
|
||||
<button
|
||||
onClick={() => openAssetLibrary('reference')}
|
||||
disabled={referenceAssetIds.length >= 4}
|
||||
className="w-full px-4 py-2 bg-forge-dark border border-gray-700 rounded-lg text-sm text-gray-300 hover:border-forge-yellow transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (referenceAssetIds.length >= 4) return;
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
if (data.type === 'forge-asset') {
|
||||
setReferenceAssetIds(prev => [...prev, data.id]);
|
||||
setReferencePreviews(prev => [...prev, data.url]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Invalid drop data', err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add Reference Image
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openAssetLibrary('reference')}
|
||||
disabled={referenceAssetIds.length >= 4}
|
||||
className="w-full px-4 py-2 bg-forge-dark border border-gray-700 rounded-lg text-sm text-gray-300 hover:border-forge-yellow transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-dashed"
|
||||
>
|
||||
Add or Drop Reference Image
|
||||
</button>
|
||||
</div>
|
||||
{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 overflow-hidden">
|
||||
{referencePreviews[index] && <img src={referencePreviews[index]} className="w-full h-full object-cover opacity-50" />}
|
||||
<div key={`${id}-${index}`} 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" />}
|
||||
<button
|
||||
onClick={() => {
|
||||
setReferenceAssetIds(referenceAssetIds.filter((_, i) => i !== index));
|
||||
|
|
@ -549,7 +601,7 @@ export default function VideoGeneratePage() {
|
|||
>
|
||||
<X className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-xs text-white font-bold drop-shadow-md">
|
||||
<span className="absolute inset-0 flex items-center justify-center text-xs text-white font-bold drop-shadow-md pointer-events-none">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -613,6 +665,8 @@ export default function VideoGeneratePage() {
|
|||
isOpen={showAssetLibrary}
|
||||
onSelect={handleAssetSelect}
|
||||
onClose={() => setShowAssetLibrary(false)}
|
||||
fileTypes={['image']}
|
||||
title="Select Image"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -226,8 +226,8 @@ export default function AssetLibrary({
|
|||
fileTypes.includes('image')
|
||||
? { 'image/*': ['.png', '.jpg', '.jpeg', '.webp', '.gif'] }
|
||||
: fileTypes.includes('video')
|
||||
? { 'video/*': ['.mp4', '.webm', '.mov'] }
|
||||
: { 'audio/*': ['.mp3', '.wav', '.ogg'] }
|
||||
? { 'video/*': ['.mp4', '.webm', '.mov'] }
|
||||
: { 'audio/*': ['.mp3', '.wav', '.ogg'] }
|
||||
}
|
||||
label="Drop file here or click to upload"
|
||||
/>
|
||||
|
|
@ -258,6 +258,15 @@ export default function AssetLibrary({
|
|||
const isSelected = selectedAssets.has(asset.id);
|
||||
const Icon = FILE_TYPE_ICONS[asset.file_type as keyof typeof FILE_TYPE_ICONS] || FileText;
|
||||
|
||||
// Determine preview URL
|
||||
let previewUrl = null;
|
||||
if (asset.thumbnail_url) {
|
||||
previewUrl = `${process.env.NEXT_PUBLIC_API_URL}${asset.thumbnail_url}`;
|
||||
} else if (asset.mime_type?.startsWith('image/')) {
|
||||
// Fallback to direct download for images without thumbnails
|
||||
previewUrl = `/api/v1/assets/${asset.id}/download`;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={asset.id}
|
||||
|
|
@ -270,11 +279,12 @@ export default function AssetLibrary({
|
|||
)}
|
||||
>
|
||||
{/* Thumbnail or Icon */}
|
||||
{asset.thumbnail_url ? (
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}${asset.thumbnail_url}`}
|
||||
src={previewUrl}
|
||||
alt={asset.filename}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-forge-gray flex items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -17,16 +17,18 @@ export default function AuthProvider({ children }: { children: React.ReactNode }
|
|||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
// If on a public page, no need to check auth
|
||||
if (PUBLIC_PAGES.includes(pathname)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to verify auth with the backend (uses cookie automatically)
|
||||
try {
|
||||
console.log('AuthProvider: initAuth started');
|
||||
// If on a public page, no need to check auth
|
||||
if (PUBLIC_PAGES.includes(pathname)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to verify auth with the backend (uses cookie automatically)
|
||||
const response = await authApi.me();
|
||||
if (response.data) {
|
||||
console.log('AuthProvider: User authenticated', response.data.email);
|
||||
const userData = {
|
||||
id: response.data.id,
|
||||
email: response.data.email,
|
||||
|
|
@ -40,12 +42,16 @@ export default function AuthProvider({ children }: { children: React.ReactNode }
|
|||
}
|
||||
} catch (error) {
|
||||
// Not authenticated, clear state and redirect
|
||||
console.log('Not authenticated, redirecting to login');
|
||||
console.error('AuthProvider: Auth check failed', error);
|
||||
logout();
|
||||
router.push('/login');
|
||||
// Only redirect if NOT on public page (redundant check but safe)
|
||||
if (!PUBLIC_PAGES.includes(pathname)) {
|
||||
router.push('/login');
|
||||
}
|
||||
} finally {
|
||||
console.log('AuthProvider: initAuth finished, clearing loading');
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
initAuth();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Upload, X, FileImage, FileVideo, FileAudio, File } from 'lucide-react';
|
||||
import { Upload, X, FileImage, FileVideo, FileAudio, File as FileIcon } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface FileUploadProps {
|
||||
|
|
@ -92,7 +92,7 @@ export default function FileUpload({
|
|||
|
||||
const getFileIcon = (file: File) => {
|
||||
const type = file.type.split('/')[0];
|
||||
const Icon = fileIcons[type] || File;
|
||||
const Icon = fileIcons[type] || FileIcon;
|
||||
return <Icon className="w-8 h-8 text-forge-yellow" />;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export default function ModuleCard({
|
|||
color = 'forge-yellow',
|
||||
}: ModuleCardProps) {
|
||||
return (
|
||||
<Link href={href} className="module-card group">
|
||||
<Link href={href} className="module-card group block h-full">
|
||||
<div className={`w-12 h-12 bg-${color}/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-${color}/20 transition-colors`}>
|
||||
<Icon className={`w-6 h-6 text-${color}`} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ export default function RecentAssets() {
|
|||
id: asset.id,
|
||||
url: `/api/v1/assets/${asset.id}/download`,
|
||||
filename: asset.original_filename,
|
||||
mime_type: asset.mime_type,
|
||||
type: 'forge-asset'
|
||||
});
|
||||
e.dataTransfer.setData('application/json', dragData);
|
||||
|
|
@ -179,7 +180,6 @@ export default function RecentAssets() {
|
|||
// 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' : ''
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
FileCode,
|
||||
FileType,
|
||||
FileEdit,
|
||||
Scissors,
|
||||
} from 'lucide-react';
|
||||
|
||||
const modules = [
|
||||
|
|
@ -52,6 +53,7 @@ const modules = [
|
|||
items: [
|
||||
{ name: 'Generate', href: '/video/generate', icon: Film },
|
||||
{ name: 'Upscale', href: '/video/upscale', icon: Maximize },
|
||||
{ name: 'Frame Extractor', href: '/video/extract', icon: Scissors },
|
||||
{ name: 'Subtitles', href: '/video/subtitles', icon: Captions },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue