feat: Complete Veo support and UI enhancements
- Backend: - Implemented robust Veo video generation including 'lastFrame' and 'referenceImages' support - Fixed video URI extraction with recursive search for API response stability - Implemented direct HTTP video download to resolve SDK method missing errors - Frontend (Video Generator): - Updated validation to allow Text-to-Video for Veo without requiring a first frame - Fixed job state clearing to prevent UI from showing previous completion status - Frontend (My Files & Library): - Moved batch actions toolbar to bottom-left to prevent blocking pagination - Added 'Deselect All' button to batch actions toolbar - Added file type indicators to asset cards - Components: - Added 'Clear Finished' button to Active Jobs tracker - Updated Asset Library modal toolbar positioning
This commit is contained in:
parent
0a9d444249
commit
d7852fc399
12 changed files with 661 additions and 199 deletions
|
|
@ -1,93 +0,0 @@
|
|||
# Subtitle Generator - Missing UI Features
|
||||
|
||||
## Current Status
|
||||
The **backend** (`subtitle_processor.py`) has full support for subtitle styling options, but the **frontend** (`/video/subtitles/page.tsx`) is missing the UI controls to expose these features.
|
||||
|
||||
## Missing UI Controls
|
||||
|
||||
### Font Options
|
||||
- [ ] **Font Family** dropdown (24+ fonts supported)
|
||||
- Arial, Helvetica, Times New Roman, Courier New, Verdana, Georgia, Comic Sans MS, Impact, Tahoma, Trebuchet MS, Lucida Sans, Lucida Console, Palatino Linotype, Book Antiqua, Century Gothic, Franklin Gothic, Garamond, Segoe UI, Calibri, Cambria, Candara, Constantia, Consolas, Corbel
|
||||
- [ ] **Font Size** number input (8-72, default: 24)
|
||||
|
||||
### Color Options
|
||||
- [ ] **Text Color** dropdown
|
||||
- white, yellow, black, red, blue, green, orange, purple
|
||||
- [ ] **Outline Color** dropdown
|
||||
- black, white, yellow, red, blue, green
|
||||
- [ ] **Outline Width** number input (0-4, default: 1, step: 0.1)
|
||||
|
||||
### Position Options
|
||||
- [ ] **Position** dropdown (bottom/top)
|
||||
- [ ] **Alignment** dropdown (left/center/right) - currently not exposed
|
||||
- [ ] **Vertical Margin** number input - currently not exposed
|
||||
- [ ] **Horizontal Margin** number input - currently not exposed
|
||||
|
||||
### Advanced Options (Backend Supported)
|
||||
- [ ] **Background Color** dropdown (optional)
|
||||
- [ ] **Background Opacity** slider (0-1)
|
||||
- [ ] **Shadow** number input (0-4)
|
||||
- [ ] **Bold** checkbox
|
||||
- [ ] **Italic** checkbox
|
||||
- [ ] **Font Preset** dropdown (default, cinematic, documentary, news, social_media, minimal, bold)
|
||||
|
||||
### Model Options
|
||||
- [ ] **Whisper Model** dropdown
|
||||
- tiny (fastest), base (default), small, medium, large, large-v2, large-v3
|
||||
- [ ] **Output Format** dropdown
|
||||
- SRT (default), VTT, ASS
|
||||
- [ ] **Word Timestamps** checkbox
|
||||
|
||||
## Backend Parameters (Already Implemented)
|
||||
```python
|
||||
{
|
||||
"source_language": "auto",
|
||||
"target_language": "EN-US",
|
||||
"burn_subtitles": true,
|
||||
"whisper_model": "base",
|
||||
"font": "Arial",
|
||||
"font_size": 24,
|
||||
"text_color": "white",
|
||||
"outline_color": "black",
|
||||
"outline_width": 2,
|
||||
"position": "bottom",
|
||||
"alignment": "center",
|
||||
"margin_v": 30,
|
||||
"margin_h": 20,
|
||||
"shadow": 0,
|
||||
"bold": false,
|
||||
"italic": false,
|
||||
"background_color": null,
|
||||
"background_opacity": 0,
|
||||
"font_preset": null,
|
||||
"word_timestamps": false,
|
||||
"output_format": "srt"
|
||||
}
|
||||
```
|
||||
|
||||
## Recommended UI Layout
|
||||
Match the original sandbox implementation structure:
|
||||
1. **File Upload** (✅ exists)
|
||||
2. **Language Selection** (✅ exists)
|
||||
3. **Subtitle Styling Section** (❌ missing)
|
||||
- Font & Size (row)
|
||||
- Text Color & Outline Color (row)
|
||||
- Outline Width & Position (row)
|
||||
4. **Advanced Options** (collapsible) (❌ missing)
|
||||
- Whisper Model
|
||||
- Output Format
|
||||
- Background options
|
||||
- Text styling (bold/italic)
|
||||
5. **Burn Subtitles** checkbox (✅ exists)
|
||||
6. **Process Button** (✅ exists)
|
||||
|
||||
## Next Steps
|
||||
1. Add a "Subtitle Styling" section to the frontend
|
||||
2. Add form controls for all missing options
|
||||
3. Update the `modulesApi.processSubtitles()` call to include all parameters
|
||||
4. Test with different styling combinations
|
||||
5. Consider adding a live preview of subtitle styling
|
||||
|
||||
## Files to Modify
|
||||
- `/forge-ai/frontend/app/video/subtitles/page.tsx` - Add UI controls
|
||||
- `/forge-ai/frontend/lib/api.ts` - Ensure processSubtitles accepts all parameters (likely already does)
|
||||
|
|
@ -444,6 +444,10 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt
|
|||
|
||||
# Frame control
|
||||
first_frame_asset_id = input_data.get("first_frame_asset_id")
|
||||
# Fallback to standard input asset (Image-to-Video mode)
|
||||
if not first_frame_asset_id and job.input_asset_ids:
|
||||
first_frame_asset_id = job.input_asset_ids[0]
|
||||
|
||||
last_frame_asset_id = input_data.get("last_frame_asset_id")
|
||||
reference_asset_ids = input_data.get("reference_asset_ids", [])[:3] # Max 3 for Veo 3.1
|
||||
|
||||
|
|
@ -543,86 +547,210 @@ async def _generate_veo(job, input_data: dict, db) -> Tuple[Optional[bytes], Opt
|
|||
job.progress = 40
|
||||
db.commit()
|
||||
|
||||
# Generate video using the async long-running operation
|
||||
if extend_video:
|
||||
# Video extension mode
|
||||
operation = await asyncio.to_thread(
|
||||
client.models.generate_videos,
|
||||
model=model,
|
||||
video=extend_video,
|
||||
prompt=prompt,
|
||||
config=config
|
||||
)
|
||||
elif first_frame_image:
|
||||
# Image-to-video mode
|
||||
operation = await asyncio.to_thread(
|
||||
client.models.generate_videos,
|
||||
model=model,
|
||||
image=first_frame_image,
|
||||
prompt=prompt,
|
||||
config=config
|
||||
)
|
||||
else:
|
||||
# Text-to-video mode
|
||||
operation = await asyncio.to_thread(
|
||||
client.models.generate_videos,
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
config=config
|
||||
)
|
||||
# Prepare contents using raw HTTPX for predictLongRunning (Vertex-style)
|
||||
|
||||
logger.info(f"Veo Operation Started. Name: {operation.name}")
|
||||
# Helper for base64 data and mime type
|
||||
def get_image_data(aid):
|
||||
asset = db.query(Asset).filter(Asset.id == aid).first()
|
||||
if asset and os.path.exists(asset.file_path):
|
||||
mime = "image/png"
|
||||
path_lower = asset.file_path.lower()
|
||||
if path_lower.endswith(".jpg") or path_lower.endswith(".jpeg"):
|
||||
mime = "image/jpeg"
|
||||
elif path_lower.endswith(".webp"):
|
||||
mime = "image/webp"
|
||||
|
||||
with open(asset.file_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8"), mime
|
||||
return None, None
|
||||
|
||||
# Poll for completion (can take 11 seconds to 6 minutes)
|
||||
job.progress = 50
|
||||
db.commit()
|
||||
# Build Instance
|
||||
instance = {"prompt": prompt}
|
||||
|
||||
if extend_video:
|
||||
# Extension not implemented yet in this fallback
|
||||
pass
|
||||
elif first_frame_asset_id:
|
||||
b64, mime = get_image_data(first_frame_asset_id)
|
||||
if b64:
|
||||
instance["image"] = {
|
||||
"bytesBase64Encoded": b64,
|
||||
"mimeType": mime
|
||||
}
|
||||
|
||||
# Build Parameters (Veo 3.1 Features)
|
||||
params = {
|
||||
"sampleCount": 1
|
||||
}
|
||||
if config_kwargs.get("aspect_ratio"):
|
||||
params["aspectRatio"] = config_kwargs["aspect_ratio"]
|
||||
if config_kwargs.get("negative_prompt"):
|
||||
params["negativePrompt"] = config_kwargs["negative_prompt"]
|
||||
|
||||
# Last Frame
|
||||
if last_frame_asset_id:
|
||||
b64, mime = get_image_data(last_frame_asset_id)
|
||||
if b64:
|
||||
params["lastFrame"] = {
|
||||
"image": {
|
||||
"bytesBase64Encoded": b64,
|
||||
"mimeType": mime
|
||||
}
|
||||
}
|
||||
|
||||
# Reference Images
|
||||
if reference_asset_ids:
|
||||
ref_imgs = []
|
||||
for rid in reference_asset_ids:
|
||||
b64, mime = get_image_data(rid)
|
||||
if b64:
|
||||
ref_imgs.append({
|
||||
"image": {
|
||||
"bytesBase64Encoded": b64,
|
||||
"mimeType": mime
|
||||
},
|
||||
"referenceType": "asset"
|
||||
})
|
||||
if ref_imgs:
|
||||
params["referenceImages"] = ref_imgs
|
||||
|
||||
max_attempts = 72 # 6 minutes with 5 second intervals
|
||||
for attempt in range(max_attempts):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# Check operation status
|
||||
operation = await asyncio.to_thread(
|
||||
client.operations.get,
|
||||
operation
|
||||
)
|
||||
logger.info(f"Veo Generation sending raw predictLongRunning request to {model}")
|
||||
|
||||
api_key = settings.google_api_key
|
||||
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:predictLongRunning?key={api_key}"
|
||||
|
||||
payload = {
|
||||
"instances": [instance],
|
||||
"parameters": params
|
||||
}
|
||||
|
||||
# Use simple HTTPX
|
||||
async with httpx.AsyncClient() as http:
|
||||
# Start Operation
|
||||
resp = await http.post(url, json=payload, headers={"Content-Type": "application/json"}, timeout=120)
|
||||
|
||||
if attempt % 5 == 0:
|
||||
logger.info(f"Veo Operation Status: Done={operation.done}")
|
||||
|
||||
if operation.done:
|
||||
break
|
||||
|
||||
# Update progress
|
||||
progress = min(50 + (attempt * 0.5), 90)
|
||||
job.progress = int(progress)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"Veo API Error {resp.status_code}: {resp.text}")
|
||||
raise ValueError(f"Veo API Error {resp.status_code}: {resp.text}")
|
||||
|
||||
op_data = resp.json()
|
||||
if "name" not in op_data:
|
||||
raise ValueError(f"No operation name returned: {op_data}")
|
||||
|
||||
op_name = op_data["name"]
|
||||
logger.info(f"Veo Operation Started: {op_name}")
|
||||
|
||||
# Polling Loop
|
||||
job.progress = 50
|
||||
db.commit()
|
||||
|
||||
# Poll URL
|
||||
poll_url = f"https://generativelanguage.googleapis.com/v1beta/{op_name}?key={api_key}"
|
||||
|
||||
max_attempts = 120 # 10 minutes (5s interval)
|
||||
final_op = None
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
poll_resp = await http.get(poll_url)
|
||||
if poll_resp.status_code != 200:
|
||||
logger.warning(f"Polling failed: {poll_resp.status_code} {poll_resp.text}")
|
||||
continue
|
||||
|
||||
op_status = poll_resp.json()
|
||||
|
||||
if attempt % 5 == 0:
|
||||
logger.info(f"Veo Operation Status: done={op_status.get('done', False)}")
|
||||
|
||||
job.progress = 90
|
||||
db.commit()
|
||||
if op_status.get("done", False):
|
||||
final_op = op_status
|
||||
break
|
||||
|
||||
# Update progress
|
||||
progress = min(50 + (attempt * 0.4), 95)
|
||||
job.progress = int(progress)
|
||||
db.commit()
|
||||
|
||||
if not final_op:
|
||||
raise ValueError("Veo generation timed out")
|
||||
|
||||
if "error" in final_op:
|
||||
raise ValueError(f"Veo Operation Failed: {final_op['error']}")
|
||||
|
||||
# Recursive URI Find Helper
|
||||
def find_video_uri(data):
|
||||
if isinstance(data, str):
|
||||
# Check for likely API video URI patterns
|
||||
# Usually: https://generativelanguage.googleapis.com/v1beta/files/NAME
|
||||
if "/files/" in data and "googleapis.com" in data:
|
||||
return data
|
||||
|
||||
if isinstance(data, dict):
|
||||
for k, v in data.items():
|
||||
# Check keys if value is string URI
|
||||
if k in ["uri", "fileUri", "videoUri"] and isinstance(v, str):
|
||||
return v
|
||||
# Recurse
|
||||
res = find_video_uri(v)
|
||||
if res: return res
|
||||
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
res = find_video_uri(item)
|
||||
if res: return res
|
||||
return None
|
||||
|
||||
# Extract Response Body
|
||||
# Structure: response -> result -> videos? Or response -> generatedVideos?
|
||||
# Or LRO might use 'result' key instead of 'response'
|
||||
response_body = final_op.get("response", {})
|
||||
if not response_body:
|
||||
response_body = final_op.get("result", {})
|
||||
|
||||
# 1. Search recursively logic to find ANY file URI
|
||||
video_uri = find_video_uri(response_body)
|
||||
|
||||
if not video_uri:
|
||||
# Last ditch: check if 'generateVideoResponse' is empty but maybe URI is in metadata (unlikely)
|
||||
|
||||
# Debug: Dump keys/values of GVR if present
|
||||
gvr_dump = "Missing"
|
||||
if "generateVideoResponse" in response_body:
|
||||
gvr = response_body["generateVideoResponse"]
|
||||
gvr_dump = str(gvr)
|
||||
|
||||
logger.error(f"Could not find video URI. Final Op: {final_op}")
|
||||
raise ValueError(f"No video URI found. GVR was: {gvr_dump}. Full Response keys: {list(response_body.keys())}")
|
||||
|
||||
# Extract video from response
|
||||
if operation.done and operation.response:
|
||||
generated_videos = operation.response.generated_videos
|
||||
if generated_videos and len(generated_videos) > 0:
|
||||
video = generated_videos[0]
|
||||
logger.info(f"Veo generated URI: {video_uri}. Downloading...")
|
||||
|
||||
logger.info(f"Veo generated URI: {video_uri}. Downloading via HTTPX...")
|
||||
|
||||
# Manual Download via HTTPX
|
||||
download_url = video_uri
|
||||
|
||||
# If it's a Generative Language File API URL, append API key if missing
|
||||
if "generativelanguage.googleapis.com" in video_uri and "key=" not in video_uri:
|
||||
if "?" in video_uri:
|
||||
download_url += f"&key={settings.google_api_key}"
|
||||
else:
|
||||
download_url += f"?key={settings.google_api_key}"
|
||||
|
||||
# Download the video file
|
||||
video_data = await asyncio.to_thread(
|
||||
client.files.download,
|
||||
file=video.video
|
||||
)
|
||||
async with httpx.AsyncClient() as http:
|
||||
# Increase timeout for large video downloads
|
||||
resp = await http.get(download_url, follow_redirects=True, timeout=300)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"Failed to download video: {resp.status_code} {resp.text}")
|
||||
raise ValueError(f"Failed to download video from {video_uri}: Status {resp.status_code}")
|
||||
|
||||
video_data = resp.content
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"veo_{model}_{timestamp}.mp4"
|
||||
return video_data, filename
|
||||
|
||||
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.")
|
||||
|
|
|
|||
39
backend/list_models.py
Normal file
39
backend/list_models.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import httpx
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add current directory to path to find app module if needed
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
try:
|
||||
from app.config import settings
|
||||
api_key = settings.google_api_key
|
||||
except ImportError:
|
||||
# Try reading .env manually if app config fails
|
||||
print("Could not import settings, checking env...")
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
api_key = os.getenv("GOOGLE_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
print("No API Key found")
|
||||
sys.exit(1)
|
||||
|
||||
async def check_models():
|
||||
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={api_key}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code != 200:
|
||||
print(f"Error: {resp.status_code} {resp.text}")
|
||||
return
|
||||
|
||||
data = resp.json()
|
||||
print(f"Found {len(data.get('models', []))} models.")
|
||||
for m in data.get('models', []):
|
||||
if 'veo' in m['name'].lower() or 'video' in m['name'].lower():
|
||||
print(f"Model: {m['name']}")
|
||||
print(f" - Methods: {m.get('supportedGenerationMethods')}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_models())
|
||||
|
|
@ -20,7 +20,8 @@ import {
|
|||
Eye,
|
||||
CheckSquare,
|
||||
Square,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import FileUpload from '@/components/FileUpload';
|
||||
import api, { assetsApi } from '@/lib/api';
|
||||
|
|
@ -389,9 +390,16 @@ export default function MyFilesPage() {
|
|||
|
||||
{/* Batch Actions Toolbar */}
|
||||
{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">
|
||||
{selectedAssetsMap.size} Selected
|
||||
<div className="fixed bottom-6 left-6 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 flex items-center gap-2">
|
||||
<span>{selectedAssetsMap.size} Selected</span>
|
||||
<button
|
||||
onClick={() => setSelectedAssetsMap(new Map())}
|
||||
className="p-1 hover:bg-white/10 rounded-full transition-colors"
|
||||
title="Deselect All"
|
||||
>
|
||||
<X className="w-3 h-3 text-gray-400 hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
@ -558,7 +566,10 @@ export default function MyFilesPage() {
|
|||
{/* Info */}
|
||||
<div className="p-2">
|
||||
<p className="text-[10px] text-white break-words line-clamp-2 leading-tight min-h-[2.5em]">{asset.filename}</p>
|
||||
<p className="text-[9px] text-gray-500">{formatDate(asset.created_at)}</p>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-[9px] text-gray-500">{formatDate(asset.created_at)}</p>
|
||||
<p className="text-[9px] text-gray-400 capitalize bg-gray-800/50 px-1.5 rounded">{asset.file_type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
Clock,
|
||||
CheckCircle,
|
||||
Crop,
|
||||
Scissors,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
|
@ -62,7 +63,7 @@ const modules = [
|
|||
{
|
||||
title: 'Frame Extractor',
|
||||
description: 'Extract high-quality frames from videos',
|
||||
icon: Crop,
|
||||
icon: Scissors,
|
||||
href: '/video/extract',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Film, Download, Sparkles, FolderOpen, X, Loader2 } from 'lucide-react';
|
||||
import { Film, Download, Sparkles, FolderOpen, X, Loader2, Crop, CheckCircle, Scissors } from 'lucide-react';
|
||||
import ImageCropper from '@/components/ImageCropper';
|
||||
import FileUpload from '@/components/FileUpload';
|
||||
import JobProgress from '@/components/JobProgress';
|
||||
import AssetLibrary from '@/components/AssetLibrary';
|
||||
|
|
@ -47,6 +48,12 @@ export default function VideoGeneratePage() {
|
|||
const [showAssetLibrary, setShowAssetLibrary] = useState(false);
|
||||
const [assetSelectTarget, setAssetSelectTarget] = useState<'input' | 'first' | 'last' | 'reference'>('input');
|
||||
|
||||
// Cropper State
|
||||
const [showCropper, setShowCropper] = useState(false);
|
||||
const [cropImage, setCropImage] = useState<string | null>(null);
|
||||
const [cropTarget, setCropTarget] = useState<'input' | 'first' | 'last' | 'reference' | null>(null);
|
||||
const [cropReferenceIndex, setCropReferenceIndex] = useState<number>(-1);
|
||||
|
||||
// Results
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [generatedVideo, setGeneratedVideo] = useState<any>(null);
|
||||
|
|
@ -141,11 +148,19 @@ export default function VideoGeneratePage() {
|
|||
// Merge current options with model defaults
|
||||
const modelDefaults: Record<string, any> = {};
|
||||
modelConfig?.controls?.forEach((control) => {
|
||||
// Use existing value if present, else use default
|
||||
if (!(control.name in providerOptions)) {
|
||||
modelDefaults[control.name] = control.default;
|
||||
}
|
||||
});
|
||||
|
||||
// Enforce Gen-4 Strict Constraints
|
||||
if (newModel === 'gen4') {
|
||||
setMode('image');
|
||||
modelDefaults['ratio'] = '1280:768'; // Force landscape
|
||||
toast('Gen-4 set to Image Mode (Text-to-Video not supported)', { icon: 'ℹ️' });
|
||||
}
|
||||
|
||||
setProviderOptions({
|
||||
...providerOptions,
|
||||
...modelDefaults
|
||||
|
|
@ -235,12 +250,17 @@ export default function VideoGeneratePage() {
|
|||
return;
|
||||
}
|
||||
if (mode === 'image' && !assetId) {
|
||||
toast.error('Please upload or select an image');
|
||||
return;
|
||||
// Check if Veo has first frame set (valid alternative to main input)
|
||||
const isVeoInput = provider === 'veo' && firstFrameAssetId;
|
||||
if (!isVeoInput) {
|
||||
toast.error('Please upload or select an image');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setGeneratedVideo(null);
|
||||
setJobId(null); // Clear previous job to prevent JobProgress from showing old status
|
||||
|
||||
try {
|
||||
const payload: any = {
|
||||
|
|
@ -315,6 +335,67 @@ export default function VideoGeneratePage() {
|
|||
}
|
||||
};
|
||||
|
||||
const getCropAspectRatio = () => {
|
||||
// Try to parse from options first to respect user selection
|
||||
if (providerOptions?.ratio) {
|
||||
const parts = providerOptions.ratio.split(':');
|
||||
if (parts.length === 2 && !isNaN(Number(parts[0]))) {
|
||||
return Number(parts[0]) / Number(parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback defaults if no ratio set
|
||||
if (model === 'gen4') return 1280 / 768;
|
||||
return 16 / 9;
|
||||
};
|
||||
|
||||
const startCrop = (target: 'input' | 'first' | 'last' | 'reference', imageUrl: string, index = -1) => {
|
||||
setCropImage(imageUrl);
|
||||
setCropTarget(target);
|
||||
setCropReferenceIndex(index);
|
||||
setShowCropper(true);
|
||||
};
|
||||
|
||||
const handleCropSave = async (blob: Blob) => {
|
||||
setShowCropper(false);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const fileName = `cropped_${Date.now()}.jpg`;
|
||||
const file = new File([blob], fileName, { type: 'image/jpeg' });
|
||||
const response = await assetsApi.upload(file);
|
||||
const newAssetId = response.data.id;
|
||||
const newPreview = URL.createObjectURL(blob);
|
||||
|
||||
if (cropTarget === 'input') {
|
||||
setAssetId(newAssetId);
|
||||
setInputPreview(newPreview);
|
||||
setInputFilename(fileName);
|
||||
setMode('image'); // Ensure mode is set
|
||||
} else if (cropTarget === 'first') {
|
||||
setFirstFrameAssetId(newAssetId);
|
||||
setFirstFramePreview(newPreview);
|
||||
} else if (cropTarget === 'last') {
|
||||
setLastFrameAssetId(newAssetId);
|
||||
setLastFramePreview(newPreview);
|
||||
} else if (cropTarget === 'reference' && cropReferenceIndex >= 0) {
|
||||
const newIds = [...referenceAssetIds];
|
||||
newIds[cropReferenceIndex] = newAssetId;
|
||||
setReferenceAssetIds(newIds);
|
||||
|
||||
const newPreviews = [...referencePreviews];
|
||||
newPreviews[cropReferenceIndex] = newPreview;
|
||||
setReferencePreviews(newPreviews);
|
||||
}
|
||||
toast.success('Image cropped successfully');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Failed to save cropped image');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingCapabilities) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
|
|
@ -323,7 +404,30 @@ export default function VideoGeneratePage() {
|
|||
);
|
||||
}
|
||||
|
||||
const currentConfig = capabilities?.[provider];
|
||||
const currentConfig = capabilities?.[provider] ? (() => {
|
||||
// Clone config to apply UI locks for specific models
|
||||
const config = JSON.parse(JSON.stringify(capabilities[provider]));
|
||||
|
||||
if (model === 'gen4') {
|
||||
// Lock Ratio in Common Controls
|
||||
const commonRatio = config.commonControls?.find((c: any) => c.name === 'ratio');
|
||||
if (commonRatio && commonRatio.options) {
|
||||
commonRatio.options = commonRatio.options.filter((opt: any) => opt.value === '1280:768');
|
||||
commonRatio.label = "Ratio (Locked for Gen-4)";
|
||||
}
|
||||
|
||||
// Lock Ratio in Model Controls
|
||||
const modelDef = config.models.find((m: any) => m.id === 'gen4');
|
||||
if (modelDef?.controls) {
|
||||
const modelRatio = modelDef.controls.find((c: any) => c.name === 'ratio');
|
||||
if (modelRatio && modelRatio.options) {
|
||||
modelRatio.options = modelRatio.options.filter((opt: any) => opt.value === '1280:768');
|
||||
modelRatio.label = "Ratio (Locked for Gen-4)";
|
||||
}
|
||||
}
|
||||
}
|
||||
return config;
|
||||
})() : null;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
|
|
@ -346,10 +450,12 @@ export default function VideoGeneratePage() {
|
|||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setMode('text')}
|
||||
disabled={model === 'gen4'}
|
||||
title={model === 'gen4' ? 'Gen-4 only supports Image to Video' : ''}
|
||||
className={`flex-1 px-4 py-3 rounded-lg font-medium transition-colors ${mode === 'text'
|
||||
? 'bg-forge-yellow text-black'
|
||||
: 'bg-forge-dark border border-gray-700 text-gray-300 hover:border-gray-600'
|
||||
}`}
|
||||
} ${model === 'gen4' ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
Text to Video
|
||||
</button>
|
||||
|
|
@ -378,8 +484,8 @@ export default function VideoGeneratePage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Image Upload (for image mode) */}
|
||||
{mode === 'image' && (
|
||||
{/* Image Upload (for image mode - Hidden for Veo as it uses Advanced Frame Control) */}
|
||||
{mode === 'image' && provider !== 'veo' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Source Image</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
|
|
@ -411,6 +517,9 @@ export default function VideoGeneratePage() {
|
|||
<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={() => startCrop('input', inputPreview)} className="p-1.5 bg-forge-gray rounded hover:text-forge-yellow" title="Crop Image">
|
||||
<Crop 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>
|
||||
)}
|
||||
|
|
@ -476,7 +585,7 @@ export default function VideoGeneratePage() {
|
|||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-2">First Frame</label>
|
||||
<button
|
||||
<div
|
||||
onClick={() => openAssetLibrary('first')}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
|
|
@ -491,7 +600,9 @@ export default function VideoGeneratePage() {
|
|||
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"
|
||||
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 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{firstFramePreview ? (
|
||||
<div className="relative w-full h-full">
|
||||
|
|
@ -502,10 +613,20 @@ export default function VideoGeneratePage() {
|
|||
setFirstFrameAssetId(null);
|
||||
setFirstFramePreview(null);
|
||||
}}
|
||||
className="absolute top-1 right-1 p-1 bg-black/50 rounded"
|
||||
className="absolute top-1 right-1 p-1 bg-black/50 rounded z-10 hover:bg-black/70"
|
||||
>
|
||||
<X className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startCrop('first', firstFramePreview);
|
||||
}}
|
||||
className="absolute top-1 right-7 p-1 bg-black/50 rounded z-10 hover:bg-black/70"
|
||||
title="Crop"
|
||||
>
|
||||
<Crop className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-gray-500">
|
||||
|
|
@ -513,12 +634,12 @@ export default function VideoGeneratePage() {
|
|||
<span className="text-xs">Select or Drop Frame</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-2">Last Frame</label>
|
||||
<button
|
||||
<div
|
||||
onClick={() => openAssetLibrary('last')}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
|
|
@ -533,7 +654,9 @@ export default function VideoGeneratePage() {
|
|||
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"
|
||||
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 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{lastFramePreview ? (
|
||||
<div className="relative w-full h-full">
|
||||
|
|
@ -544,10 +667,20 @@ export default function VideoGeneratePage() {
|
|||
setLastFrameAssetId(null);
|
||||
setLastFramePreview(null);
|
||||
}}
|
||||
className="absolute top-1 right-1 p-1 bg-black/50 rounded"
|
||||
className="absolute top-1 right-1 p-1 bg-black/50 rounded z-10 hover:bg-black/70"
|
||||
>
|
||||
<X className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startCrop('last', lastFramePreview);
|
||||
}}
|
||||
className="absolute top-1 right-7 p-1 bg-black/50 rounded z-10 hover:bg-black/70"
|
||||
title="Crop"
|
||||
>
|
||||
<Crop className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-gray-500">
|
||||
|
|
@ -555,7 +688,7 @@ export default function VideoGeneratePage() {
|
|||
<span className="text-xs">Select or Drop Frame</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -601,6 +734,20 @@ export default function VideoGeneratePage() {
|
|||
>
|
||||
<X className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startCrop('reference', referencePreviews[index], index)}
|
||||
className="absolute bottom-1 right-1 p-1 bg-black/50 rounded-full z-10 hover:bg-black/70"
|
||||
title="Crop"
|
||||
>
|
||||
<Crop className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startCrop('reference', referencePreviews[index], index)}
|
||||
className="absolute bottom-1 right-1 p-1 bg-black/50 rounded-full z-10 hover:bg-black/70"
|
||||
title="Crop"
|
||||
>
|
||||
<Crop 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 pointer-events-none">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
|
@ -615,7 +762,7 @@ export default function VideoGeneratePage() {
|
|||
{/* Generate Button */}
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || (mode === 'text' ? !prompt.trim() : !assetId)}
|
||||
disabled={loading || (mode === 'text' ? !prompt.trim() : (!assetId && !(provider === 'veo' && (firstFrameAssetId || prompt.trim()))))}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
|
|
@ -658,16 +805,29 @@ export default function VideoGeneratePage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
|
||||
{/* Asset Library Modal */}
|
||||
<AssetLibrary
|
||||
< AssetLibrary
|
||||
isOpen={showAssetLibrary}
|
||||
onSelect={handleAssetSelect}
|
||||
onClose={() => setShowAssetLibrary(false)}
|
||||
onClose={() => setShowAssetLibrary(false)
|
||||
}
|
||||
fileTypes={['image']}
|
||||
title="Select Image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image Cropper */}
|
||||
{
|
||||
showCropper && cropImage && (
|
||||
<ImageCropper
|
||||
image={cropImage}
|
||||
aspect={getCropAspectRatio()}
|
||||
onCrop={handleCropSave}
|
||||
onCancel={() => setShowCropper(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export default function AssetLibrary({
|
|||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="bg-forge-dark border border-gray-800 rounded-xl w-full max-w-4xl max-h-[80vh] flex flex-col">
|
||||
<div className="bg-forge-dark border border-gray-800 rounded-xl w-full max-w-4xl max-h-[80vh] flex flex-col relative">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -333,15 +333,15 @@ export default function AssetLibrary({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer for multiple selection */}
|
||||
{/* Footer for multiple selection - Floating */}
|
||||
{multiple && selectedAssets.size > 0 && (
|
||||
<div className="p-4 border-t border-gray-800 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">
|
||||
{selectedAssets.size} file(s) selected
|
||||
<div className="absolute bottom-4 left-4 z-20 bg-forge-dark border border-forge-yellow rounded-lg shadow-xl p-3 flex items-center gap-4 animate-in slide-in-from-bottom-2">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{selectedAssets.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={handleConfirmMultiple}
|
||||
className="px-4 py-2 bg-forge-yellow text-black font-medium rounded-lg hover:bg-forge-yellow/90"
|
||||
className="px-3 py-1.5 bg-forge-yellow text-black text-sm font-bold rounded hover:bg-forge-yellow/90"
|
||||
>
|
||||
Use Selected
|
||||
</button>
|
||||
|
|
|
|||
89
frontend/components/ImageCropper.tsx
Normal file
89
frontend/components/ImageCropper.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { X, Check, Loader2 } from 'lucide-react';
|
||||
import getCroppedImg from '@/lib/imageUtils';
|
||||
|
||||
interface ImageCropperProps {
|
||||
image: string;
|
||||
aspect: number;
|
||||
onCrop: (croppedBlob: Blob) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ImageCropper({ image, aspect, onCrop, onCancel }: ImageCropperProps) {
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Define onCropComplete as a useCallback to avoid re-renders
|
||||
const onCropComplete = useCallback((croppedArea: any, croppedAreaPixels: any) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels);
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!croppedAreaPixels) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const croppedImage = await getCroppedImg(image, croppedAreaPixels);
|
||||
onCrop(croppedImage);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-black/90 flex flex-col animate-in fade-in duration-200">
|
||||
<div className="p-4 flex items-center justify-between border-b border-gray-800 bg-forge-dark z-10">
|
||||
<h3 className="text-white font-medium">Crop Image</h3>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="px-4 py-1.5 bg-forge-yellow text-black rounded-lg font-medium flex items-center gap-2 hover:bg-yellow-400 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
Apply Crop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative bg-black">
|
||||
<Cropper
|
||||
image={image}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={aspect}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={onCropComplete}
|
||||
onZoomChange={setZoom}
|
||||
classes={{
|
||||
containerClassName: "bg-black",
|
||||
mediaClassName: "",
|
||||
cropAreaClassName: "border-2 border-forge-yellow"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-forge-dark border-t border-gray-800 z-10 flex items-center gap-4">
|
||||
<button onClick={onCancel} className="text-gray-400 hover:text-white font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<div className="flex-1 flex items-center gap-4">
|
||||
<span className="text-gray-400 text-center text-sm w-12">Zoom</span>
|
||||
<input
|
||||
type="range"
|
||||
value={zoom}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 accent-forge-yellow h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -179,9 +179,21 @@ export default function JobTracker({ className }: JobTrackerProps) {
|
|||
<div className="absolute right-0 top-full mt-2 w-96 bg-forge-dark border border-gray-800 rounded-xl shadow-2xl z-50 overflow-hidden">
|
||||
<div className="p-3 border-b border-gray-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white">Active Jobs</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{polling && 'Updating...'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const finished = activeJobs.filter(j => j.status === 'completed' || j.status === 'failed');
|
||||
finished.forEach(j => removeJob(j.id));
|
||||
}}
|
||||
className="text-xs text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Clear Finished
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{polling && 'Updating...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
|
|
|
|||
93
frontend/lib/imageUtils.ts
Normal file
93
frontend/lib/imageUtils.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
export const createImage = (url: string): Promise<HTMLImageElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener('load', () => resolve(image));
|
||||
image.addEventListener('error', (error) => reject(error));
|
||||
image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox
|
||||
image.src = url;
|
||||
});
|
||||
|
||||
export function getRadianAngle(degreeValue: number) {
|
||||
return (degreeValue * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the new bounding area of a rotated rectangle.
|
||||
*/
|
||||
export function rotateSize(width: number, height: number, rotation: number) {
|
||||
const rotRad = getRadianAngle(rotation);
|
||||
|
||||
return {
|
||||
width:
|
||||
Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
|
||||
height:
|
||||
Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This function was adapted from the one in the Readme of https://github.com/DominicTobias/react-image-crop
|
||||
*/
|
||||
export default async function getCroppedImg(
|
||||
imageSrc: string,
|
||||
pixelCrop: { x: number; y: number; width: number; height: number },
|
||||
rotation = 0,
|
||||
flip = { horizontal: false, vertical: false }
|
||||
): Promise<Blob> {
|
||||
const image = await createImage(imageSrc);
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
return Promise.reject(new Error('No 2d context'));
|
||||
}
|
||||
|
||||
const rotRad = getRadianAngle(rotation);
|
||||
|
||||
// calculate bounding box of the rotated image
|
||||
const { width: bBoxWidth, height: bBoxHeight } = rotateSize(
|
||||
image.width,
|
||||
image.height,
|
||||
rotation
|
||||
);
|
||||
|
||||
// set canvas size to match the bounding box
|
||||
canvas.width = bBoxWidth;
|
||||
canvas.height = bBoxHeight;
|
||||
|
||||
// translate canvas context to a central location to allow rotating and flipping around the center
|
||||
ctx.translate(bBoxWidth / 2, bBoxHeight / 2);
|
||||
ctx.rotate(rotRad);
|
||||
ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1);
|
||||
ctx.translate(-image.width / 2, -image.height / 2);
|
||||
|
||||
// draw rotated image
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
const data = ctx.getImageData(
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height
|
||||
);
|
||||
|
||||
// set canvas width to final desired crop size - this will clear existing context
|
||||
canvas.width = pixelCrop.width;
|
||||
canvas.height = pixelCrop.height;
|
||||
|
||||
// paste generated rotate image at the top left corner
|
||||
ctx.putImageData(data, 0, 0);
|
||||
|
||||
// As Blob
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((file) => {
|
||||
if (file) {
|
||||
// @ts-ignore
|
||||
file.name = 'cropped.jpg';
|
||||
resolve(file);
|
||||
} else {
|
||||
reject(new Error('Canvas is empty'));
|
||||
}
|
||||
}, 'image/jpeg');
|
||||
});
|
||||
}
|
||||
21
frontend/package-lock.json
generated
21
frontend/package-lock.json
generated
|
|
@ -17,6 +17,7 @@
|
|||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.9",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"zustand": "^5.0.1"
|
||||
|
|
@ -1816,6 +1817,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
@ -2130,6 +2137,20 @@
|
|||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz",
|
||||
"integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hot-toast": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.9",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"zustand": "^5.0.1"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue