| Current Path : /var/www/pythonian/qwen/static/ |
| Current File : /var/www/pythonian/qwen/static/image-editor.html |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> Image Editor</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* Previous styles remain the same */
.drop-zone.dragover {
border-color: #3b82f6;
background-color: #ebf5ff;
}
.image-preview {
position: relative;
transition: transform 0.2s;
}
.image-preview:hover {
transform: scale(1.02);
}
.image-preview:hover .image-overlay {
opacity: 1;
}
.file-size-warning {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="imageEditor()" x-init="init()">
<div class="container mx-auto px-4 py-8 max-w-6xl">
<!-- Header -->
<header class="mb-10 text-center">
<h1 class="text-4xl font-bold text-gray-800 mb-2">
<i class="fas fa-magic text-blue-500 mr-2"></i>Image Editor
</h1>
<p class="text-gray-600">Upload images or use URLs to edit with AI prompts</p>
</header>
<!-- Main Card -->
<div class="bg-white rounded-xl shadow-lg p-6 mb-8">
<!-- Upload Method Toggle -->
<div class="mb-6">
<div class="flex border border-gray-300 rounded-lg overflow-hidden w-fit">
<button
@click="uploadMethod = 'file'"
:class="uploadMethod === 'file' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700'"
class="px-6 py-2 font-medium transition-colors"
>
<i class="fas fa-upload mr-2"></i>Upload Files
</button>
<button
@click="uploadMethod = 'url'"
:class="uploadMethod === 'url' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700'"
class="px-6 py-2 font-medium transition-colors"
>
<i class="fas fa-link mr-2"></i>Use URLs
</button>
</div>
</div>
<!-- File Upload Section -->
<div x-show="uploadMethod === 'file'" x-transition class="mb-8">
<h2 class="text-xl font-semibold text-gray-800 mb-4">
<i class="fas fa-cloud-upload-alt text-blue-500 mr-2"></i>Upload Images (Max: 3)
</h2>
<!-- Drop Zone -->
<div
class="drop-zone rounded-lg p-8 text-center cursor-pointer mb-4"
:class="isDragging ? 'dragover' : ''"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop($event)"
@click="openFilePicker"
>
<input
type="file"
id="fileInput"
@change="handleFileSelect($event)"
multiple
accept=".png,.jpg,.jpeg,.gif,.webp,.bmp"
class="hidden"
>
<div class="mb-4">
<i class="fas fa-cloud-upload-alt text-4xl text-blue-400 mb-2"></i>
<p class="text-lg font-medium text-gray-700">Drop images here or click to upload</p>
<p class="text-sm text-gray-500 mt-1">Supported: PNG, JPG, JPEG, GIF, WEBP, BMP</p>
<p class="text-xs text-gray-400 mt-1">Max 3 images, 5MB each</p>
</div>
</div>
<!-- File Previews -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<template x-for="(file, index) in uploadedFiles" :key="file.id">
<div class="image-preview border border-gray-200 rounded-lg overflow-hidden bg-gray-50 fade-in">
<div class="relative" style="padding-top: 75%;">
<img
:src="file.preview"
:alt="file.name"
class="absolute top-0 left-0 w-full h-full object-cover"
>
<div class="image-overlay absolute top-0 left-0 w-full h-full bg-black/50 opacity-0 transition-opacity flex items-center justify-center">
<button
@click="removeUploadedFile(index)"
class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors"
>
<i class="fas fa-trash"></i>
</button>
</div>
<!-- File size warning -->
<div x-show="file.size > 4 * 1024 * 1024" class="absolute top-2 left-2">
<span class="bg-yellow-500 text-white text-xs px-2 py-1 rounded-full flex items-center">
<i class="fas fa-exclamation-triangle mr-1"></i> Large
</span>
</div>
</div>
<div class="p-3">
<div class="flex justify-between items-center mb-1">
<span class="text-sm font-medium text-gray-700 truncate" x-text="file.name"></span>
<span class="text-xs text-gray-500" x-text="formatFileSize(file.size)"></span>
</div>
<div class="text-xs text-gray-500">
<span x-text="file.type.toUpperCase()"></span>
<span class="mx-1">•</span>
<span x-text="file.dimensions || 'Loading...'"></span>
</div>
<div x-show="file.size > 4 * 1024 * 1024" class="mt-2 text-xs text-yellow-600">
<i class="fas fa-info-circle mr-1"></i>Large file may take longer
</div>
</div>
</div>
</template>
</div>
<div x-show="uploadedFiles.length === 0" class="text-center py-6 text-gray-500">
<i class="fas fa-image text-3xl mb-2 opacity-50"></i>
<p>No images uploaded yet</p>
</div>
<div x-show="uploadedFiles.length > 0" class="mt-4 text-right">
<button
@click="clearUploadedFiles()"
class="px-4 py-2 text-sm text-red-600 hover:text-red-800"
>
<i class="fas fa-times mr-1"></i>Clear All
</button>
</div>
</div>
<!-- URL Input Section -->
<div x-show="uploadMethod === 'url'" x-transition class="mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-800">
<i class="fas fa-link text-blue-500 mr-2"></i>Image URLs (Max: 3)
</h2>
<button
@click="addUrlInput()"
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="imageUrls.length >= 3"
>
<i class="fas fa-plus mr-1"></i> Add URL
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<template x-for="(url, index) in imageUrls" :key="index">
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50 fade-in">
<div class="flex justify-between items-center mb-2">
<span class="font-medium text-gray-700">URL #<span x-text="index+1"></span></span>
<button
@click="removeUrlInput(index)"
class="text-red-500 hover:text-red-700"
title="Remove URL"
>
<i class="fas fa-times"></i>
</button>
</div>
<input
type="text"
x-model="imageUrls[index].url"
placeholder="https://example.com/image.jpg"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
@input="validateImageUrl(index)"
/>
<div class="mt-2 flex items-center">
<span x-show="imageUrls[index].valid" class="text-green-600 text-sm">
<i class="fas fa-check-circle mr-1"></i> Valid URL
</span>
<span x-show="!imageUrls[index].valid && imageUrls[index].url" class="text-red-600 text-sm">
<i class="fas fa-exclamation-circle mr-1"></i> Invalid URL
</span>
</div>
</div>
</template>
</div>
<div x-show="imageUrls.length === 0" class="text-center py-8 border-2 border-dashed border-gray-300 rounded-lg">
<i class="fas fa-link text-gray-400 text-4xl mb-2"></i>
<p class="text-gray-500">Add up to 3 image URLs to get started</p>
</div>
</div>
<!-- Prompt Section -->
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-800 mb-4">
<i class="fas fa-comment-dots text-blue-500 mr-2"></i>Edit Instructions
</h2>
<textarea
x-model="prompt"
rows="4"
placeholder="Describe how you want to edit the image(s). For example: 'Make the background a sunset beach' or 'Replace the car with a bicycle'"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
></textarea>
<div class="flex justify-between mt-2">
<span class="text-sm text-gray-500">Required field</span>
<span class="text-sm" :class="prompt.length > 0 ? 'text-green-600' : 'text-gray-500'">
<span x-text="prompt.length"></span> characters
</span>
</div>
</div>
<!-- Advanced Options (unchanged) -->
<!-- ... same as before ... -->
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row justify-between items-center pt-6 border-t border-gray-200">
<div class="mb-4 sm:mb-0">
<button
@click="resetForm()"
class="px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
<i class="fas fa-redo mr-2"></i>Reset Form
</button>
</div>
<div class="flex space-x-4">
<button
@click="validateAndSubmit()"
:disabled="isLoading || !isFormValid"
class="px-8 py-2.5 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg hover:from-blue-600 hover:to-blue-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-w-[140px]"
>
<template x-if="isLoading">
<div class="spinner mr-2"></div>
</template>
<template x-if="!isLoading">
<i class="fas fa-magic mr-2"></i>
</template>
<span x-text="isLoading ? 'Editing...' : 'Edit Images'"></span>
</button>
</div>
</div>
</div>
<!-- Results Section -->
<div x-show="results.length > 0" x-transition class="mt-12 fade-in">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-800">
<i class="fas fa-images text-green-500 mr-2"></i>Generated Images
</h2>
<div class="text-sm text-gray-600">
<span x-text="results.length"></span> result<span x-show="results.length !== 1">s</span>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="(result, resultIndex) in results" :key="resultIndex">
<div class="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200">
<div class="relative overflow-hidden bg-gray-100" style="padding-top: 75%;">
<img
:src="result.image_urls[0]"
:alt="'Generated image ' + (resultIndex + 1)"
class="absolute top-0 left-0 w-full h-full object-cover"
@error="result.error = true"
>
<div x-show="result.error" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-gray-200">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-gray-400 text-3xl mb-2"></i>
<p class="text-gray-500">Image failed to load</p>
</div>
</div>
<div class="absolute top-3 right-3 bg-black/70 text-white text-xs px-2 py-1 rounded">
<span x-text="result.size || '1024x1536'"></span>
</div>
</div>
<div class="p-4">
<div class="mb-3">
<div class="text-sm font-medium text-gray-700 mb-1">Prompt:</div>
<div class="text-sm text-gray-600 line-clamp-2" x-text="result.prompt"></div>
</div>
<div class="flex justify-between items-center pt-3 border-t border-gray-100">
<a
:href="result.image_urls[0]"
target="_blank"
class="px-3 py-1.5 bg-blue-50 text-blue-600 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium"
>
<i class="fas fa-external-link-alt mr-1"></i> Open
</a>
<button
@click="downloadImage(result.image_urls[0], resultIndex)"
class="px-3 py-1.5 bg-green-50 text-green-600 rounded-md hover:bg-green-100 transition-colors text-sm font-medium"
>
<i class="fas fa-download mr-1"></i> Save
</button>
<div class="text-xs text-gray-500">
<i class="far fa-clock mr-1"></i>
<span x-text="new Date(result.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})"></span>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- Error Modal -->
<div
x-show="errorMessage"
x-transition
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
@click.self="errorMessage = null"
>
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
<div class="flex items-center mb-4">
<div class="flex-shrink-0 w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-600"></i>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-800">Error</h3>
</div>
</div>
<div class="mb-6">
<p class="text-gray-600" x-text="errorMessage"></p>
</div>
<div class="flex justify-end">
<button
@click="errorMessage = null"
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
Close
</button>
</div>
</div>
</div>
<!-- Success Toast -->
<div
x-show="showSuccessToast"
x-transition
class="fixed bottom-4 right-4 bg-green-500 text-white rounded-lg shadow-lg p-4 max-w-sm z-50 fade-in"
>
<div class="flex items-center">
<i class="fas fa-check-circle text-xl mr-3"></i>
<div>
<p class="font-medium">Images generated successfully!</p>
<p class="text-sm opacity-90" x-text="'Request ID: ' + lastRequestId"></p>
</div>
<button @click="showSuccessToast = false" class="ml-4 text-white/80 hover:text-white">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<script>
function imageEditor() {
return {
// State
uploadMethod: 'file',
uploadedFiles: [],
imageUrls: [],
isDragging: false,
prompt: '',
negativePrompt: '',
n: 1,
size: '1024*1536',
promptExtend: true,
watermark: false,
model: 'qwen-image-edit-max',
showAdvanced: false,
isLoading: false,
results: [],
errorMessage: null,
showSuccessToast: false,
lastRequestId: null,
// Constants
modelOptions: [
{ value: 'qwen-image-edit-max', label: 'Max', description: 'High quality' },
{ value: 'qwen-image-edit-plus', label: 'Plus', description: 'Fast & efficient' }
],
sizeOptions: [
{ value: '1024*1024', label: 'Square (1024x1024)' },
{ value: '1024*1536', label: 'Portrait (1024x1536)' },
{ value: '1536*1024', label: 'Landscape (1536x1024)' }
],
// Computed properties
get isFormValid() {
if (this.uploadMethod === 'file') {
return this.uploadedFiles.length > 0 && this.prompt.trim().length > 0;
} else {
return this.imageUrls.length > 0 &&
this.imageUrls.every(img => img.valid) &&
this.prompt.trim().length > 0;
}
},
// Methods
init() {
this.addUrlInput();
this.loadSavedResults();
},
loadSavedResults() {
const savedResults = localStorage.getItem('qwenImageEditorResults');
if (savedResults) {
try {
this.results = JSON.parse(savedResults);
} catch (e) {
console.error('Failed to load saved results:', e);
}
}
},
// File Upload Methods
openFilePicker() {
document.getElementById('fileInput').click();
},
handleFileSelect(event) {
const files = Array.from(event.target.files);
this.processFiles(files);
event.target.value = '';
},
handleDrop(event) {
this.isDragging = false;
const files = Array.from(event.dataTransfer.files);
this.processFiles(files);
},
async processFiles(files) {
const remainingSlots = 3 - this.uploadedFiles.length;
const filesToAdd = files.slice(0, remainingSlots);
for (const file of filesToAdd) {
// Validate file type
const fileType = file.type.split('/')[0];
if (fileType !== 'image') {
this.errorMessage = `File "${file.name}" is not an image`;
continue;
}
// Validate file size (5MB max for Base64)
if (file.size > 5 * 1024 * 1024) {
this.errorMessage = `File "${file.name}" exceeds 5MB limit for Base64 conversion`;
continue;
}
// Create preview and get dimensions
const preview = await this.readFileAsDataURL(file);
const dimensions = await this.getImageDimensions(file);
this.uploadedFiles.push({
id: Date.now() + Math.random(),
file: file,
name: file.name,
size: file.size,
type: file.type.split('/')[1].toLowerCase(),
preview: preview,
dimensions: dimensions
});
}
if (files.length > remainingSlots) {
this.errorMessage = `Maximum 3 images allowed. Only ${remainingSlots} were added.`;
}
},
readFileAsDataURL(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.readAsDataURL(file);
});
},
getImageDimensions(file) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
resolve(`${img.width}×${img.height}`);
};
img.onerror = () => resolve('Unknown');
img.src = URL.createObjectURL(file);
});
},
removeUploadedFile(index) {
this.uploadedFiles.splice(index, 1);
},
clearUploadedFiles() {
this.uploadedFiles = [];
},
// URL Methods
addUrlInput() {
if (this.imageUrls.length < 3) {
this.imageUrls.push({
url: '',
valid: false
});
}
},
removeUrlInput(index) {
this.imageUrls.splice(index, 1);
},
validateImageUrl(index) {
const url = this.imageUrls[index].url.trim();
if (!url) {
this.imageUrls[index].valid = false;
return;
}
const urlPattern = /^https?:\/\/.+/;
this.imageUrls[index].valid = urlPattern.test(url);
},
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
// Submit Method
async validateAndSubmit() {
if (!this.isFormValid) {
this.errorMessage = this.uploadMethod === 'file'
? 'Please upload at least one image and provide a prompt'
: 'Please provide at least one valid image URL and a prompt';
return;
}
this.isLoading = true;
try {
let formData = new FormData();
// Add images based on upload method
if (this.uploadMethod === 'file') {
// Add uploaded files
this.uploadedFiles.forEach(fileObj => {
formData.append('images', fileObj.file);
});
} else {
// For URLs, we need to send as JSON
const validUrls = this.imageUrls
.filter(img => img.valid)
.map(img => img.url.trim());
if (validUrls.length === 0) {
throw new Error('No valid URLs provided');
}
// Create JSON payload for URLs
const payload = {
images: validUrls,
prompt: this.prompt.trim(),
negative_prompt: this.negativePrompt.trim(),
n: this.n,
size: this.size,
prompt_extend: this.promptExtend,
watermark: this.watermark,
model: this.model
};
const response = await fetch('/edit-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
return this.handleResponse(response);
}
// Add other form data for file upload
formData.append('prompt', this.prompt.trim());
formData.append('negative_prompt', this.negativePrompt.trim());
formData.append('n', this.n.toString());
formData.append('size', this.size);
formData.append('prompt_extend', this.promptExtend.toString());
formData.append('watermark', this.watermark.toString());
formData.append('model', this.model);
const response = await fetch('/edit-image', {
method: 'POST',
body: formData
});
return this.handleResponse(response);
} catch (error) {
this.errorMessage = `Upload failed: ${error.message}`;
console.error('Upload Error:', error);
this.isLoading = false;
}
},
async handleResponse(response) {
const result = await response.json();
if (response.ok) {
// Add timestamp and store result
result.timestamp = new Date().toISOString();
this.results.unshift(result);
// Keep only last 6 results
if (this.results.length > 6) {
this.results = this.results.slice(0, 6);
}
// Save to localStorage
localStorage.setItem('qwenImageEditorResults', JSON.stringify(this.results));
// Show success
this.lastRequestId = result.request_id;
this.showSuccessToast = true;
setTimeout(() => {
this.showSuccessToast = false;
}, 5000);
} else {
this.errorMessage = result.error || `Error: ${response.statusText}`;
}
this.isLoading = false;
},
downloadImage(url, index) {
const link = document.createElement('a');
link.href = url;
link.download = `qwen-edited-image-${index + 1}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
resetForm() {
this.uploadMethod = 'file';
this.uploadedFiles = [];
this.imageUrls = [{ url: '', valid: false }];
this.prompt = '';
this.negativePrompt = '';
this.n = 1;
this.size = '1024*1536';
this.promptExtend = true;
this.watermark = false;
this.model = 'qwen-image-edit-max';
}
}
}
</script>
</body>
</html>