beta
up up update beta Initial commit
This commit is contained in:
commit
f647892fa0
8 changed files with 1436 additions and 0 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/static/generated
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
108
app.py
Normal file
108
app.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import os
|
||||
import base64
|
||||
import uuid
|
||||
import glob
|
||||
from flask import Flask, render_template, request, jsonify, url_for
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
|
||||
|
||||
# Ensure generated directory exists inside Flask static folder
|
||||
GENERATED_DIR = os.path.join(app.static_folder, 'generated')
|
||||
os.makedirs(GENERATED_DIR, exist_ok=True)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/generate', methods=['POST'])
|
||||
def generate_image():
|
||||
multipart = request.content_type and 'multipart/form-data' in request.content_type
|
||||
|
||||
if multipart:
|
||||
form = request.form
|
||||
prompt = form.get('prompt')
|
||||
aspect_ratio = form.get('aspect_ratio')
|
||||
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')
|
||||
else:
|
||||
data = request.get_json() or {}
|
||||
prompt = data.get('prompt')
|
||||
aspect_ratio = data.get('aspect_ratio')
|
||||
resolution = data.get('resolution', '2K')
|
||||
api_key = data.get('api_key') or os.environ.get('GOOGLE_API_KEY')
|
||||
reference_files = []
|
||||
|
||||
if not prompt:
|
||||
return jsonify({'error': 'Prompt is required'}), 400
|
||||
|
||||
if not api_key:
|
||||
return jsonify({'error': 'API Key is required.'}), 401
|
||||
|
||||
try:
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
image_config_args = {
|
||||
"image_size": resolution
|
||||
}
|
||||
|
||||
if aspect_ratio and aspect_ratio != 'Auto':
|
||||
image_config_args["aspect_ratio"] = aspect_ratio
|
||||
|
||||
contents = [prompt]
|
||||
for reference in reference_files:
|
||||
try:
|
||||
reference.stream.seek(0)
|
||||
reference_image = Image.open(reference.stream)
|
||||
contents.append(reference_image)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-3-pro-image-preview",
|
||||
contents=contents,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=['IMAGE'],
|
||||
image_config=types.ImageConfig(**image_config_args),
|
||||
)
|
||||
)
|
||||
|
||||
for part in response.parts:
|
||||
if part.inline_data:
|
||||
image_bytes = part.inline_data.data
|
||||
|
||||
# Save image to file
|
||||
filename = f"{uuid.uuid4()}.png"
|
||||
filepath = os.path.join(GENERATED_DIR, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
image_url = url_for('static', filename=f'generated/{filename}')
|
||||
image_data = base64.b64encode(image_bytes).decode('utf-8')
|
||||
return jsonify({
|
||||
'image': image_url,
|
||||
'image_data': image_data,
|
||||
})
|
||||
|
||||
return jsonify({'error': 'No image generated'}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/gallery')
|
||||
def get_gallery():
|
||||
# List all png files in generated dir, sorted by modification time (newest first)
|
||||
files = glob.glob(os.path.join(GENERATED_DIR, '*.png'))
|
||||
files.sort(key=os.path.getmtime, reverse=True)
|
||||
|
||||
image_urls = [url_for('static', filename=f'generated/{os.path.basename(f)}') for f in files]
|
||||
response = jsonify({'images': image_urls})
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
return response
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, port=8888)
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
flask
|
||||
google-genai
|
||||
pillow
|
||||
583
static/script.js
Normal file
583
static/script.js
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const generateBtn = document.getElementById('generate-btn');
|
||||
const promptInput = document.getElementById('prompt');
|
||||
const aspectRatioInput = document.getElementById('aspect-ratio');
|
||||
const resolutionInput = document.getElementById('resolution');
|
||||
const apiKeyInput = document.getElementById('api-key');
|
||||
|
||||
// States
|
||||
const placeholderState = document.getElementById('placeholder-state');
|
||||
const loadingState = document.getElementById('loading-state');
|
||||
const errorState = document.getElementById('error-state');
|
||||
const resultState = document.getElementById('result-state');
|
||||
|
||||
const errorText = document.getElementById('error-text');
|
||||
const generatedImage = document.getElementById('generated-image');
|
||||
const downloadLink = document.getElementById('download-link');
|
||||
const galleryGrid = document.getElementById('gallery-grid');
|
||||
const imageInputGrid = document.getElementById('image-input-grid');
|
||||
const SETTINGS_STORAGE_KEY = 'gemini-image-app-settings';
|
||||
const MAX_IMAGE_SLOTS = 16;
|
||||
const INITIAL_IMAGE_SLOTS = 4;
|
||||
const imageSlotState = [];
|
||||
let cachedReferenceImages = [];
|
||||
const imageDisplayArea = document.querySelector('.image-display-area');
|
||||
const canvasToolbar = document.querySelector('.canvas-toolbar');
|
||||
const 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 };
|
||||
|
||||
// Load gallery on start
|
||||
loadSettings();
|
||||
initializeImageInputs();
|
||||
loadGallery();
|
||||
|
||||
apiKeyInput.addEventListener('input', persistSettings);
|
||||
promptInput.addEventListener('input', persistSettings);
|
||||
aspectRatioInput.addEventListener('change', persistSettings);
|
||||
resolutionInput.addEventListener('change', persistSettings);
|
||||
|
||||
generateBtn.addEventListener('click', async () => {
|
||||
const prompt = promptInput.value.trim();
|
||||
const aspectRatio = aspectRatioInput.value;
|
||||
const resolution = resolutionInput.value;
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
|
||||
if (!prompt) {
|
||||
showError('Please enter a prompt.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set UI to loading
|
||||
setViewState('loading');
|
||||
generateBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const formData = buildGenerateFormData({
|
||||
prompt,
|
||||
aspect_ratio: aspectRatio,
|
||||
resolution,
|
||||
api_key: apiKey,
|
||||
});
|
||||
|
||||
const response = await fetch('/generate', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to generate image');
|
||||
}
|
||||
|
||||
if (data.image) {
|
||||
displayImage(data.image, data.image_data);
|
||||
// Refresh gallery to show new image
|
||||
loadGallery();
|
||||
} else {
|
||||
throw new Error('No image data received');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
generateBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function setViewState(state) {
|
||||
placeholderState.classList.add('hidden');
|
||||
loadingState.classList.add('hidden');
|
||||
errorState.classList.add('hidden');
|
||||
resultState.classList.add('hidden');
|
||||
|
||||
switch (state) {
|
||||
case 'placeholder':
|
||||
placeholderState.classList.remove('hidden');
|
||||
break;
|
||||
case 'loading':
|
||||
loadingState.classList.remove('hidden');
|
||||
break;
|
||||
case 'error':
|
||||
errorState.classList.remove('hidden');
|
||||
break;
|
||||
case 'result':
|
||||
resultState.classList.remove('hidden');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (imageData) {
|
||||
generatedImage.src = `data:image/png;base64,${imageData}`;
|
||||
} else {
|
||||
generatedImage.src = cacheBustedUrl;
|
||||
}
|
||||
|
||||
downloadLink.href = imageData ? generatedImage.src : cacheBustedUrl;
|
||||
const filename = imageUrl.split('/').pop().split('?')[0];
|
||||
downloadLink.setAttribute('download', filename);
|
||||
|
||||
generatedImage.onload = () => {
|
||||
resetView();
|
||||
};
|
||||
|
||||
setViewState('result');
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
div.appendChild(img);
|
||||
galleryGrid.appendChild(div);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load gallery:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function withCacheBuster(url) {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}t=${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
function buildGenerateFormData(fields) {
|
||||
const formData = new FormData();
|
||||
|
||||
Object.entries(fields).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
imageSlotState.forEach(record => {
|
||||
const slotFile = getSlotFile(record);
|
||||
if (slotFile) {
|
||||
formData.append('reference_images', slotFile, slotFile.name);
|
||||
}
|
||||
});
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
const saved = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
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 : [];
|
||||
} catch (error) {
|
||||
console.warn('Unable to load cached settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
function persistSettings() {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
const settings = {
|
||||
apiKey: apiKeyInput.value.trim(),
|
||||
aspectRatio: aspectRatioInput.value,
|
||||
resolution: resolutionInput.value,
|
||||
prompt: promptInput.value.trim(),
|
||||
referenceImages: serializeReferenceImages(),
|
||||
};
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.warn('Unable to persist settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCanvasWheel(event) {
|
||||
if (resultState.classList.contains('hidden')) return;
|
||||
event.preventDefault();
|
||||
const delta = event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||
adjustZoom(delta);
|
||||
}
|
||||
|
||||
function handleCanvasPointerDown(event) {
|
||||
const result = document.getElementById('result-state');
|
||||
if (result.classList.contains('hidden')) return;
|
||||
isPanning = true;
|
||||
lastPointer = { x: event.clientX, y: event.clientY };
|
||||
imageDisplayArea.style.cursor = 'grabbing';
|
||||
}
|
||||
|
||||
function handleCanvasPointerMove(event) {
|
||||
if (!isPanning) return;
|
||||
const dx = event.clientX - lastPointer.x;
|
||||
const dy = event.clientY - lastPointer.y;
|
||||
panOffset.x += dx;
|
||||
panOffset.y += dy;
|
||||
lastPointer = { x: event.clientX, y: event.clientY };
|
||||
setImageTransform();
|
||||
}
|
||||
|
||||
function handleCanvasToolbarClick(event) {
|
||||
const action = event.target.closest('.canvas-btn')?.dataset.action;
|
||||
if (!action) return;
|
||||
switch (action) {
|
||||
case 'zoom-in':
|
||||
adjustZoom(ZOOM_STEP);
|
||||
break;
|
||||
case 'zoom-out':
|
||||
adjustZoom(-ZOOM_STEP);
|
||||
break;
|
||||
case 'zoom-fit':
|
||||
zoomLevel = getFitZoom();
|
||||
panOffset = { x: 0, y: 0 };
|
||||
setImageTransform();
|
||||
break;
|
||||
case 'zoom-reset':
|
||||
resetView();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function adjustZoom(delta) {
|
||||
const prevZoom = zoomLevel;
|
||||
zoomLevel = clamp(zoomLevel + delta, MIN_ZOOM, MAX_ZOOM);
|
||||
const scale = zoomLevel / prevZoom;
|
||||
panOffset.x *= scale;
|
||||
panOffset.y *= scale;
|
||||
setImageTransform();
|
||||
}
|
||||
|
||||
function setImageTransform() {
|
||||
generatedImage.style.transform = `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoomLevel})`;
|
||||
}
|
||||
|
||||
function getFitZoom() {
|
||||
if (!generatedImage.naturalWidth || !generatedImage.naturalHeight || !imageDisplayArea) {
|
||||
return 1;
|
||||
}
|
||||
const rect = imageDisplayArea.getBoundingClientRect();
|
||||
const scaleX = rect.width / generatedImage.naturalWidth;
|
||||
const scaleY = rect.height / generatedImage.naturalHeight;
|
||||
const fitZoom = Math.max(scaleX, scaleY);
|
||||
return Math.max(fitZoom, MIN_ZOOM);
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
zoomLevel = 1;
|
||||
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', event => {
|
||||
event.preventDefault();
|
||||
slot.classList.remove('drag-over');
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) {
|
||||
handleSlotFile(index, file);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
610
static/style.css
Normal file
610
static/style.css
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
:root {
|
||||
--bd-bg: radial-gradient(circle at 15% 20%, #2e2ce0 0%, #0b0b1b 35%, #03030b 100%);
|
||||
--panel-bg: rgba(10, 11, 22, 0.95);
|
||||
--panel-backdrop: rgba(6, 7, 20, 0.7);
|
||||
--border-color: rgba(255, 255, 255, 0.12);
|
||||
--text-primary: #f5f5f5;
|
||||
--text-secondary: #cbd5f5;
|
||||
--accent-color: #fbbf24;
|
||||
--accent-hover: #fcd34d;
|
||||
--danger-color: #ff4444;
|
||||
--input-bg: rgba(255, 255, 255, 0.06);
|
||||
--card-bg: rgba(6, 7, 23, 0.8);
|
||||
--shadow-glow: 0 20px 45px rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Be Vietnam Pro', sans-serif;
|
||||
background: var(--bd-bg);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
background-attachment: fixed;
|
||||
transition: background 0.6s ease;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
background: var(--panel-backdrop);
|
||||
background-image: radial-gradient(circle at 20% -20%, rgba(251, 191, 36, 0.15), transparent 45%);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset -1px 0 0 var(--border-color);
|
||||
backdrop-filter: blur(24px);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.top-toolbar {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.toolbar-info {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbar-info-btn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s, background 0.2s, box-shadow 0.2s;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.toolbar-info-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent-color);
|
||||
color: #111;
|
||||
background: rgba(251, 191, 36, 0.18);
|
||||
box-shadow: 0 15px 35px rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
.toolbar-info-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar-generate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.toolbar-generate #generate-btn {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.45rem;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.toolbar-generate #generate-btn {
|
||||
padding: 0.35rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
font-family: 'Playwrite AU SA', cursive;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
background-color: rgba(245, 197, 24, 0.15);
|
||||
color: var(--accent-color);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.controls-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
textarea,
|
||||
select {
|
||||
padding: 0.75rem;
|
||||
background-color: var(--input-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(6px);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
select {
|
||||
background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(15, 15, 25, 0.3)),
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 8'%3E%3Cpath fill='rgba(249, 203, 42, 0.85)' d='M7 8L0.928 0.5h12.144L7 8z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat, no-repeat;
|
||||
background-size: 100% 100%, 0.75rem 0.4rem;
|
||||
background-position: center, right 0.75rem center;
|
||||
padding-right: 2.5rem;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background-color: rgba(6, 7, 20, 0.95);
|
||||
box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.3), 0 10px 25px rgba(0, 0, 0, 0.25);
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
select:focus,
|
||||
select:hover {
|
||||
border-color: var(--accent-color);
|
||||
background-color: rgba(6, 7, 20, 0.9);
|
||||
box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.5), 0 15px 35px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
select option {
|
||||
background-color: rgba(3, 3, 10, 0.95);
|
||||
color: var(--text-primary);
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
select option:checked,
|
||||
select option:hover,
|
||||
select option:focus {
|
||||
background-color: rgba(251, 191, 36, 0.25);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
/* Reference image input grid */
|
||||
.image-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.image-input-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.image-input-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.image-slot {
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background-color: rgba(15, 23, 42, 0.8);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
min-height: 90px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-slot:hover {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.image-slot.drag-over {
|
||||
border-color: var(--accent-hover);
|
||||
background-color: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.image-slot.empty .slot-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.image-slot .slot-icon {
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.slot-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 0.5rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-slot.filled {
|
||||
border-style: solid;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.image-slot .slot-placeholder {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slot-preview.hidden,
|
||||
.slot-remove.hidden,
|
||||
.slot-placeholder.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slot-remove {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
color: var(--text-primary);
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.slot-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button#generate-btn {
|
||||
position: relative;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
|
||||
color: #111;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 15px rgba(245, 197, 24, 0.25);
|
||||
transition: transform 0.1s, filter 0.2s;
|
||||
}
|
||||
|
||||
button#generate-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
button#generate-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: var(--text-primary);
|
||||
border-radius: 1.25rem;
|
||||
padding: 0.75rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.image-display-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: radial-gradient(circle at top, rgba(27, 38, 102, 0.4), rgba(6, 6, 18, 0.95));
|
||||
border-radius: 1.5rem;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 30px 60px rgba(9, 5, 25, 0.65), inset 0 0 40px rgba(251, 191, 36, 0.1);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.state-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.icon-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px dashed rgba(251, 191, 36, 0.6);
|
||||
background: radial-gradient(circle at center, rgba(251, 191, 36, 0.15), transparent 60%);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#generated-image {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: none;
|
||||
transition: transform 0.2s;
|
||||
pointer-events: none;
|
||||
transform-origin: center center;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-block;
|
||||
background-color: rgba(245, 197, 24, 0.1);
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: rgba(245, 197, 24, 0.25);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.canvas-toolbar {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
background: rgba(15, 15, 15, 0.9);
|
||||
padding: 0.35rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 10;
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.canvas-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: none;
|
||||
color: var(--accent-color);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.canvas-btn:hover {
|
||||
background: rgba(245, 197, 24, 0.35);
|
||||
}
|
||||
|
||||
.canvas-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.canvas-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Gallery Section */
|
||||
.history-section {
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 180px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.history-section h3 {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.gallery-item:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.gallery-item.active {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 25px rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.08);
|
||||
border-left-color: var(--accent-hover);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
126
templates/index.html
Normal file
126
templates/index.html
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>aPix Image Workspace</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700&family=Playwrite+AU+SA&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<h1>aPix</h1>
|
||||
<span class="badge">Creative Studio</span>
|
||||
</div>
|
||||
|
||||
<div class="controls-section">
|
||||
<div class="input-group">
|
||||
<label for="api-key">API Key</label>
|
||||
<input type="password" id="api-key" placeholder="Google Cloud API Key">
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="prompt">Prompt</label>
|
||||
<textarea id="prompt" placeholder="Describe your imagination..." rows="6"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="input-group image-inputs">
|
||||
<div class="image-input-header">
|
||||
<label>Reference Images (optional)</label>
|
||||
<span>Drag & drop up to 16 files</span>
|
||||
</div>
|
||||
<div id="image-input-grid" class="image-input-grid" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="aspect-ratio">Aspect Ratio</label>
|
||||
<select id="aspect-ratio">
|
||||
<option value="Auto">Auto (Default)</option>
|
||||
<option value="1:1">1:1 (Square)</option>
|
||||
<option value="16:9">16:9 (Widescreen)</option>
|
||||
<option value="4:3">4:3 (Standard)</option>
|
||||
<option value="3:4">3:4 (Portrait)</option>
|
||||
<option value="9:16">9:16 (Mobile)</option>
|
||||
<option value="2:3">2:3</option>
|
||||
<option value="3:2">3:2</option>
|
||||
<option value="4:5">4:5</option>
|
||||
<option value="5:4">5:4</option>
|
||||
<option value="21:9">21:9 (Cinema)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="resolution">Resolution</label>
|
||||
<select id="resolution">
|
||||
<option value="2K" selected>2K</option>
|
||||
<option value="1K">1K</option>
|
||||
<option value="4K">4K</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
<div class="content-area">
|
||||
<div class="top-toolbar">
|
||||
<div class="toolbar-info">
|
||||
<button type="button" class="toolbar-info-btn">Info</button>
|
||||
<button type="button" class="toolbar-info-btn">Docs</button>
|
||||
<button type="button" class="toolbar-info-btn" disabled>Preview</button>
|
||||
</div>
|
||||
<div class="toolbar-generate">
|
||||
<button id="generate-btn">
|
||||
<span>Generate</span>
|
||||
<div class="btn-shine"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<main class="main-content">
|
||||
<div class="image-display-area">
|
||||
<div id="placeholder-state" class="state-view">
|
||||
<div class="icon-placeholder"></div>
|
||||
</div>
|
||||
|
||||
<div id="loading-state" class="state-view hidden">
|
||||
<div class="spinner"></div>
|
||||
<p>Creating masterpiece...</p>
|
||||
</div>
|
||||
|
||||
<div id="error-state" class="state-view hidden">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<p id="error-text"></p>
|
||||
</div>
|
||||
|
||||
<div id="result-state" class="state-view hidden">
|
||||
<img id="generated-image" src="" alt="Generated Image">
|
||||
<div class="canvas-toolbar" role="toolbar">
|
||||
<button type="button" class="canvas-btn" data-action="zoom-out">−</button>
|
||||
<button type="button" class="canvas-btn" data-action="zoom-in">+</button>
|
||||
<button type="button" class="canvas-btn icon-btn" data-action="zoom-reset" aria-label="Reset view">↺</button>
|
||||
<a id="download-link" href="#" download="gemini_image.png" class="canvas-btn icon-btn" aria-label="Download image">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5535 16.5061C12.4114 16.6615 12.2106 16.75 12 16.75C11.7894 16.75 11.5886 16.6615 11.4465 16.5061L7.44648 12.1311C7.16698 11.8254 7.18822 11.351 7.49392 11.0715C7.79963 10.792 8.27402 10.8132 8.55352 11.1189L11.25 14.0682V3C11.25 2.58579 11.5858 2.25 12 2.25C12.4142 2.25 12.75 2.58579 12.75 3V14.0682L15.4465 11.1189C15.726 10.8132 16.2004 10.792 16.5061 11.0715C16.8118 11.351 16.833 11.8254 16.5535 12.1311L12.5535 16.5061Z" fill="currentColor"/>
|
||||
<path d="M3.75 15C3.75 14.5858 3.41422 14.25 3 14.25C2.58579 14.25 2.25 14.5858 2.25 15V15.0549C2.24998 16.4225 2.24996 17.5248 2.36652 18.3918C2.48754 19.2919 2.74643 20.0497 3.34835 20.6516C3.95027 21.2536 4.70814 21.5125 5.60825 21.6335C6.47522 21.75 7.57754 21.75 8.94513 21.75H15.0549C16.4225 21.75 17.5248 21.75 18.3918 21.6335C19.2919 21.5125 20.0497 21.2536 20.6517 20.6516C21.2536 20.0497 21.5125 19.2919 21.6335 18.3918C21.75 17.5248 21.75 16.4225 21.75 15.0549V15C21.75 14.5858 21.4142 14.25 21 14.25C20.5858 14.25 20.25 14.5858 20.25 15C20.25 16.4354 20.2484 17.4365 20.1469 18.1919C20.0482 18.9257 19.8678 19.3142 19.591 19.591C19.3142 19.8678 18.9257 20.0482 18.1919 20.1469C17.4365 20.2484 16.4354 20.25 15 20.25H9C7.56459 20.25 6.56347 20.2484 5.80812 20.1469C5.07435 20.0482 4.68577 19.8678 4.40901 19.591C4.13225 19.3142 3.9518 18.9257 3.85315 18.1919C3.75159 17.4365 3.75 16.4354 3.75 15Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<section class="history-section">
|
||||
<h3>History</h3>
|
||||
<div id="gallery-grid" class="gallery-grid">
|
||||
<!-- Gallery items will be injected here -->
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Loading…
Reference in a new issue