Fix video generation for Runway (Veo3/Gen4)

This commit is contained in:
DJP 2025-12-10 20:49:15 -05:00
parent a0c8722aa5
commit c58e4288ff
18 changed files with 994 additions and 444 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1,9 @@
node_modules
.next
.DS_Store
.git
.env*
! .env.example
build
dist
coverage

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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" />;
};

View file

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

View file

@ -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' : ''
}`}
>

View file

@ -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 },
],
},