update ver

This commit is contained in:
phamhungd 2025-11-22 00:23:18 +07:00
parent c456468b31
commit 0b24c74ef3
10 changed files with 949 additions and 484 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@
.DS_Store
.DS_Store
/.venv
/static/uploads

145
app.py
View file

@ -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,13 +61,88 @@ 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:
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(
@ -75,17 +158,36 @@ def generate_image():
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)
rel_path = os.path.join('generated', filename)
image_url = url_for('static', filename=rel_path)
image_url = url_for('static', filename=f'generated/{filename}')
image_data = base64.b64encode(image_bytes).decode('utf-8')
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)

94
static/modules/gallery.js Normal file
View file

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

View file

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

57
static/modules/popup.js Normal file
View file

@ -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 => `<li>${item}</li>`).join('');
return `
<section class="popup-section">
<h3>${section.heading}</h3>
<ul>${items}</ul>
</section>
`;
})
.join('');
overlay.classList.remove('hidden');
}
function closePopup() {
overlay.classList.add('hidden');
}
}

View file

@ -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 = '<span class="slot-icon">+</span>';
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,
};
}

26
static/modules/utils.js Normal file
View file

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

View file

@ -1,42 +1,15 @@
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');
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';
// 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 SETTINGS_STORAGE_KEY = 'gemini-image-app-settings';
const ZOOM_STEP = 0.1;
const MIN_ZOOM = 0.4;
const MAX_ZOOM = 4;
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 = {
const infoContent = {
title: 'Thông tin',
sections: [
{
@ -48,9 +21,9 @@ document.addEventListener('DOMContentLoaded', () => {
],
},
],
};
};
const docsContent = {
const docsContent = {
title: 'Phím tắt và mẹo',
sections: [
{
@ -58,7 +31,8 @@ document.addEventListener('DOMContentLoaded', () => {
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',
'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',
],
},
@ -71,17 +45,62 @@ document.addEventListener('DOMContentLoaded', () => {
],
},
],
};
};
const POPUP_CONTENT = {
const POPUP_CONTENT = {
info: infoContent,
docs: docsContent,
};
};
// Load gallery on start
loadSettings();
initializeImageInputs();
loadGallery();
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');
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 imageDisplayArea = document.querySelector('.image-display-area');
const canvasToolbar = document.querySelector('.canvas-toolbar');
let zoomLevel = 1;
let panOffset = { x: 0, y: 0 };
let isPanning = false;
let lastPointer = { x: 0, y: 0 };
const slotManager = createReferenceSlotManager(imageInputGrid, {
onChange: persistSettings,
});
const gallery = createGallery({
galleryGrid,
onSelect: async ({ imageUrl, metadata }) => {
displayImage(imageUrl);
if (metadata) {
applyMetadata(metadata);
}
},
});
setupHelpPopups({
buttonsSelector: '[data-popup-target]',
overlayId: 'popup-overlay',
titleId: 'popup-title',
bodyId: 'popup-body',
closeBtnId: 'popup-close',
content: POPUP_CONTENT,
});
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,49 +275,42 @@ document.addEventListener('DOMContentLoaded', () => {
setViewState('result');
}
async function handleCanvasDropUrl(imageUrl) {
const cleanedUrl = imageUrl;
displayImage(cleanedUrl);
try {
const response = await fetch(withCacheBuster(cleanedUrl));
if (!response.ok) return;
const metadata = await extractMetadataFromBlob(await response.blob());
if (metadata) {
applyMetadata(metadata);
}
} catch (error) {
console.warn('Unable to read metadata from dropped image', error);
}
}
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 {
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);
});
await gallery.load();
} catch (error) {
console.error('Failed to load gallery:', error);
console.error('Unable to populate gallery', error);
}
}
function withCacheBuster(url) {
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}t=${new Date().getTime()}`;
}
function buildGenerateFormData(fields) {
const formData = new FormData();
@ -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 = '<span class="slot-icon">+</span>';
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 => `<li>${item}</li>`).join('');
return `
<section class="popup-section">
<h3>${section.heading}</h3>
<ul>${items}</ul>
</section>
`;
})
.join('');
popupOverlay.classList.remove('hidden');
}
function closePopup() {
if (!popupOverlay) return;
popupOverlay.classList.add('hidden');
}
});

View file

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

View file

@ -129,7 +129,7 @@
<div id="popup-body" class="popup-body"></div>
</div>
</div>
<script src="{{ url_for('static', filename='script.js') }}"></script>
<script type="module" src="{{ url_for('static', filename='script.js') }}"></script>
</body>
</html>