update ver
This commit is contained in:
parent
c456468b31
commit
0b24c74ef3
10 changed files with 949 additions and 484 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/.venv
|
/.venv
|
||||||
|
/static/uploads
|
||||||
|
|
|
||||||
145
app.py
145
app.py
|
|
@ -2,10 +2,12 @@ import os
|
||||||
import base64
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
import glob
|
import glob
|
||||||
|
import json
|
||||||
|
from io import BytesIO
|
||||||
from flask import Flask, render_template, request, jsonify, url_for
|
from flask import Flask, render_template, request, jsonify, url_for
|
||||||
from google import genai
|
from google import genai
|
||||||
from google.genai import types
|
from google.genai import types
|
||||||
from PIL import Image
|
from PIL import Image, PngImagePlugin
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
|
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')
|
GENERATED_DIR = os.path.join(app.static_folder, 'generated')
|
||||||
os.makedirs(GENERATED_DIR, exist_ok=True)
|
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('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return render_template('index.html')
|
return render_template('index.html')
|
||||||
|
|
@ -29,6 +35,7 @@ def generate_image():
|
||||||
resolution = form.get('resolution', '2K')
|
resolution = form.get('resolution', '2K')
|
||||||
api_key = form.get('api_key') or os.environ.get('GOOGLE_API_KEY')
|
api_key = form.get('api_key') or os.environ.get('GOOGLE_API_KEY')
|
||||||
reference_files = request.files.getlist('reference_images')
|
reference_files = request.files.getlist('reference_images')
|
||||||
|
reference_paths_json = form.get('reference_image_paths')
|
||||||
else:
|
else:
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
prompt = data.get('prompt')
|
prompt = data.get('prompt')
|
||||||
|
|
@ -36,6 +43,7 @@ def generate_image():
|
||||||
resolution = data.get('resolution', '2K')
|
resolution = data.get('resolution', '2K')
|
||||||
api_key = data.get('api_key') or os.environ.get('GOOGLE_API_KEY')
|
api_key = data.get('api_key') or os.environ.get('GOOGLE_API_KEY')
|
||||||
reference_files = []
|
reference_files = []
|
||||||
|
reference_paths_json = data.get('reference_image_paths')
|
||||||
|
|
||||||
if not prompt:
|
if not prompt:
|
||||||
return jsonify({'error': 'Prompt is required'}), 400
|
return jsonify({'error': 'Prompt is required'}), 400
|
||||||
|
|
@ -53,13 +61,88 @@ def generate_image():
|
||||||
if aspect_ratio and aspect_ratio != 'Auto':
|
if aspect_ratio and aspect_ratio != 'Auto':
|
||||||
image_config_args["aspect_ratio"] = aspect_ratio
|
image_config_args["aspect_ratio"] = aspect_ratio
|
||||||
|
|
||||||
|
# Process reference paths and files
|
||||||
|
final_reference_paths = []
|
||||||
contents = [prompt]
|
contents = [prompt]
|
||||||
for reference in reference_files:
|
|
||||||
|
# Parse reference paths from frontend
|
||||||
|
frontend_paths = []
|
||||||
|
if reference_paths_json:
|
||||||
try:
|
try:
|
||||||
reference.stream.seek(0)
|
frontend_paths = json.loads(reference_paths_json)
|
||||||
reference_image = Image.open(reference.stream)
|
except json.JSONDecodeError:
|
||||||
contents.append(reference_image)
|
pass
|
||||||
except Exception:
|
|
||||||
|
# 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
|
continue
|
||||||
|
|
||||||
response = client.models.generate_content(
|
response = client.models.generate_content(
|
||||||
|
|
@ -75,17 +158,36 @@ def generate_image():
|
||||||
if part.inline_data:
|
if part.inline_data:
|
||||||
image_bytes = part.inline_data.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"
|
filename = f"{uuid.uuid4()}.png"
|
||||||
filepath = os.path.join(GENERATED_DIR, filename)
|
filepath = os.path.join(GENERATED_DIR, filename)
|
||||||
with open(filepath, "wb") as f:
|
rel_path = os.path.join('generated', filename)
|
||||||
f.write(image_bytes)
|
image_url = url_for('static', filename=rel_path)
|
||||||
|
|
||||||
image_url = url_for('static', filename=f'generated/{filename}')
|
metadata = {
|
||||||
image_data = base64.b64encode(image_bytes).decode('utf-8')
|
'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({
|
return jsonify({
|
||||||
'image': image_url,
|
'image': image_url,
|
||||||
'image_data': image_data,
|
'image_data': image_data,
|
||||||
|
'metadata': metadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({'error': 'No image generated'}), 500
|
return jsonify({'error': 'No image generated'}), 500
|
||||||
|
|
@ -93,6 +195,27 @@ def generate_image():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
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')
|
@app.route('/gallery')
|
||||||
def get_gallery():
|
def get_gallery():
|
||||||
# List all png files in generated dir, sorted by modification time (newest first)
|
# List all png files in generated dir, sorted by modification time (newest first)
|
||||||
|
|
|
||||||
94
static/modules/gallery.js
Normal file
94
static/modules/gallery.js
Normal 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 };
|
||||||
|
}
|
||||||
46
static/modules/metadata.js
Normal file
46
static/modules/metadata.js
Normal 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
57
static/modules/popup.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
313
static/modules/referenceSlots.js
Normal file
313
static/modules/referenceSlots.js
Normal 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
26
static/modules/utils.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
674
static/script.js
674
static/script.js
|
|
@ -1,42 +1,15 @@
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
import { withCacheBuster, clamp } from './modules/utils.js';
|
||||||
const generateBtn = document.getElementById('generate-btn');
|
import { createGallery } from './modules/gallery.js';
|
||||||
const promptInput = document.getElementById('prompt');
|
import { createReferenceSlotManager } from './modules/referenceSlots.js';
|
||||||
const aspectRatioInput = document.getElementById('aspect-ratio');
|
import { setupHelpPopups } from './modules/popup.js';
|
||||||
const resolutionInput = document.getElementById('resolution');
|
import { extractMetadataFromBlob } from './modules/metadata.js';
|
||||||
const apiKeyInput = document.getElementById('api-key');
|
|
||||||
|
|
||||||
// States
|
const SETTINGS_STORAGE_KEY = 'gemini-image-app-settings';
|
||||||
const placeholderState = document.getElementById('placeholder-state');
|
const ZOOM_STEP = 0.1;
|
||||||
const loadingState = document.getElementById('loading-state');
|
const MIN_ZOOM = 0.4;
|
||||||
const errorState = document.getElementById('error-state');
|
const MAX_ZOOM = 4;
|
||||||
const resultState = document.getElementById('result-state');
|
|
||||||
|
|
||||||
const errorText = document.getElementById('error-text');
|
const infoContent = {
|
||||||
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',
|
title: 'Thông tin',
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
|
|
@ -48,9 +21,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const docsContent = {
|
const docsContent = {
|
||||||
title: 'Phím tắt và mẹo',
|
title: 'Phím tắt và mẹo',
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
|
|
@ -58,7 +31,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
items: [
|
items: [
|
||||||
'Ctrl/Cmd + Enter → tạo ảnh mới',
|
'Ctrl/Cmd + Enter → tạo ảnh mới',
|
||||||
'D → tải ảnh hiện tạ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',
|
'Esc → đóng popup thông tin/docs',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -71,17 +45,62 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const POPUP_CONTENT = {
|
const POPUP_CONTENT = {
|
||||||
info: infoContent,
|
info: infoContent,
|
||||||
docs: docsContent,
|
docs: docsContent,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load gallery on start
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadSettings();
|
const generateBtn = document.getElementById('generate-btn');
|
||||||
initializeImageInputs();
|
const promptInput = document.getElementById('prompt');
|
||||||
loadGallery();
|
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);
|
apiKeyInput.addEventListener('input', persistSettings);
|
||||||
promptInput.addEventListener('input', persistSettings);
|
promptInput.addEventListener('input', persistSettings);
|
||||||
|
|
@ -99,7 +118,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set UI to loading
|
|
||||||
setViewState('loading');
|
setViewState('loading');
|
||||||
generateBtn.disabled = true;
|
generateBtn.disabled = true;
|
||||||
|
|
||||||
|
|
@ -124,12 +142,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
if (data.image) {
|
if (data.image) {
|
||||||
displayImage(data.image, data.image_data);
|
displayImage(data.image, data.image_data);
|
||||||
// Refresh gallery to show new image
|
gallery.load();
|
||||||
loadGallery();
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No image data received');
|
throw new Error('No image data received');
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error.message);
|
showError(error.message);
|
||||||
} finally {
|
} 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) {
|
function setViewState(state) {
|
||||||
placeholderState.classList.add('hidden');
|
placeholderState.classList.add('hidden');
|
||||||
loadingState.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) {
|
function showError(message) {
|
||||||
errorText.textContent = message;
|
errorText.textContent = message;
|
||||||
setViewState('error');
|
setViewState('error');
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayImage(imageUrl, imageData) {
|
function displayImage(imageUrl, imageData) {
|
||||||
const cacheBustedUrl = withCacheBuster(imageUrl);
|
let cacheBustedUrl = imageUrl;
|
||||||
|
if (!imageUrl.startsWith('blob:') && !imageUrl.startsWith('data:')) {
|
||||||
|
cacheBustedUrl = withCacheBuster(imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
if (imageData) {
|
if (imageData) {
|
||||||
generatedImage.src = `data:image/png;base64,${imageData}`;
|
generatedImage.src = `data:image/png;base64,${imageData}`;
|
||||||
|
|
@ -220,49 +275,42 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
setViewState('result');
|
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() {
|
async function loadGallery() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/gallery?t=${new Date().getTime()}`);
|
await gallery.load();
|
||||||
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);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} 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) {
|
function buildGenerateFormData(fields) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
|
|
@ -272,30 +320,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
imageSlotState.forEach(record => {
|
slotManager.getReferenceFiles().forEach(file => {
|
||||||
const slotFile = getSlotFile(record);
|
formData.append('reference_images', file, file.name);
|
||||||
if (slotFile) {
|
|
||||||
formData.append('reference_images', slotFile, slotFile.name);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const referencePaths = slotManager.getReferencePaths();
|
||||||
|
if (referencePaths && referencePaths.length > 0) {
|
||||||
|
formData.append('reference_image_paths', JSON.stringify(referencePaths));
|
||||||
|
}
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSettings() {
|
function loadSettings() {
|
||||||
if (typeof localStorage === 'undefined') return;
|
if (typeof localStorage === 'undefined') return {};
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
const saved = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||||
if (!saved) return;
|
if (!saved) return {};
|
||||||
|
|
||||||
const { apiKey, aspectRatio, resolution, prompt, referenceImages } = JSON.parse(saved);
|
const { apiKey, aspectRatio, resolution, prompt, referenceImages } = JSON.parse(saved);
|
||||||
if (apiKey) apiKeyInput.value = apiKey;
|
if (apiKey) apiKeyInput.value = apiKey;
|
||||||
if (aspectRatio) aspectRatioInput.value = aspectRatio;
|
if (aspectRatio) aspectRatioInput.value = aspectRatio;
|
||||||
if (resolution) resolutionInput.value = resolution;
|
if (resolution) resolutionInput.value = resolution;
|
||||||
if (prompt) promptInput.value = prompt;
|
if (prompt) promptInput.value = prompt;
|
||||||
cachedReferenceImages = Array.isArray(referenceImages) ? referenceImages : [];
|
return { apiKey, aspectRatio, resolution, prompt, referenceImages };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Unable to load cached settings', error);
|
console.warn('Unable to load cached settings', error);
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,7 +358,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
aspectRatio: aspectRatioInput.value,
|
aspectRatio: aspectRatioInput.value,
|
||||||
resolution: resolutionInput.value,
|
resolution: resolutionInput.value,
|
||||||
prompt: promptInput.value.trim(),
|
prompt: promptInput.value.trim(),
|
||||||
referenceImages: serializeReferenceImages(),
|
referenceImages: slotManager.serializeReferenceImages(),
|
||||||
};
|
};
|
||||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||||
} catch (error) {
|
} 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) {
|
function handleCanvasWheel(event) {
|
||||||
if (resultState.classList.contains('hidden')) return;
|
if (resultState.classList.contains('hidden')) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -390,328 +472,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
panOffset = { x: 0, y: 0 };
|
panOffset = { x: 0, y: 0 };
|
||||||
setImageTransform();
|
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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,17 @@ body {
|
||||||
transition: background 0.6s ease;
|
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 {
|
.app-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
@ -423,6 +434,13 @@ button#generate-btn:disabled {
|
||||||
min-height: 0;
|
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 {
|
.state-view {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -675,6 +693,35 @@ button#generate-btn:disabled {
|
||||||
object-fit: cover;
|
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 */
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@
|
||||||
<div id="popup-body" class="popup-body"></div>
|
<div id="popup-body" class="popup-body"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
<script type="module" src="{{ url_for('static', filename='script.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue