From 0b24c74ef323b929b367a4c6a6ed0ae6db0eee10 Mon Sep 17 00:00:00 2001 From: phamhungd Date: Sat, 22 Nov 2025 00:23:18 +0700 Subject: [PATCH] update ver --- .gitignore | 1 + app.py | 151 ++++++- static/modules/gallery.js | 94 +++++ static/modules/metadata.js | 46 ++ static/modules/popup.js | 57 +++ static/modules/referenceSlots.js | 313 ++++++++++++++ static/modules/utils.js | 26 ++ static/script.js | 696 ++++++++++--------------------- static/style.css | 47 +++ templates/index.html | 2 +- 10 files changed, 949 insertions(+), 484 deletions(-) create mode 100644 static/modules/gallery.js create mode 100644 static/modules/metadata.js create mode 100644 static/modules/popup.js create mode 100644 static/modules/referenceSlots.js create mode 100644 static/modules/utils.js diff --git a/.gitignore b/.gitignore index 6261338..15a661e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .DS_Store .DS_Store /.venv +/static/uploads diff --git a/app.py b/app.py index 3351b86..0d5f964 100644 --- a/app.py +++ b/app.py @@ -2,10 +2,12 @@ import os import base64 import uuid import glob +import json +from io import BytesIO from flask import Flask, render_template, request, jsonify, url_for from google import genai from google.genai import types -from PIL import Image +from PIL import Image, PngImagePlugin app = Flask(__name__) app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 @@ -14,6 +16,10 @@ app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 GENERATED_DIR = os.path.join(app.static_folder, 'generated') os.makedirs(GENERATED_DIR, exist_ok=True) +# Ensure uploads directory exists +UPLOADS_DIR = os.path.join(app.static_folder, 'uploads') +os.makedirs(UPLOADS_DIR, exist_ok=True) + @app.route('/') def index(): return render_template('index.html') @@ -29,6 +35,7 @@ def generate_image(): resolution = form.get('resolution', '2K') api_key = form.get('api_key') or os.environ.get('GOOGLE_API_KEY') reference_files = request.files.getlist('reference_images') + reference_paths_json = form.get('reference_image_paths') else: data = request.get_json() or {} prompt = data.get('prompt') @@ -36,6 +43,7 @@ def generate_image(): resolution = data.get('resolution', '2K') api_key = data.get('api_key') or os.environ.get('GOOGLE_API_KEY') reference_files = [] + reference_paths_json = data.get('reference_image_paths') if not prompt: return jsonify({'error': 'Prompt is required'}), 400 @@ -53,14 +61,89 @@ def generate_image(): if aspect_ratio and aspect_ratio != 'Auto': image_config_args["aspect_ratio"] = aspect_ratio + # Process reference paths and files + final_reference_paths = [] contents = [prompt] - for reference in reference_files: + + # Parse reference paths from frontend + frontend_paths = [] + if reference_paths_json: try: - reference.stream.seek(0) - reference_image = Image.open(reference.stream) - contents.append(reference_image) - except Exception: - continue + frontend_paths = json.loads(reference_paths_json) + except json.JSONDecodeError: + pass + + # If no paths provided but we have files (legacy or simple upload), treat all as new uploads + # But we need to handle the mix. + # Strategy: Iterate frontend_paths. If it looks like a path/URL, keep it. + # If it doesn't (or is null), consume from reference_files. + + file_index = 0 + + # If frontend_paths is empty but we have files, just use the files + if not frontend_paths and reference_files: + for _ in reference_files: + frontend_paths.append(None) # Placeholder for each file + + for path in frontend_paths: + if path and (path.startswith('/') or path.startswith('http')): + # Existing path/URL + final_reference_paths.append(path) + # We also need to add the image content to the prompt + # We need to fetch it or read it if it's local (server-side local) + # If it's a URL we generated, it's in static/generated or static/uploads + # path might be "http://localhost:8888/static/generated/..." or "/static/generated/..." + + # Extract relative path to open file + # Assuming path contains '/static/' + try: + if '/static/' in path: + rel_path = path.split('/static/')[1] + abs_path = os.path.join(app.static_folder, rel_path) + if os.path.exists(abs_path): + img = Image.open(abs_path) + contents.append(img) + else: + print(f"Warning: Reference file not found at {abs_path}") + else: + print(f"Warning: Could not resolve local path for {path}") + except Exception as e: + print(f"Error loading reference from path {path}: {e}") + + elif file_index < len(reference_files): + # New upload + file = reference_files[file_index] + file_index += 1 + + try: + # Save to uploads + ext = os.path.splitext(file.filename)[1] + if not ext: + ext = '.png' + filename = f"{uuid.uuid4()}{ext}" + filepath = os.path.join(UPLOADS_DIR, filename) + + # We need to read the file for Gemini AND save it + # file.stream is a stream. + file.stream.seek(0) + file_bytes = file.read() + + with open(filepath, 'wb') as f: + f.write(file_bytes) + + # Add to contents + image = Image.open(BytesIO(file_bytes)) + contents.append(image) + + # Add to final paths + # URL for the uploaded file + rel_path = os.path.join('uploads', filename) + file_url = url_for('static', filename=rel_path) + final_reference_paths.append(file_url) + + except Exception as e: + print(f"Error processing uploaded file: {e}") + continue response = client.models.generate_content( model="gemini-3-pro-image-preview", @@ -74,18 +157,37 @@ def generate_image(): for part in response.parts: if part.inline_data: image_bytes = part.inline_data.data - - # Save image to file + + image = Image.open(BytesIO(image_bytes)) + png_info = PngImagePlugin.PngInfo() + filename = f"{uuid.uuid4()}.png" filepath = os.path.join(GENERATED_DIR, filename) - with open(filepath, "wb") as f: - f.write(image_bytes) - - image_url = url_for('static', filename=f'generated/{filename}') - image_data = base64.b64encode(image_bytes).decode('utf-8') + rel_path = os.path.join('generated', filename) + image_url = url_for('static', filename=rel_path) + + metadata = { + 'prompt': prompt, + 'aspect_ratio': aspect_ratio or 'Auto', + 'resolution': resolution, + 'reference_images': final_reference_paths, + } + + png_info.add_text('sdvn_meta', json.dumps(metadata)) + + buffer = BytesIO() + image.save(buffer, format='PNG', pnginfo=png_info) + final_bytes = buffer.getvalue() + + # Save image to file + with open(filepath, 'wb') as f: + f.write(final_bytes) + + image_data = base64.b64encode(final_bytes).decode('utf-8') return jsonify({ 'image': image_url, 'image_data': image_data, + 'metadata': metadata, }) return jsonify({'error': 'No image generated'}), 500 @@ -93,6 +195,27 @@ def generate_image(): except Exception as e: return jsonify({'error': str(e)}), 500 +@app.route('/delete_image', methods=['POST']) +def delete_image(): + data = request.get_json() + filename = data.get('filename') + + if not filename: + return jsonify({'error': 'Filename is required'}), 400 + + # Security check: ensure filename is just a basename, no paths + filename = os.path.basename(filename) + filepath = os.path.join(GENERATED_DIR, filename) + + if os.path.exists(filepath): + try: + os.remove(filepath) + return jsonify({'success': True}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + else: + return jsonify({'error': 'File not found'}), 404 + @app.route('/gallery') def get_gallery(): # List all png files in generated dir, sorted by modification time (newest first) diff --git a/static/modules/gallery.js b/static/modules/gallery.js new file mode 100644 index 0000000..6cf33b4 --- /dev/null +++ b/static/modules/gallery.js @@ -0,0 +1,94 @@ +import { withCacheBuster } from './utils.js'; +import { extractMetadataFromBlob } from './metadata.js'; + +export function createGallery({ galleryGrid, onSelect }) { + async function readMetadataFromImage(imageUrl) { + try { + const response = await fetch(withCacheBuster(imageUrl)); + if (!response.ok) return null; + const blob = await response.blob(); + return await extractMetadataFromBlob(blob); + } catch (error) { + console.warn('Unable to read gallery metadata', error); + return null; + } + } + + async function load() { + if (!galleryGrid) return; + try { + const response = await fetch(`/gallery?t=${new Date().getTime()}`); + const data = await response.json(); + galleryGrid.innerHTML = ''; + + data.images.forEach(imageUrl => { + const div = document.createElement('div'); + div.className = 'gallery-item'; + + // Image container for positioning + div.style.position = 'relative'; + + const img = document.createElement('img'); + img.src = withCacheBuster(imageUrl); + img.loading = 'lazy'; + img.draggable = true; + img.dataset.source = imageUrl; + + // Click to select + div.addEventListener('click', async (e) => { + // Don't select if clicking delete button + if (e.target.closest('.delete-btn')) return; + + const metadata = await readMetadataFromImage(imageUrl); + await onSelect?.({ imageUrl, metadata }); + const siblings = galleryGrid.querySelectorAll('.gallery-item'); + siblings.forEach(el => el.classList.remove('active')); + div.classList.add('active'); + }); + + img.addEventListener('dragstart', event => { + event.dataTransfer?.setData('text/uri-list', imageUrl); + event.dataTransfer?.setData('text/plain', imageUrl); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'copy'; + } + }); + + // Delete button + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'delete-btn'; + deleteBtn.innerHTML = '×'; + deleteBtn.title = 'Delete image'; + deleteBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + if (!confirm('Are you sure you want to delete this image?')) return; + + const filename = imageUrl.split('/').pop().split('?')[0]; + try { + const res = await fetch('/delete_image', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename }) + }); + + if (res.ok) { + div.remove(); + } else { + console.error('Failed to delete image'); + } + } catch (err) { + console.error('Error deleting image:', err); + } + }); + + div.appendChild(img); + div.appendChild(deleteBtn); + galleryGrid.appendChild(div); + }); + } catch (error) { + console.error('Failed to load gallery:', error); + } + } + + return { load }; +} diff --git a/static/modules/metadata.js b/static/modules/metadata.js new file mode 100644 index 0000000..cc34cef --- /dev/null +++ b/static/modules/metadata.js @@ -0,0 +1,46 @@ +export async function extractMetadataFromBlob(blob, key = 'sdvn_meta') { + try { + const arrayBuffer = await blob.arrayBuffer(); + if (arrayBuffer.byteLength < 8) return null; + const view = new DataView(arrayBuffer); + const signature = [137, 80, 78, 71, 13, 10, 26, 10]; + for (let i = 0; i < signature.length; i++) { + if (view.getUint8(i) !== signature[i]) return null; + } + + let offset = 8; + const decoder = new TextDecoder('utf-8'); + + while (offset + 12 <= view.byteLength) { + const length = view.getUint32(offset); + offset += 4; + const chunkTypeBytes = new Uint8Array(arrayBuffer, offset, 4); + const chunkType = decoder.decode(chunkTypeBytes); + offset += 4; + + const chunkData = new Uint8Array(arrayBuffer, offset, length); + offset += length; + offset += 4; // skip CRC + + if (chunkType === 'tEXt') { + const chunkText = decoder.decode(chunkData); + const nullIndex = chunkText.indexOf('\u0000'); + if (nullIndex !== -1) { + const chunkKey = chunkText.slice(0, nullIndex); + const chunkValue = chunkText.slice(nullIndex + 1); + if (chunkKey === key) { + try { + return JSON.parse(chunkValue); + } catch (error) { + console.warn('Invalid metadata JSON', error); + return null; + } + } + } + } + } + } catch (error) { + console.warn('Unable to extract metadata', error); + } + return null; +} diff --git a/static/modules/popup.js b/static/modules/popup.js new file mode 100644 index 0000000..a1cab10 --- /dev/null +++ b/static/modules/popup.js @@ -0,0 +1,57 @@ +export function setupHelpPopups({ buttonsSelector, overlayId, titleId, bodyId, closeBtnId, content }) { + const overlay = document.getElementById(overlayId); + const titleEl = document.getElementById(titleId); + const bodyEl = document.getElementById(bodyId); + const closeBtn = document.getElementById(closeBtnId); + const buttons = document.querySelectorAll(buttonsSelector); + + if (!overlay || !titleEl || !bodyEl) return; + + buttons.forEach(button => { + button.addEventListener('click', () => { + const target = button.dataset.popupTarget; + if (target) { + showPopup(target); + } + }); + }); + + closeBtn?.addEventListener('click', closePopup); + + overlay.addEventListener('click', event => { + if (event.target === overlay) { + closePopup(); + } + }); + + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && !overlay.classList.contains('hidden')) { + event.preventDefault(); + closePopup(); + } + }); + + function showPopup(type) { + const popupContent = content[type]; + if (!popupContent) return; + + titleEl.textContent = popupContent.title; + bodyEl.innerHTML = popupContent.sections + .map(section => { + const items = (section.items || []).map(item => `
  • ${item}
  • `).join(''); + return ` + + `; + }) + .join(''); + + overlay.classList.remove('hidden'); + } + + function closePopup() { + overlay.classList.add('hidden'); + } +} diff --git a/static/modules/referenceSlots.js b/static/modules/referenceSlots.js new file mode 100644 index 0000000..9ddd6f4 --- /dev/null +++ b/static/modules/referenceSlots.js @@ -0,0 +1,313 @@ +import { dataUrlToBlob, withCacheBuster } from './utils.js'; + +export function createReferenceSlotManager(imageInputGrid, options = {}) { + const MAX_IMAGE_SLOTS = 16; + const INITIAL_IMAGE_SLOTS = 4; + const onChange = options.onChange; + const imageSlotState = []; + let cachedReferenceImages = []; + + function initialize(initialCached = []) { + cachedReferenceImages = Array.isArray(initialCached) ? initialCached : []; + const requiredSlots = Math.min( + MAX_IMAGE_SLOTS, + Math.max(INITIAL_IMAGE_SLOTS, cachedReferenceImages.length + 1) + ); + + for (let i = 0; i < requiredSlots; i++) { + addImageSlot(); + } + + cachedReferenceImages.forEach((cached, index) => applyCachedImageToSlot(index, cached)); + maybeAddSlot(); + } + + function getReferenceFiles() { + return imageSlotState + .filter(record => record.data && !record.data.sourceUrl) // Only return files if no sourceUrl + .map(record => getSlotFile(record)) + .filter(Boolean); + } + + function getReferencePaths() { + return imageSlotState.map(record => { + if (!record.data) return null; + return record.data.sourceUrl || record.data.file?.name || null; + }); + } + + function serializeReferenceImages() { + return imageSlotState + .map((record, index) => { + if (!record.data || !record.data.preview) return null; + const name = record.data.cached?.name || record.data.file?.name || `reference-${index + 1}.png`; + const type = record.data.cached?.type || record.data.file?.type || 'image/png'; + return { + name, + type, + dataUrl: record.data.preview, + sourceUrl: record.data.sourceUrl, + }; + }) + .filter(Boolean); + } + + function addImageSlot() { + if (!imageInputGrid || imageSlotState.length >= MAX_IMAGE_SLOTS) return; + const index = imageSlotState.length; + const slotElement = createImageSlotElement(index); + imageSlotState.push({ slot: slotElement, data: null }); + imageInputGrid.appendChild(slotElement); + } + + function createImageSlotElement(index) { + const slot = document.createElement('div'); + slot.className = 'image-slot empty'; + slot.dataset.index = index; + + const placeholder = document.createElement('div'); + placeholder.className = 'slot-placeholder'; + placeholder.innerHTML = '+'; + slot.appendChild(placeholder); + + const preview = document.createElement('img'); + preview.className = 'slot-preview hidden'; + preview.alt = 'Uploaded reference'; + slot.appendChild(preview); + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'slot-remove hidden'; + removeBtn.setAttribute('aria-label', 'Remove image'); + removeBtn.textContent = '×'; + slot.appendChild(removeBtn); + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.className = 'slot-input'; + slot.appendChild(input); + + slot.addEventListener('click', event => { + if (event.target === removeBtn) return; + input.click(); + }); + + input.addEventListener('change', () => { + if (input.files && input.files.length) { + handleSlotFile(index, input.files[0]); + } + }); + + slot.addEventListener('dragenter', event => { + event.preventDefault(); + slot.classList.add('drag-over'); + }); + + slot.addEventListener('dragover', event => { + event.preventDefault(); + slot.classList.add('drag-over'); + }); + + slot.addEventListener('dragleave', () => { + slot.classList.remove('drag-over'); + }); + + slot.addEventListener('drop', async event => { + event.preventDefault(); + slot.classList.remove('drag-over'); + const file = event.dataTransfer?.files?.[0]; + if (file) { + handleSlotFile(index, file); + return; + } + + const imageUrl = event.dataTransfer?.getData('text/uri-list') + || event.dataTransfer?.getData('text/plain'); + if (imageUrl) { + await handleSlotDropFromHistory(index, imageUrl); + } + }); + + removeBtn.addEventListener('click', event => { + event.stopPropagation(); + clearSlot(index); + }); + + return slot; + } + + function handleSlotFile(index, file, sourceUrl = null) { + if (!file || !file.type.startsWith('image/')) return; + const reader = new FileReader(); + reader.onload = () => { + const previewUrl = reader.result; + if (typeof previewUrl !== 'string') return; + const slotRecord = imageSlotState[index]; + if (!slotRecord) return; + slotRecord.data = { + file, + preview: previewUrl, + cached: null, + sourceUrl: sourceUrl, + }; + updateSlotVisual(index); + onChange?.(); + maybeAddSlot(); + }; + reader.readAsDataURL(file); + } + + async function handleSlotDropFromHistory(index, imageUrl) { + try { + const response = await fetch(withCacheBuster(imageUrl)); + if (!response.ok) { + console.warn('Failed to fetch history image', response.statusText); + return; + } + + const blob = await response.blob(); + const name = imageUrl.split('/').pop()?.split('?')[0] || `history-${index + 1}.png`; + const type = blob.type || 'image/png'; + const file = new File([blob], name, { type }); + + // Extract relative path if possible, or use full URL + // Assuming imageUrl is like http://localhost:8888/static/generated/uuid.png + // We want /static/generated/uuid.png or just generated/uuid.png if that's how we store it. + // The app.py stores 'generated/filename.png' in metadata if we send it. + // But wait, app.py constructs image_url using url_for('static', filename=rel_path). + // Let's just store the full URL or relative path. + // If we store the full URL, we can fetch it back. + + let sourceUrl = imageUrl; + try { + const urlObj = new URL(imageUrl, window.location.origin); + if (urlObj.origin === window.location.origin) { + sourceUrl = urlObj.pathname; + } + } catch (e) { + // ignore + } + + handleSlotFile(index, file, sourceUrl); + } catch (error) { + console.error('Unable to import history image', error); + } + } + + function updateSlotVisual(index) { + const slotRecord = imageSlotState[index]; + if (!slotRecord) return; + const slot = slotRecord.slot; + const placeholder = slot.querySelector('.slot-placeholder'); + const preview = slot.querySelector('.slot-preview'); + const removeBtn = slot.querySelector('.slot-remove'); + + if (slotRecord.data && slotRecord.data.preview) { + preview.src = slotRecord.data.preview; + preview.classList.remove('hidden'); + placeholder.classList.add('hidden'); + removeBtn.classList.remove('hidden'); + slot.classList.add('filled'); + slot.classList.remove('empty'); + } else { + preview.src = ''; + preview.classList.add('hidden'); + placeholder.classList.remove('hidden'); + removeBtn.classList.add('hidden'); + slot.classList.add('empty'); + slot.classList.remove('filled'); + } + } + + function clearSlot(index) { + const slotRecord = imageSlotState[index]; + if (!slotRecord) return; + slotRecord.data = null; + const input = slotRecord.slot.querySelector('.slot-input'); + if (input) input.value = ''; + updateSlotVisual(index); + onChange?.(); + } + + function maybeAddSlot() { + const hasEmpty = imageSlotState.some(record => !record.data); + if (!hasEmpty && imageSlotState.length < MAX_IMAGE_SLOTS) { + addImageSlot(); + } + } + + function applyCachedImageToSlot(index, cached) { + if (!cached || !cached.dataUrl) return; + const slotRecord = imageSlotState[index]; + if (!slotRecord) return; + slotRecord.data = { + file: null, + preview: cached.dataUrl, + cached: { + name: cached.name, + type: cached.type, + dataUrl: cached.dataUrl, + }, + sourceUrl: cached.sourceUrl || null, + }; + updateSlotVisual(index); + } + + async function setReferenceImages(paths) { + if (!Array.isArray(paths)) return; + + // Clear existing slots first? Or overwrite? + // Let's overwrite from the beginning. + + // Ensure we have enough slots + while (imageSlotState.length < paths.length && imageSlotState.length < MAX_IMAGE_SLOTS) { + addImageSlot(); + } + + for (let i = 0; i < paths.length; i++) { + const path = paths[i]; + if (!path) { + clearSlot(i); + continue; + } + + // Check if it looks like a path we can fetch + if (path.startsWith('/') || path.startsWith('http')) { + await handleSlotDropFromHistory(i, path); + } else { + // It's likely just a filename of a local file we can't restore + // So we clear the slot + clearSlot(i); + } + } + + // Clear remaining slots + for (let i = paths.length; i < imageSlotState.length; i++) { + clearSlot(i); + } + + maybeAddSlot(); + } + + function getSlotFile(record) { + if (!record.data) return null; + if (record.data.file) return record.data.file; + if (record.data.cached && record.data.cached.dataUrl) { + const blob = dataUrlToBlob(record.data.cached.dataUrl); + if (!blob) return null; + const fileName = record.data.cached.name || `reference.png`; + const fileType = record.data.cached.type || 'image/png'; + return new File([blob], fileName, { type: fileType }); + } + return null; + } + + return { + initialize, + getReferenceFiles, + getReferencePaths, + serializeReferenceImages, + setReferenceImages, + }; +} diff --git a/static/modules/utils.js b/static/modules/utils.js new file mode 100644 index 0000000..3732ede --- /dev/null +++ b/static/modules/utils.js @@ -0,0 +1,26 @@ +export function withCacheBuster(url) { + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}t=${new Date().getTime()}`; +} + +export function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +export function dataUrlToBlob(dataUrl) { + try { + const [prefix, base64] = dataUrl.split(','); + const mimeMatch = prefix.match(/:(.*?);/); + const mime = mimeMatch ? mimeMatch[1] : 'image/png'; + const binary = atob(base64); + const len = binary.length; + const buffer = new Uint8Array(len); + for (let i = 0; i < len; i++) { + buffer[i] = binary.charCodeAt(i); + } + return new Blob([buffer], { type: mime }); + } catch (error) { + console.warn('Unable to convert cached image to blob', error); + return null; + } +} diff --git a/static/script.js b/static/script.js index 2205da3..0a08ef2 100644 --- a/static/script.js +++ b/static/script.js @@ -1,87 +1,106 @@ +import { withCacheBuster, clamp } from './modules/utils.js'; +import { createGallery } from './modules/gallery.js'; +import { createReferenceSlotManager } from './modules/referenceSlots.js'; +import { setupHelpPopups } from './modules/popup.js'; +import { extractMetadataFromBlob } from './modules/metadata.js'; + +const SETTINGS_STORAGE_KEY = 'gemini-image-app-settings'; +const ZOOM_STEP = 0.1; +const MIN_ZOOM = 0.4; +const MAX_ZOOM = 4; + +const infoContent = { + title: 'Thông tin', + sections: [ + { + heading: 'Liên hệ', + items: [ + 'Người tạo: Phạm Hưng', + 'Group: SDVN - Cộng đồng AI Art', + 'Website: sdvn.vn', + ], + }, + ], +}; + +const docsContent = { + title: 'Phím tắt và mẹo', + sections: [ + { + heading: 'Phím tắt', + items: [ + 'Ctrl/Cmd + Enter → tạo ảnh mới', + 'D → tải ảnh hiện tại', + 'D → tải ảnh hiện tại', + 'Space → reset zoom/pan vùng hiển thị ảnh', + 'Esc → đóng popup thông tin/docs', + ], + }, + { + heading: 'Thao tác nhanh', + items: [ + 'Kéo ảnh từ lịch sử vào ô tham chiếu để tái sử dụng', + 'Tùy chỉnh tỉ lệ và độ phân giải trước khi nhấn Generate', + 'API key và prompt được lưu để lần sau không phải nhập lại', + ], + }, + ], +}; + +const POPUP_CONTENT = { + info: infoContent, + docs: docsContent, +}; + document.addEventListener('DOMContentLoaded', () => { const generateBtn = document.getElementById('generate-btn'); const promptInput = document.getElementById('prompt'); const aspectRatioInput = document.getElementById('aspect-ratio'); const resolutionInput = document.getElementById('resolution'); const apiKeyInput = document.getElementById('api-key'); - - // States + const placeholderState = document.getElementById('placeholder-state'); const loadingState = document.getElementById('loading-state'); const errorState = document.getElementById('error-state'); const resultState = document.getElementById('result-state'); - const errorText = document.getElementById('error-text'); const generatedImage = document.getElementById('generated-image'); const downloadLink = document.getElementById('download-link'); const galleryGrid = document.getElementById('gallery-grid'); const imageInputGrid = document.getElementById('image-input-grid'); - const SETTINGS_STORAGE_KEY = 'gemini-image-app-settings'; - const MAX_IMAGE_SLOTS = 16; - const INITIAL_IMAGE_SLOTS = 4; - const imageSlotState = []; - let cachedReferenceImages = []; const imageDisplayArea = document.querySelector('.image-display-area'); const canvasToolbar = document.querySelector('.canvas-toolbar'); - const popupOverlay = document.getElementById('popup-overlay'); - const popupTitleEl = document.getElementById('popup-title'); - const popupBodyEl = document.getElementById('popup-body'); - const popupCloseBtn = document.getElementById('popup-close'); - const popupButtons = document.querySelectorAll('[data-popup-target]'); - const ZOOM_STEP = 0.1; - const MIN_ZOOM = 0.4; - const MAX_ZOOM = 4; + let zoomLevel = 1; let panOffset = { x: 0, y: 0 }; let isPanning = false; let lastPointer = { x: 0, y: 0 }; - const infoContent = { - title: 'Thông tin', - sections: [ - { - heading: 'Liên hệ', - items: [ - 'Người tạo: Phạm Hưng', - 'Group: SDVN - Cộng đồng AI Art', - 'Website: sdvn.vn', - ], - }, - ], - }; + const slotManager = createReferenceSlotManager(imageInputGrid, { + onChange: persistSettings, + }); - const docsContent = { - title: 'Phím tắt và mẹo', - sections: [ - { - heading: 'Phím tắt', - items: [ - 'Ctrl/Cmd + Enter → tạo ảnh mới', - 'D → tải ảnh hiện tại', - 'R → reset zoom/pan vùng hiển thị ảnh', - 'Esc → đóng popup thông tin/docs', - ], - }, - { - heading: 'Thao tác nhanh', - items: [ - 'Kéo ảnh từ lịch sử vào ô tham chiếu để tái sử dụng', - 'Tùy chỉnh tỉ lệ và độ phân giải trước khi nhấn Generate', - 'API key và prompt được lưu để lần sau không phải nhập lại', - ], - }, - ], - }; + const gallery = createGallery({ + galleryGrid, + onSelect: async ({ imageUrl, metadata }) => { + displayImage(imageUrl); + if (metadata) { + applyMetadata(metadata); + } + }, + }); - const POPUP_CONTENT = { - info: infoContent, - docs: docsContent, - }; + setupHelpPopups({ + buttonsSelector: '[data-popup-target]', + overlayId: 'popup-overlay', + titleId: 'popup-title', + bodyId: 'popup-body', + closeBtnId: 'popup-close', + content: POPUP_CONTENT, + }); - // Load gallery on start - loadSettings(); - initializeImageInputs(); - loadGallery(); + const savedSettings = loadSettings(); + slotManager.initialize(savedSettings.referenceImages || []); apiKeyInput.addEventListener('input', persistSettings); promptInput.addEventListener('input', persistSettings); @@ -99,7 +118,6 @@ document.addEventListener('DOMContentLoaded', () => { return; } - // Set UI to loading setViewState('loading'); generateBtn.disabled = true; @@ -124,12 +142,10 @@ document.addEventListener('DOMContentLoaded', () => { if (data.image) { displayImage(data.image, data.image_data); - // Refresh gallery to show new image - loadGallery(); + gallery.load(); } else { throw new Error('No image data received'); } - } catch (error) { showError(error.message); } finally { @@ -137,6 +153,78 @@ document.addEventListener('DOMContentLoaded', () => { } }); + document.addEventListener('keydown', handleGenerateShortcut); + document.addEventListener('keydown', handleResetShortcut); + document.addEventListener('keydown', handleDownloadShortcut); + + if (imageDisplayArea) { + imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false }); + imageDisplayArea.addEventListener('pointerdown', handleCanvasPointerDown); + + // Drag and drop support + imageDisplayArea.addEventListener('dragover', (e) => { + e.preventDefault(); + imageDisplayArea.classList.add('drag-over'); + }); + + imageDisplayArea.addEventListener('dragleave', (e) => { + e.preventDefault(); + imageDisplayArea.classList.remove('drag-over'); + }); + + imageDisplayArea.addEventListener('drop', async (e) => { + e.preventDefault(); + imageDisplayArea.classList.remove('drag-over'); + + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const file = files[0]; + if (file.type.startsWith('image/')) { + try { + // Display image immediately + const objectUrl = URL.createObjectURL(file); + displayImage(objectUrl); + + // Extract and apply metadata + const metadata = await extractMetadataFromBlob(file); + if (metadata) { + applyMetadata(metadata); + } + } catch (error) { + console.error('Error handling dropped image:', error); + } + } + } + else { + const imageUrl = e.dataTransfer?.getData('text/uri-list') + || e.dataTransfer?.getData('text/plain'); + if (imageUrl) { + await handleCanvasDropUrl(imageUrl.trim()); + } + } + }); + } + + if (canvasToolbar) { + canvasToolbar.addEventListener('click', handleCanvasToolbarClick); + } + + document.addEventListener('pointermove', handleCanvasPointerMove); + document.addEventListener('pointerup', () => { + if (isPanning && imageDisplayArea) { + imageDisplayArea.style.cursor = 'grab'; + } + isPanning = false; + }); + document.addEventListener('pointerleave', () => { + if (isPanning && imageDisplayArea) { + imageDisplayArea.style.cursor = 'grab'; + } + isPanning = false; + }); + + loadGallery(); + function setViewState(state) { placeholderState.classList.add('hidden'); loadingState.classList.add('hidden'); @@ -159,49 +247,16 @@ document.addEventListener('DOMContentLoaded', () => { } } - document.addEventListener('keydown', handleGenerateShortcut); - document.addEventListener('keydown', handleResetShortcut); - document.addEventListener('keydown', handleDownloadShortcut); - - function handleGenerateShortcut(event) { - if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { - event.preventDefault(); - if (generateBtn && !generateBtn.disabled) { - generateBtn.click(); - } - } - } - - if (imageDisplayArea) { - imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false }); - imageDisplayArea.addEventListener('pointerdown', handleCanvasPointerDown); - } - - if (canvasToolbar) { - canvasToolbar.addEventListener('click', handleCanvasToolbarClick); - } - - document.addEventListener('pointermove', handleCanvasPointerMove); - document.addEventListener('pointerup', () => { - if (isPanning && imageDisplayArea) { - imageDisplayArea.style.cursor = 'grab'; - } - isPanning = false; - }); - document.addEventListener('pointerleave', () => { - if (isPanning && imageDisplayArea) { - imageDisplayArea.style.cursor = 'grab'; - } - isPanning = false; - }); - function showError(message) { errorText.textContent = message; setViewState('error'); } function displayImage(imageUrl, imageData) { - const cacheBustedUrl = withCacheBuster(imageUrl); + let cacheBustedUrl = imageUrl; + if (!imageUrl.startsWith('blob:') && !imageUrl.startsWith('data:')) { + cacheBustedUrl = withCacheBuster(imageUrl); + } if (imageData) { generatedImage.src = `data:image/png;base64,${imageData}`; @@ -220,47 +275,40 @@ document.addEventListener('DOMContentLoaded', () => { setViewState('result'); } - async function loadGallery() { + async function handleCanvasDropUrl(imageUrl) { + const cleanedUrl = imageUrl; + displayImage(cleanedUrl); try { - const response = await fetch(`/gallery?t=${new Date().getTime()}`); - const data = await response.json(); - - galleryGrid.innerHTML = ''; - - data.images.forEach(imageUrl => { - const div = document.createElement('div'); - div.className = 'gallery-item'; - div.onclick = () => { - displayImage(imageUrl); - // Update active state - document.querySelectorAll('.gallery-item').forEach(el => el.classList.remove('active')); - div.classList.add('active'); - }; - - const img = document.createElement('img'); - img.src = withCacheBuster(imageUrl); - img.loading = 'lazy'; - img.draggable = true; - img.dataset.source = imageUrl; - img.addEventListener('dragstart', event => { - event.dataTransfer?.setData('text/uri-list', imageUrl); - event.dataTransfer?.setData('text/plain', imageUrl); - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = 'copy'; - } - }); - - div.appendChild(img); - galleryGrid.appendChild(div); - }); + const response = await fetch(withCacheBuster(cleanedUrl)); + if (!response.ok) return; + const metadata = await extractMetadataFromBlob(await response.blob()); + if (metadata) { + applyMetadata(metadata); + } } catch (error) { - console.error('Failed to load gallery:', error); + console.warn('Unable to read metadata from dropped image', error); } } - function withCacheBuster(url) { - const separator = url.includes('?') ? '&' : '?'; - return `${url}${separator}t=${new Date().getTime()}`; + function applyMetadata(metadata) { + if (!metadata) return; + if (metadata.prompt) promptInput.value = metadata.prompt; + if (metadata.aspect_ratio) aspectRatioInput.value = metadata.aspect_ratio; + if (metadata.resolution) resolutionInput.value = metadata.resolution; + + if (metadata.reference_images && Array.isArray(metadata.reference_images)) { + slotManager.setReferenceImages(metadata.reference_images); + } + + persistSettings(); + } + + async function loadGallery() { + try { + await gallery.load(); + } catch (error) { + console.error('Unable to populate gallery', error); + } } function buildGenerateFormData(fields) { @@ -272,30 +320,33 @@ document.addEventListener('DOMContentLoaded', () => { } }); - imageSlotState.forEach(record => { - const slotFile = getSlotFile(record); - if (slotFile) { - formData.append('reference_images', slotFile, slotFile.name); - } + slotManager.getReferenceFiles().forEach(file => { + formData.append('reference_images', file, file.name); }); + + const referencePaths = slotManager.getReferencePaths(); + if (referencePaths && referencePaths.length > 0) { + formData.append('reference_image_paths', JSON.stringify(referencePaths)); + } return formData; } function loadSettings() { - if (typeof localStorage === 'undefined') return; + if (typeof localStorage === 'undefined') return {}; try { const saved = localStorage.getItem(SETTINGS_STORAGE_KEY); - if (!saved) return; + if (!saved) return {}; const { apiKey, aspectRatio, resolution, prompt, referenceImages } = JSON.parse(saved); if (apiKey) apiKeyInput.value = apiKey; if (aspectRatio) aspectRatioInput.value = aspectRatio; if (resolution) resolutionInput.value = resolution; if (prompt) promptInput.value = prompt; - cachedReferenceImages = Array.isArray(referenceImages) ? referenceImages : []; + return { apiKey, aspectRatio, resolution, prompt, referenceImages }; } catch (error) { console.warn('Unable to load cached settings', error); + return {}; } } @@ -307,7 +358,7 @@ document.addEventListener('DOMContentLoaded', () => { aspectRatio: aspectRatioInput.value, resolution: resolutionInput.value, prompt: promptInput.value.trim(), - referenceImages: serializeReferenceImages(), + referenceImages: slotManager.serializeReferenceImages(), }; localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); } catch (error) { @@ -315,6 +366,37 @@ document.addEventListener('DOMContentLoaded', () => { } } + function handleGenerateShortcut(event) { + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { + event.preventDefault(); + if (generateBtn && !generateBtn.disabled) { + generateBtn.click(); + } + } + } + + function handleResetShortcut(event) { + if (event.code !== 'Space' && event.key !== ' ') return; + if (event.ctrlKey || event.metaKey || event.altKey) return; + const targetTag = event.target?.tagName; + if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return; + if (event.target?.isContentEditable) return; + if (resultState.classList.contains('hidden')) return; + event.preventDefault(); + resetView(); + } + + function handleDownloadShortcut(event) { + if (event.key !== 'd') return; + if (event.ctrlKey || event.metaKey || event.altKey) return; + const targetTag = event.target?.tagName; + if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return; + if (event.target?.isContentEditable) return; + if (resultState.classList.contains('hidden')) return; + event.preventDefault(); + downloadLink.click(); + } + function handleCanvasWheel(event) { if (resultState.classList.contains('hidden')) return; event.preventDefault(); @@ -390,328 +472,4 @@ document.addEventListener('DOMContentLoaded', () => { panOffset = { x: 0, y: 0 }; setImageTransform(); } - - function clamp(value, min, max) { - return Math.min(Math.max(value, min), max); - } - - function handleResetShortcut(event) { - if (event.key !== 'r') return; - if (event.ctrlKey || event.metaKey || event.altKey) return; - const targetTag = event.target?.tagName; - if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return; - if (event.target?.isContentEditable) return; - if (resultState.classList.contains('hidden')) return; - event.preventDefault(); - resetView(); - } - - function handleDownloadShortcut(event) { - if (event.key !== 'd') return; - if (event.ctrlKey || event.metaKey || event.altKey) return; - const targetTag = event.target?.tagName; - if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return; - if (event.target?.isContentEditable) return; - if (resultState.classList.contains('hidden')) return; - event.preventDefault(); - downloadLink.click(); - } - - function initializeImageInputs() { - if (!imageInputGrid) return; - const requiredSlots = Math.min( - MAX_IMAGE_SLOTS, - Math.max(INITIAL_IMAGE_SLOTS, cachedReferenceImages.length + 1) - ); - for (let i = 0; i < requiredSlots; i++) { - addImageSlot(); - } - cachedReferenceImages.forEach((cached, index) => { - applyCachedImageToSlot(index, cached); - }); - maybeAddSlot(); - } - - function applyCachedImageToSlot(index, cached) { - if (!cached || !cached.dataUrl) return; - const slotRecord = imageSlotState[index]; - if (!slotRecord) return; - slotRecord.data = { - file: null, - preview: cached.dataUrl, - cached: { - name: cached.name, - type: cached.type, - dataUrl: cached.dataUrl, - }, - }; - updateSlotVisual(index); - } - - function addImageSlot() { - if (!imageInputGrid || imageSlotState.length >= MAX_IMAGE_SLOTS) return; - const index = imageSlotState.length; - const slotElement = createImageSlotElement(index); - imageSlotState.push({ - slot: slotElement, - data: null, - }); - imageInputGrid.appendChild(slotElement); - } - - function createImageSlotElement(index) { - const slot = document.createElement('div'); - slot.className = 'image-slot empty'; - slot.dataset.index = index; - - const placeholder = document.createElement('div'); - placeholder.className = 'slot-placeholder'; - placeholder.innerHTML = '+'; - slot.appendChild(placeholder); - - const preview = document.createElement('img'); - preview.className = 'slot-preview hidden'; - preview.alt = 'Uploaded reference'; - slot.appendChild(preview); - - const removeBtn = document.createElement('button'); - removeBtn.type = 'button'; - removeBtn.className = 'slot-remove hidden'; - removeBtn.setAttribute('aria-label', 'Remove image'); - removeBtn.textContent = '×'; - slot.appendChild(removeBtn); - - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'image/*'; - input.className = 'slot-input'; - slot.appendChild(input); - - slot.addEventListener('click', event => { - if (event.target === removeBtn) return; - input.click(); - }); - - input.addEventListener('change', () => { - if (input.files && input.files.length) { - handleSlotFile(index, input.files[0]); - } - }); - - slot.addEventListener('dragenter', event => { - event.preventDefault(); - slot.classList.add('drag-over'); - }); - - slot.addEventListener('dragover', event => { - event.preventDefault(); - slot.classList.add('drag-over'); - }); - - slot.addEventListener('dragleave', () => { - slot.classList.remove('drag-over'); - }); - - slot.addEventListener('drop', async event => { - event.preventDefault(); - slot.classList.remove('drag-over'); - const file = event.dataTransfer?.files?.[0]; - if (file) { - handleSlotFile(index, file); - return; - } - - const imageUrl = event.dataTransfer?.getData('text/uri-list') - || event.dataTransfer?.getData('text/plain'); - if (imageUrl) { - await handleSlotDropFromHistory(index, imageUrl); - } - }); - - removeBtn.addEventListener('click', event => { - event.stopPropagation(); - clearSlot(index); - }); - - return slot; - } - - function handleSlotFile(index, file) { - if (!file || !file.type.startsWith('image/')) return; - const reader = new FileReader(); - reader.onload = () => { - const previewUrl = reader.result; - if (typeof previewUrl !== 'string') return; - const slotRecord = imageSlotState[index]; - if (!slotRecord) return; - slotRecord.data = { - file, - preview: previewUrl, - cached: null, - }; - updateSlotVisual(index); - persistSettings(); - maybeAddSlot(); - }; - reader.readAsDataURL(file); - } - - async function handleSlotDropFromHistory(index, imageUrl) { - try { - const response = await fetch(withCacheBuster(imageUrl)); - if (!response.ok) { - console.warn('Failed to fetch history image', response.statusText); - return; - } - - const blob = await response.blob(); - const name = imageUrl.split('/').pop()?.split('?')[0] || `history-${index + 1}.png`; - const type = blob.type || 'image/png'; - const file = new File([blob], name, { type }); - handleSlotFile(index, file); - } catch (error) { - console.error('Unable to import history image', error); - } - } - - function updateSlotVisual(index) { - const slotRecord = imageSlotState[index]; - if (!slotRecord) return; - const slot = slotRecord.slot; - const placeholder = slot.querySelector('.slot-placeholder'); - const preview = slot.querySelector('.slot-preview'); - const removeBtn = slot.querySelector('.slot-remove'); - - if (slotRecord.data && slotRecord.data.preview) { - preview.src = slotRecord.data.preview; - preview.classList.remove('hidden'); - placeholder.classList.add('hidden'); - removeBtn.classList.remove('hidden'); - slot.classList.add('filled'); - slot.classList.remove('empty'); - } else { - preview.src = ''; - preview.classList.add('hidden'); - placeholder.classList.remove('hidden'); - removeBtn.classList.add('hidden'); - slot.classList.add('empty'); - slot.classList.remove('filled'); - } - } - - function clearSlot(index) { - const slotRecord = imageSlotState[index]; - if (!slotRecord) return; - slotRecord.data = null; - const input = slotRecord.slot.querySelector('.slot-input'); - if (input) input.value = ''; - updateSlotVisual(index); - persistSettings(); - } - - function maybeAddSlot() { - const hasEmpty = imageSlotState.some(record => !record.data); - if (!hasEmpty && imageSlotState.length < MAX_IMAGE_SLOTS) { - addImageSlot(); - } - } - - function serializeReferenceImages() { - return imageSlotState - .map((record, index) => { - if (!record.data || !record.data.preview) return null; - const name = record.data.cached?.name || record.data.file?.name || `reference-${index + 1}.png`; - const type = record.data.cached?.type || record.data.file?.type || 'image/png'; - return { - name, - type, - dataUrl: record.data.preview, - }; - }) - .filter(Boolean); - } - - function getSlotFile(record) { - if (!record.data) return null; - if (record.data.file) return record.data.file; - if (record.data.cached && record.data.cached.dataUrl) { - const blob = dataUrlToBlob(record.data.cached.dataUrl); - if (!blob) return null; - const fileName = record.data.cached.name || `reference.png`; - const fileType = record.data.cached.type || 'image/png'; - return new File([blob], fileName, { type: fileType }); - } - return null; - } - - function dataUrlToBlob(dataUrl) { - try { - const [prefix, base64] = dataUrl.split(','); - const mimeMatch = prefix.match(/:(.*?);/); - const mime = mimeMatch ? mimeMatch[1] : 'image/png'; - const binary = atob(base64); - const len = binary.length; - const buffer = new Uint8Array(len); - for (let i = 0; i < len; i++) { - buffer[i] = binary.charCodeAt(i); - } - return new Blob([buffer], { type: mime }); - } catch (error) { - console.warn('Unable to convert cached image to blob', error); - return null; - } - } - - popupButtons.forEach(button => { - button.addEventListener('click', () => { - const target = button.dataset.popupTarget; - if (target) { - showPopup(target); - } - }); - }); - - if (popupCloseBtn) { - popupCloseBtn.addEventListener('click', closePopup); - } - - if (popupOverlay) { - popupOverlay.addEventListener('click', event => { - if (event.target === popupOverlay) { - closePopup(); - } - }); - } - - document.addEventListener('keydown', event => { - if (event.key === 'Escape' && popupOverlay && !popupOverlay.classList.contains('hidden')) { - event.preventDefault(); - closePopup(); - } - }); - - function showPopup(type) { - const content = POPUP_CONTENT[type]; - if (!content || !popupOverlay || !popupBodyEl || !popupTitleEl) return; - - popupTitleEl.textContent = content.title; - popupBodyEl.innerHTML = content.sections - .map(section => { - const items = (section.items || []).map(item => `
  • ${item}
  • `).join(''); - return ` - - `; - }) - .join(''); - - popupOverlay.classList.remove('hidden'); - } - - function closePopup() { - if (!popupOverlay) return; - popupOverlay.classList.add('hidden'); - } }); diff --git a/static/style.css b/static/style.css index 0486df4..a04efc9 100644 --- a/static/style.css +++ b/static/style.css @@ -32,6 +32,17 @@ body { transition: background 0.6s ease; } +a { + color: var(--accent-color); + text-decoration: none; + transition: color 0.2s; +} + +a:hover { + color: var(--accent-hover); + text-decoration: underline; +} + .app-container { display: flex; height: 100vh; @@ -423,6 +434,13 @@ button#generate-btn:disabled { min-height: 0; } +.image-display-area.drag-over { + border-color: rgba(251, 191, 36, 0.8); + box-shadow: 0 30px 60px rgba(9, 5, 25, 0.7), inset 0 0 60px rgba(250, 204, 21, 0.25); + background: radial-gradient(circle at top, rgba(255, 255, 255, 0.08), rgba(6, 6, 18, 0.95)); + backdrop-filter: blur(8px); +} + .state-view { width: 100%; height: 100%; @@ -675,6 +693,35 @@ button#generate-btn:disabled { object-fit: cover; } +.gallery-item .delete-btn { + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + color: white; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s, background 0.2s; + font-size: 14px; + line-height: 1; + z-index: 10; +} + +.gallery-item:hover .delete-btn { + opacity: 1; +} + +.gallery-item .delete-btn:hover { + background: rgba(255, 68, 68, 0.9); +} + /* Spinner */ .spinner { width: 50px; diff --git a/templates/index.html b/templates/index.html index 6b20d7b..5dba4f5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -129,7 +129,7 @@ - +