From 63ad7cc21feeec4a375039f0a8d32cd266adc3a4 Mon Sep 17 00:00:00 2001 From: "Khoa.vo" Date: Tue, 30 Dec 2025 19:09:21 +0700 Subject: [PATCH] feat: Add Whisk integration and Docker support --- .DS_Store | Bin 10244 -> 10244 bytes Dockerfile | 21 ++ __pycache__/app.cpython-314.pyc | Bin 0 -> 62999 bytes __pycache__/whisk_client.cpython-314.pyc | Bin 0 -> 10934 bytes app.py | 137 +++++++++++- docker-compose.yml | 17 ++ static/script.js | 159 ++++++++------ templates/index.html | 44 ++-- whisk_client.py | 254 +++++++++++++++++++++++ 9 files changed, 543 insertions(+), 89 deletions(-) create mode 100644 Dockerfile create mode 100644 __pycache__/app.cpython-314.pyc create mode 100644 __pycache__/whisk_client.cpython-314.pyc create mode 100644 docker-compose.yml create mode 100644 whisk_client.py diff --git a/.DS_Store b/.DS_Store index b0f911e4e5c3b95eed951d046d6044a85048397f..24d7a5275228ab953c645f5b95f40bb25c13cc8b 100644 GIT binary patch delta 149 zcmZn(XbG4g&d4*dP;8=}(X1#TeW3N(oaXw-M+$^0swll^%(Cda5OoSeXV1gck<2>`*sB3A$a delta 748 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 DOtbu{ literal 0 HcmV?d00001 diff --git a/__pycache__/whisk_client.cpython-314.pyc b/__pycache__/whisk_client.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff802374bddb88df91b3dac18c4d7f9d87c7ade2 GIT binary patch literal 10934 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 literal 0 HcmV?d00001 diff --git a/app.py b/app.py index 18e0904..cfff405 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,7 @@ from google import genai from google.genai import types from PIL import Image, PngImagePlugin import threading, time, subprocess, re +import whisk_client import logging @@ -393,12 +394,15 @@ def generate_image(): if not prompt: return jsonify({'error': 'Prompt is required'}), 400 - if not api_key: - return jsonify({'error': 'API Key is required.'}), 401 + # Determine if this is a Whisk request + is_whisk = 'whisk' in model.lower() or 'imagefx' in model.lower() + + if not is_whisk and not api_key: + return jsonify({'error': 'API Key is required for Gemini models.'}), 401 try: print("Đang gửi lệnh...", flush=True) - client = genai.Client(api_key=api_key) + # client initialization moved to Gemini block image_config_args = {} @@ -514,6 +518,133 @@ def generate_image(): continue model_name = model + + # ================================================================================== + # WHISK (IMAGEFX) HANDLING + # ================================================================================== + if is_whisk: + print(f"Detected Whisk/ImageFX model request: {model_name}", flush=True) + + # Extract cookies from request headers or form data + # Priority: Form Data 'cookies' > Request Header 'x-whisk-cookies' > Environment Variable + cookie_str = request.form.get('cookies') or request.headers.get('x-whisk-cookies') or os.environ.get('WHISK_COOKIES') + + if not cookie_str: + return jsonify({'error': 'Whisk cookies are required. Please provide them in the "cookies" form field or configuration.'}), 400 + + print("Sending request to Whisk...", flush=True) + try: + # Check for reference images + reference_image_path = None + + # final_reference_paths (populated above) contains URLs/paths to reference images. + # Can be new uploads or history items. + if final_reference_paths: + # Use the first one + ref_url = final_reference_paths[0] + + # Convert URL/Path to absolute local path + # ref_url might be "http://.../static/..." or "/static/..." + if '/static/' in ref_url: + rel_path = ref_url.split('/static/')[1] + possible_path = os.path.join(app.static_folder, rel_path) + if os.path.exists(possible_path): + reference_image_path = possible_path + print(f"Whisk: Using reference image at {reference_image_path}", flush=True) + elif os.path.exists(ref_url): + # It's already a path? + reference_image_path = ref_url + + # Call the client + try: + whisk_result = whisk_client.generate_image_whisk( + prompt=api_prompt, + cookie_str=cookie_str, + aspect_ratio=aspect_ratio, + resolution=resolution, + reference_image_path=reference_image_path + ) + except Exception as e: + # Re-raise to be caught by the outer block + raise e + + # Process result - whisk_client returns raw bytes + image_bytes = None + if isinstance(whisk_result, bytes): + image_bytes = whisk_result + elif isinstance(whisk_result, dict): + # Fallback if I ever change the client to return dict + if 'image_data' in whisk_result: + image_bytes = whisk_result['image_data'] + elif 'image_url' in whisk_result: + import requests + img_resp = requests.get(whisk_result['image_url']) + image_bytes = img_resp.content + + if not image_bytes: + raise ValueError("No image data returned from Whisk.") + + # Save and process image (Reuse existing logic) + image = Image.open(BytesIO(image_bytes)) + png_info = PngImagePlugin.PngInfo() + + date_str = datetime.now().strftime("%Y%m%d") + search_pattern = os.path.join(GENERATED_DIR, f"whisk_{date_str}_*.png") + existing_files = glob.glob(search_pattern) + max_id = 0 + for f in existing_files: + try: + basename = os.path.basename(f) + name_without_ext = os.path.splitext(basename)[0] + id_part = name_without_ext.split('_')[-1] + id_num = int(id_part) + if id_num > max_id: + max_id = id_num + except ValueError: + continue + + next_id = max_id + 1 + filename = f"whisk_{date_str}_{next_id}.png" + filepath = os.path.join(GENERATED_DIR, filename) + rel_path = os.path.join('generated', filename) + image_url = url_for('static', filename=rel_path) + + metadata = { + 'prompt': prompt, + 'note': note, + 'processed_prompt': api_prompt, + 'aspect_ratio': aspect_ratio or 'Auto', + 'resolution': resolution, + 'reference_images': final_reference_paths, + 'model': 'whisk' + } + png_info.add_text('sdvn_meta', json.dumps(metadata)) + + buffer = BytesIO() + image.save(buffer, format='PNG', pnginfo=png_info) + final_bytes = buffer.getvalue() + + with open(filepath, 'wb') as f: + f.write(final_bytes) + + image_data = base64.b64encode(final_bytes).decode('utf-8') + return jsonify({ + 'image': image_url, + 'image_data': image_data, + 'metadata': metadata, + }) + + except Exception as e: + print(f"Whisk error: {e}") + return jsonify({'error': f"Whisk Generation Error: {str(e)}"}), 500 + + # ================================================================================== + # STANDARD GEMINI HANDLING + # ================================================================================== + + # Initialize Client here, since API Key is required + client = genai.Client(api_key=api_key) + print(f"Đang tạo với model {model_name}...", flush=True) response = client.models.generate_content( model=model_name, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ef166c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + app: + build: . + platform: linux/amd64 + ports: + - "8558:8888" + volumes: + - ./static:/app/static + - ./prompts.json:/app/prompts.json + - ./user_prompts.json:/app/user_prompts.json + - ./gallery_favorites.json:/app/gallery_favorites.json + environment: + - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} # Optional for Whisk + - WHISK_COOKIES=${WHISK_COOKIES:-} + restart: unless-stopped diff --git a/static/script.js b/static/script.js index 6cc8494..a8598cc 100644 --- a/static/script.js +++ b/static/script.js @@ -132,10 +132,28 @@ document.addEventListener('DOMContentLoaded', () => { if (apiModelSelect) { apiModelSelect.addEventListener('change', () => { toggleResolutionVisibility(); + toggleCookiesVisibility(); persistSettings(); }); } + const whiskCookiesGroup = document.getElementById('whisk-cookies-group'); + const whiskCookiesInput = document.getElementById('whisk-cookies'); + + function toggleCookiesVisibility() { + if (whiskCookiesGroup && apiModelSelect) { + if (apiModelSelect.value === 'whisk') { + whiskCookiesGroup.classList.remove('hidden'); + } else { + whiskCookiesGroup.classList.add('hidden'); + } + } + } + + if (whiskCookiesInput) { + whiskCookiesInput.addEventListener('input', persistSettings); + } + // Load Settings function loadSettings() { try { @@ -156,6 +174,10 @@ document.addEventListener('DOMContentLoaded', () => { if (bodyFontSelect && settings.bodyFont) { bodyFontSelect.value = settings.bodyFont; } + if (whiskCookiesInput && settings.whiskCookies) { + whiskCookiesInput.value = settings.whiskCookies; + } + toggleCookiesVisibility(); return settings; } } catch (e) { @@ -169,7 +191,7 @@ document.addEventListener('DOMContentLoaded', () => { const referenceImages = (typeof slotManager !== 'undefined' && typeof slotManager.serializeReferenceImages === 'function') ? slotManager.serializeReferenceImages() : []; - + const settings = { apiKey: apiKeyInput.value, prompt: promptInput.value, @@ -180,6 +202,7 @@ document.addEventListener('DOMContentLoaded', () => { referenceImages, theme: currentTheme || DEFAULT_THEME, bodyFont: bodyFontSelect ? bodyFontSelect.value : DEFAULT_BODY_FONT, + whiskCookies: whiskCookiesInput ? whiskCookiesInput.value : '', }; try { localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); @@ -199,12 +222,16 @@ document.addEventListener('DOMContentLoaded', () => { const selectedModel = model || (apiModelSelect ? apiModelSelect.value : 'gemini-3-pro-image-preview'); formData.append('model', selectedModel); + if (whiskCookiesInput && whiskCookiesInput.value) { + formData.append('cookies', whiskCookiesInput.value); + } + // Add reference images using correct slotManager methods const referenceFiles = slotManager.getReferenceFiles(); referenceFiles.forEach(file => { formData.append('reference_images', file); }); - + const referencePaths = slotManager.getReferencePaths(); if (referencePaths && referencePaths.length > 0) { formData.append('reference_image_paths', JSON.stringify(referencePaths)); @@ -592,14 +619,14 @@ document.addEventListener('DOMContentLoaded', () => { // 2. Item currently being processed (isProcessingQueue) // 3. Items waiting for backend response (pendingRequests) const count = generationQueue.length + (isProcessingQueue ? 1 : 0) + pendingRequests; - - console.log('Queue counter update:', { - queue: generationQueue.length, - processing: isProcessingQueue, + + console.log('Queue counter update:', { + queue: generationQueue.length, + processing: isProcessingQueue, pending: pendingRequests, - total: count + total: count }); - + if (count > 0) { if (queueCounter) { queueCounter.classList.remove('hidden'); @@ -623,10 +650,10 @@ document.addEventListener('DOMContentLoaded', () => { const task = generationQueue.shift(); isProcessingQueue = true; updateQueueCounter(); // Show counter immediately - + try { setViewState('loading'); - + // Check if this task already has a result (immediate generation) if (task.immediateResult) { // Display the already-generated image @@ -730,7 +757,7 @@ document.addEventListener('DOMContentLoaded', () => { }); const data = await response.json(); - + // Mark fetch as completed and decrement pending // We do this BEFORE adding to queue to avoid double counting fetchCompleted = true; @@ -785,12 +812,12 @@ document.addEventListener('DOMContentLoaded', () => { } } catch (error) { console.error('Error in addToQueue:', error); - + // If fetch failed (didn't complete), we need to decrement pendingRequests if (!fetchCompleted) { pendingRequests--; } - + updateQueueCounter(); showError(error.message); } @@ -816,7 +843,7 @@ document.addEventListener('DOMContentLoaded', () => { const response = await fetch(url); const blob = await response.blob(); const blobUrl = window.URL.createObjectURL(blob); - + const tempLink = document.createElement('a'); tempLink.href = blobUrl; tempLink.download = filename; @@ -834,7 +861,7 @@ document.addEventListener('DOMContentLoaded', () => { if (imageDisplayArea) { imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false }); imageDisplayArea.addEventListener('pointerdown', handleCanvasPointerDown); - + // Drag and drop support imageDisplayArea.addEventListener('dragover', (e) => { e.preventDefault(); @@ -849,7 +876,7 @@ document.addEventListener('DOMContentLoaded', () => { 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]; @@ -858,7 +885,7 @@ document.addEventListener('DOMContentLoaded', () => { // Display image immediately const objectUrl = URL.createObjectURL(file); displayImage(objectUrl); - + // Extract and apply metadata const metadata = await extractMetadataFromBlob(file); if (metadata) { @@ -965,7 +992,7 @@ document.addEventListener('DOMContentLoaded', () => { const createTemplateModal = document.getElementById('create-template-modal'); const closeTemplateModalBtn = document.getElementById('close-template-modal'); const saveTemplateBtn = document.getElementById('save-template-btn'); - + const templateTitleInput = document.getElementById('template-title'); const templatePromptInput = document.getElementById('template-prompt'); const templateNoteInput = document.getElementById('template-note'); @@ -1189,11 +1216,11 @@ document.addEventListener('DOMContentLoaded', () => { } // Global function for opening edit modal (called from templateGallery.js) - window.openEditTemplateModal = async function(template) { + window.openEditTemplateModal = async function (template) { editingTemplate = template; editingTemplateSource = template.isUserTemplate ? 'user' : 'builtin'; editingBuiltinIndex = editingTemplateSource === 'builtin' ? template.builtinTemplateIndex : null; - + // Pre-fill with template data templateTitleInput.value = template.title || ''; templatePromptInput.value = template.prompt || ''; @@ -1206,18 +1233,18 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { const categories = new Set(); data.prompts.forEach(t => { if (t.category) { - const categoryText = typeof t.category === 'string' - ? t.category + const categoryText = typeof t.category === 'string' + ? t.category : (t.category.vi || t.category.en || ''); if (categoryText) categories.add(categoryText); } }); - + templateCategorySelect.innerHTML = ''; const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1226,15 +1253,15 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + // Set to template's category - const templateCategory = typeof template.category === 'string' - ? template.category + const templateCategory = typeof template.category === 'string' + ? template.category : (template.category.vi || template.category.en || ''); templateCategorySelect.value = templateCategory || 'User'; } @@ -1263,16 +1290,16 @@ document.addEventListener('DOMContentLoaded', () => { // Update button text saveTemplateBtn.innerHTML = 'Update Template'; - + createTemplateModal.classList.remove('hidden'); }; // Global function for opening create modal with empty values (called from templateGallery.js) - window.openCreateTemplateModal = async function() { + window.openCreateTemplateModal = async function () { editingTemplate = null; editingTemplateSource = 'user'; editingBuiltinIndex = null; - + setTemplateTags([]); if (templateTagInput) { templateTagInput.value = ''; @@ -1290,18 +1317,18 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { const categories = new Set(); data.prompts.forEach(t => { if (t.category) { - const categoryText = typeof t.category === 'string' - ? t.category + const categoryText = typeof t.category === 'string' + ? t.category : (t.category.vi || t.category.en || ''); if (categoryText) categories.add(categoryText); } }); - + templateCategorySelect.innerHTML = ''; const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1310,12 +1337,12 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + if (sortedCategories.includes('User')) { templateCategorySelect.value = 'User'; } else if (sortedCategories.length > 0) { @@ -1335,7 +1362,7 @@ document.addEventListener('DOMContentLoaded', () => { // Update button text saveTemplateBtn.innerHTML = 'Save Template'; - + createTemplateModal.classList.remove('hidden'); }; @@ -1345,7 +1372,7 @@ document.addEventListener('DOMContentLoaded', () => { editingTemplate = null; editingTemplateSource = 'user'; editingBuiltinIndex = null; - + // Pre-fill data templateTitleInput.value = ''; templatePromptInput.value = promptInput.value; @@ -1358,25 +1385,25 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { // Extract unique categories const categories = new Set(); data.prompts.forEach(template => { if (template.category) { // Handle both string and object categories - const categoryText = typeof template.category === 'string' - ? template.category + const categoryText = typeof template.category === 'string' + ? template.category : (template.category.vi || template.category.en || ''); if (categoryText) { categories.add(categoryText); } } }); - + // Clear existing options except "new" templateCategorySelect.innerHTML = ''; - + // Add sorted categories const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1385,13 +1412,13 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + // Add "new category" option at the end const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + // Set default to first category or "User" if it exists if (sortedCategories.includes('User')) { templateCategorySelect.value = 'User'; @@ -1465,7 +1492,7 @@ document.addEventListener('DOMContentLoaded', () => { templatePreviewDropzone.addEventListener('click', (e) => { // Don't toggle if clicking on the input itself if (e.target === templatePreviewUrlInput) return; - + if (!isUrlInputMode) { // Switch to URL input mode isUrlInputMode = true; @@ -1520,7 +1547,7 @@ document.addEventListener('DOMContentLoaded', () => { } }); } - + templatePreviewDropzone.addEventListener('dragover', (e) => { e.preventDefault(); templatePreviewDropzone.classList.add('drag-over'); @@ -1534,7 +1561,7 @@ document.addEventListener('DOMContentLoaded', () => { templatePreviewDropzone.addEventListener('drop', (e) => { e.preventDefault(); templatePreviewDropzone.classList.remove('drag-over'); - + const files = e.dataTransfer.files; if (files.length > 0) { const file = files[0]; @@ -1559,7 +1586,7 @@ document.addEventListener('DOMContentLoaded', () => { const note = templateNoteInput.value.trim(); const mode = templateModeSelect.value; let category = templateCategorySelect.value; - + if (category === 'new') { category = templateCategoryInput.value.trim(); } @@ -1619,10 +1646,10 @@ document.addEventListener('DOMContentLoaded', () => { // Success createTemplateModal.classList.add('hidden'); - + // Reload template gallery await templateGallery.load(); - + // Reset editing state editingTemplate = null; editingTemplateSource = null; @@ -1666,7 +1693,7 @@ document.addEventListener('DOMContentLoaded', () => { loadGallery(); loadTemplateGallery(); initializeSidebarResizer(sidebar, resizeHandle); - + // Restore last image if available try { const lastImage = localStorage.getItem('gemini-app-last-image'); @@ -1676,13 +1703,13 @@ document.addEventListener('DOMContentLoaded', () => { } catch (e) { console.warn('Failed to restore last image', e); } - + // Setup canvas language toggle const canvasLangInput = document.getElementById('canvas-lang-input'); if (canvasLangInput) { // Set initial state canvasLangInput.checked = i18n.currentLang === 'en'; - + canvasLangInput.addEventListener('change', (e) => { i18n.setLanguage(e.target.checked ? 'en' : 'vi'); // Update visual state @@ -1753,7 +1780,7 @@ document.addEventListener('DOMContentLoaded', () => { if (!btn.classList.contains('history-favorites-btn')) { btn.addEventListener('click', () => { const filterType = btn.dataset.filter; - + // Remove active from all date filter buttons (not favorites) historyFilterBtns.forEach(b => { if (!b.classList.contains('history-favorites-btn')) { @@ -1834,7 +1861,7 @@ document.addEventListener('DOMContentLoaded', () => { hasGeneratedImage = true; // Mark that we have an image setViewState('result'); - + // Persist image URL try { localStorage.setItem('gemini-app-last-image', imageUrl); @@ -1864,7 +1891,7 @@ document.addEventListener('DOMContentLoaded', () => { promptInput.value = metadata.prompt; refreshPromptHighlight(); } - + // If metadata doesn't have 'note' field, set to empty string instead of keeping current value if (metadata.hasOwnProperty('note')) { promptNoteInput.value = metadata.note || ''; @@ -1872,14 +1899,14 @@ document.addEventListener('DOMContentLoaded', () => { promptNoteInput.value = ''; } refreshNoteHighlight(); - + 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(); } @@ -1968,9 +1995,9 @@ document.addEventListener('DOMContentLoaded', () => { const targetTag = event.target?.tagName; if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return; if (event.target?.isContentEditable) return; - + event.preventDefault(); - + // Toggle template gallery if (templateGalleryState.classList.contains('hidden')) { setViewState('template-gallery'); @@ -2140,9 +2167,9 @@ document.addEventListener('DOMContentLoaded', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); - + const data = await response.json(); - + if (!response.ok) { throw new Error(data.error || 'Failed to download image'); } @@ -2155,7 +2182,7 @@ document.addEventListener('DOMContentLoaded', () => { alert('Không còn slot trống cho ảnh tham chiếu.'); } } else { - throw new Error('No image path returned'); + throw new Error('No image path returned'); } } catch (error) { diff --git a/templates/index.html b/templates/index.html index f35facf..ff5134b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -46,18 +46,16 @@ - + - + @@ -67,9 +65,8 @@ - + @@ -132,18 +129,16 @@ - + - + @@ -153,9 +148,8 @@ - + @@ -173,7 +167,7 @@ - @@ -498,6 +492,15 @@ rel="noreferrer">aistudio.google.com/api-keys
r1DC^iDd`6V_j?T54gI2k<1oNepV4D*l?yp> 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 DOtbu{ literal 0 HcmV?d00001 diff --git a/__pycache__/whisk_client.cpython-314.pyc b/__pycache__/whisk_client.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff802374bddb88df91b3dac18c4d7f9d87c7ade2 GIT binary patch literal 10934 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 literal 0 HcmV?d00001 diff --git a/app.py b/app.py index 18e0904..cfff405 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,7 @@ from google import genai from google.genai import types from PIL import Image, PngImagePlugin import threading, time, subprocess, re +import whisk_client import logging @@ -393,12 +394,15 @@ def generate_image(): if not prompt: return jsonify({'error': 'Prompt is required'}), 400 - if not api_key: - return jsonify({'error': 'API Key is required.'}), 401 + # Determine if this is a Whisk request + is_whisk = 'whisk' in model.lower() or 'imagefx' in model.lower() + + if not is_whisk and not api_key: + return jsonify({'error': 'API Key is required for Gemini models.'}), 401 try: print("Đang gửi lệnh...", flush=True) - client = genai.Client(api_key=api_key) + # client initialization moved to Gemini block image_config_args = {} @@ -514,6 +518,133 @@ def generate_image(): continue model_name = model + + # ================================================================================== + # WHISK (IMAGEFX) HANDLING + # ================================================================================== + if is_whisk: + print(f"Detected Whisk/ImageFX model request: {model_name}", flush=True) + + # Extract cookies from request headers or form data + # Priority: Form Data 'cookies' > Request Header 'x-whisk-cookies' > Environment Variable + cookie_str = request.form.get('cookies') or request.headers.get('x-whisk-cookies') or os.environ.get('WHISK_COOKIES') + + if not cookie_str: + return jsonify({'error': 'Whisk cookies are required. Please provide them in the "cookies" form field or configuration.'}), 400 + + print("Sending request to Whisk...", flush=True) + try: + # Check for reference images + reference_image_path = None + + # final_reference_paths (populated above) contains URLs/paths to reference images. + # Can be new uploads or history items. + if final_reference_paths: + # Use the first one + ref_url = final_reference_paths[0] + + # Convert URL/Path to absolute local path + # ref_url might be "http://.../static/..." or "/static/..." + if '/static/' in ref_url: + rel_path = ref_url.split('/static/')[1] + possible_path = os.path.join(app.static_folder, rel_path) + if os.path.exists(possible_path): + reference_image_path = possible_path + print(f"Whisk: Using reference image at {reference_image_path}", flush=True) + elif os.path.exists(ref_url): + # It's already a path? + reference_image_path = ref_url + + # Call the client + try: + whisk_result = whisk_client.generate_image_whisk( + prompt=api_prompt, + cookie_str=cookie_str, + aspect_ratio=aspect_ratio, + resolution=resolution, + reference_image_path=reference_image_path + ) + except Exception as e: + # Re-raise to be caught by the outer block + raise e + + # Process result - whisk_client returns raw bytes + image_bytes = None + if isinstance(whisk_result, bytes): + image_bytes = whisk_result + elif isinstance(whisk_result, dict): + # Fallback if I ever change the client to return dict + if 'image_data' in whisk_result: + image_bytes = whisk_result['image_data'] + elif 'image_url' in whisk_result: + import requests + img_resp = requests.get(whisk_result['image_url']) + image_bytes = img_resp.content + + if not image_bytes: + raise ValueError("No image data returned from Whisk.") + + # Save and process image (Reuse existing logic) + image = Image.open(BytesIO(image_bytes)) + png_info = PngImagePlugin.PngInfo() + + date_str = datetime.now().strftime("%Y%m%d") + search_pattern = os.path.join(GENERATED_DIR, f"whisk_{date_str}_*.png") + existing_files = glob.glob(search_pattern) + max_id = 0 + for f in existing_files: + try: + basename = os.path.basename(f) + name_without_ext = os.path.splitext(basename)[0] + id_part = name_without_ext.split('_')[-1] + id_num = int(id_part) + if id_num > max_id: + max_id = id_num + except ValueError: + continue + + next_id = max_id + 1 + filename = f"whisk_{date_str}_{next_id}.png" + filepath = os.path.join(GENERATED_DIR, filename) + rel_path = os.path.join('generated', filename) + image_url = url_for('static', filename=rel_path) + + metadata = { + 'prompt': prompt, + 'note': note, + 'processed_prompt': api_prompt, + 'aspect_ratio': aspect_ratio or 'Auto', + 'resolution': resolution, + 'reference_images': final_reference_paths, + 'model': 'whisk' + } + png_info.add_text('sdvn_meta', json.dumps(metadata)) + + buffer = BytesIO() + image.save(buffer, format='PNG', pnginfo=png_info) + final_bytes = buffer.getvalue() + + with open(filepath, 'wb') as f: + f.write(final_bytes) + + image_data = base64.b64encode(final_bytes).decode('utf-8') + return jsonify({ + 'image': image_url, + 'image_data': image_data, + 'metadata': metadata, + }) + + except Exception as e: + print(f"Whisk error: {e}") + return jsonify({'error': f"Whisk Generation Error: {str(e)}"}), 500 + + # ================================================================================== + # STANDARD GEMINI HANDLING + # ================================================================================== + + # Initialize Client here, since API Key is required + client = genai.Client(api_key=api_key) + print(f"Đang tạo với model {model_name}...", flush=True) response = client.models.generate_content( model=model_name, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ef166c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + app: + build: . + platform: linux/amd64 + ports: + - "8558:8888" + volumes: + - ./static:/app/static + - ./prompts.json:/app/prompts.json + - ./user_prompts.json:/app/user_prompts.json + - ./gallery_favorites.json:/app/gallery_favorites.json + environment: + - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} # Optional for Whisk + - WHISK_COOKIES=${WHISK_COOKIES:-} + restart: unless-stopped diff --git a/static/script.js b/static/script.js index 6cc8494..a8598cc 100644 --- a/static/script.js +++ b/static/script.js @@ -132,10 +132,28 @@ document.addEventListener('DOMContentLoaded', () => { if (apiModelSelect) { apiModelSelect.addEventListener('change', () => { toggleResolutionVisibility(); + toggleCookiesVisibility(); persistSettings(); }); } + const whiskCookiesGroup = document.getElementById('whisk-cookies-group'); + const whiskCookiesInput = document.getElementById('whisk-cookies'); + + function toggleCookiesVisibility() { + if (whiskCookiesGroup && apiModelSelect) { + if (apiModelSelect.value === 'whisk') { + whiskCookiesGroup.classList.remove('hidden'); + } else { + whiskCookiesGroup.classList.add('hidden'); + } + } + } + + if (whiskCookiesInput) { + whiskCookiesInput.addEventListener('input', persistSettings); + } + // Load Settings function loadSettings() { try { @@ -156,6 +174,10 @@ document.addEventListener('DOMContentLoaded', () => { if (bodyFontSelect && settings.bodyFont) { bodyFontSelect.value = settings.bodyFont; } + if (whiskCookiesInput && settings.whiskCookies) { + whiskCookiesInput.value = settings.whiskCookies; + } + toggleCookiesVisibility(); return settings; } } catch (e) { @@ -169,7 +191,7 @@ document.addEventListener('DOMContentLoaded', () => { const referenceImages = (typeof slotManager !== 'undefined' && typeof slotManager.serializeReferenceImages === 'function') ? slotManager.serializeReferenceImages() : []; - + const settings = { apiKey: apiKeyInput.value, prompt: promptInput.value, @@ -180,6 +202,7 @@ document.addEventListener('DOMContentLoaded', () => { referenceImages, theme: currentTheme || DEFAULT_THEME, bodyFont: bodyFontSelect ? bodyFontSelect.value : DEFAULT_BODY_FONT, + whiskCookies: whiskCookiesInput ? whiskCookiesInput.value : '', }; try { localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); @@ -199,12 +222,16 @@ document.addEventListener('DOMContentLoaded', () => { const selectedModel = model || (apiModelSelect ? apiModelSelect.value : 'gemini-3-pro-image-preview'); formData.append('model', selectedModel); + if (whiskCookiesInput && whiskCookiesInput.value) { + formData.append('cookies', whiskCookiesInput.value); + } + // Add reference images using correct slotManager methods const referenceFiles = slotManager.getReferenceFiles(); referenceFiles.forEach(file => { formData.append('reference_images', file); }); - + const referencePaths = slotManager.getReferencePaths(); if (referencePaths && referencePaths.length > 0) { formData.append('reference_image_paths', JSON.stringify(referencePaths)); @@ -592,14 +619,14 @@ document.addEventListener('DOMContentLoaded', () => { // 2. Item currently being processed (isProcessingQueue) // 3. Items waiting for backend response (pendingRequests) const count = generationQueue.length + (isProcessingQueue ? 1 : 0) + pendingRequests; - - console.log('Queue counter update:', { - queue: generationQueue.length, - processing: isProcessingQueue, + + console.log('Queue counter update:', { + queue: generationQueue.length, + processing: isProcessingQueue, pending: pendingRequests, - total: count + total: count }); - + if (count > 0) { if (queueCounter) { queueCounter.classList.remove('hidden'); @@ -623,10 +650,10 @@ document.addEventListener('DOMContentLoaded', () => { const task = generationQueue.shift(); isProcessingQueue = true; updateQueueCounter(); // Show counter immediately - + try { setViewState('loading'); - + // Check if this task already has a result (immediate generation) if (task.immediateResult) { // Display the already-generated image @@ -730,7 +757,7 @@ document.addEventListener('DOMContentLoaded', () => { }); const data = await response.json(); - + // Mark fetch as completed and decrement pending // We do this BEFORE adding to queue to avoid double counting fetchCompleted = true; @@ -785,12 +812,12 @@ document.addEventListener('DOMContentLoaded', () => { } } catch (error) { console.error('Error in addToQueue:', error); - + // If fetch failed (didn't complete), we need to decrement pendingRequests if (!fetchCompleted) { pendingRequests--; } - + updateQueueCounter(); showError(error.message); } @@ -816,7 +843,7 @@ document.addEventListener('DOMContentLoaded', () => { const response = await fetch(url); const blob = await response.blob(); const blobUrl = window.URL.createObjectURL(blob); - + const tempLink = document.createElement('a'); tempLink.href = blobUrl; tempLink.download = filename; @@ -834,7 +861,7 @@ document.addEventListener('DOMContentLoaded', () => { if (imageDisplayArea) { imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false }); imageDisplayArea.addEventListener('pointerdown', handleCanvasPointerDown); - + // Drag and drop support imageDisplayArea.addEventListener('dragover', (e) => { e.preventDefault(); @@ -849,7 +876,7 @@ document.addEventListener('DOMContentLoaded', () => { 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]; @@ -858,7 +885,7 @@ document.addEventListener('DOMContentLoaded', () => { // Display image immediately const objectUrl = URL.createObjectURL(file); displayImage(objectUrl); - + // Extract and apply metadata const metadata = await extractMetadataFromBlob(file); if (metadata) { @@ -965,7 +992,7 @@ document.addEventListener('DOMContentLoaded', () => { const createTemplateModal = document.getElementById('create-template-modal'); const closeTemplateModalBtn = document.getElementById('close-template-modal'); const saveTemplateBtn = document.getElementById('save-template-btn'); - + const templateTitleInput = document.getElementById('template-title'); const templatePromptInput = document.getElementById('template-prompt'); const templateNoteInput = document.getElementById('template-note'); @@ -1189,11 +1216,11 @@ document.addEventListener('DOMContentLoaded', () => { } // Global function for opening edit modal (called from templateGallery.js) - window.openEditTemplateModal = async function(template) { + window.openEditTemplateModal = async function (template) { editingTemplate = template; editingTemplateSource = template.isUserTemplate ? 'user' : 'builtin'; editingBuiltinIndex = editingTemplateSource === 'builtin' ? template.builtinTemplateIndex : null; - + // Pre-fill with template data templateTitleInput.value = template.title || ''; templatePromptInput.value = template.prompt || ''; @@ -1206,18 +1233,18 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { const categories = new Set(); data.prompts.forEach(t => { if (t.category) { - const categoryText = typeof t.category === 'string' - ? t.category + const categoryText = typeof t.category === 'string' + ? t.category : (t.category.vi || t.category.en || ''); if (categoryText) categories.add(categoryText); } }); - + templateCategorySelect.innerHTML = ''; const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1226,15 +1253,15 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + // Set to template's category - const templateCategory = typeof template.category === 'string' - ? template.category + const templateCategory = typeof template.category === 'string' + ? template.category : (template.category.vi || template.category.en || ''); templateCategorySelect.value = templateCategory || 'User'; } @@ -1263,16 +1290,16 @@ document.addEventListener('DOMContentLoaded', () => { // Update button text saveTemplateBtn.innerHTML = 'Update Template'; - + createTemplateModal.classList.remove('hidden'); }; // Global function for opening create modal with empty values (called from templateGallery.js) - window.openCreateTemplateModal = async function() { + window.openCreateTemplateModal = async function () { editingTemplate = null; editingTemplateSource = 'user'; editingBuiltinIndex = null; - + setTemplateTags([]); if (templateTagInput) { templateTagInput.value = ''; @@ -1290,18 +1317,18 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { const categories = new Set(); data.prompts.forEach(t => { if (t.category) { - const categoryText = typeof t.category === 'string' - ? t.category + const categoryText = typeof t.category === 'string' + ? t.category : (t.category.vi || t.category.en || ''); if (categoryText) categories.add(categoryText); } }); - + templateCategorySelect.innerHTML = ''; const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1310,12 +1337,12 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + if (sortedCategories.includes('User')) { templateCategorySelect.value = 'User'; } else if (sortedCategories.length > 0) { @@ -1335,7 +1362,7 @@ document.addEventListener('DOMContentLoaded', () => { // Update button text saveTemplateBtn.innerHTML = 'Save Template'; - + createTemplateModal.classList.remove('hidden'); }; @@ -1345,7 +1372,7 @@ document.addEventListener('DOMContentLoaded', () => { editingTemplate = null; editingTemplateSource = 'user'; editingBuiltinIndex = null; - + // Pre-fill data templateTitleInput.value = ''; templatePromptInput.value = promptInput.value; @@ -1358,25 +1385,25 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { // Extract unique categories const categories = new Set(); data.prompts.forEach(template => { if (template.category) { // Handle both string and object categories - const categoryText = typeof template.category === 'string' - ? template.category + const categoryText = typeof template.category === 'string' + ? template.category : (template.category.vi || template.category.en || ''); if (categoryText) { categories.add(categoryText); } } }); - + // Clear existing options except "new" templateCategorySelect.innerHTML = ''; - + // Add sorted categories const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1385,13 +1412,13 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + // Add "new category" option at the end const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + // Set default to first category or "User" if it exists if (sortedCategories.includes('User')) { templateCategorySelect.value = 'User'; @@ -1465,7 +1492,7 @@ document.addEventListener('DOMContentLoaded', () => { templatePreviewDropzone.addEventListener('click', (e) => { // Don't toggle if clicking on the input itself if (e.target === templatePreviewUrlInput) return; - + if (!isUrlInputMode) { // Switch to URL input mode isUrlInputMode = true; @@ -1520,7 +1547,7 @@ document.addEventListener('DOMContentLoaded', () => { } }); } - + templatePreviewDropzone.addEventListener('dragover', (e) => { e.preventDefault(); templatePreviewDropzone.classList.add('drag-over'); @@ -1534,7 +1561,7 @@ document.addEventListener('DOMContentLoaded', () => { templatePreviewDropzone.addEventListener('drop', (e) => { e.preventDefault(); templatePreviewDropzone.classList.remove('drag-over'); - + const files = e.dataTransfer.files; if (files.length > 0) { const file = files[0]; @@ -1559,7 +1586,7 @@ document.addEventListener('DOMContentLoaded', () => { const note = templateNoteInput.value.trim(); const mode = templateModeSelect.value; let category = templateCategorySelect.value; - + if (category === 'new') { category = templateCategoryInput.value.trim(); } @@ -1619,10 +1646,10 @@ document.addEventListener('DOMContentLoaded', () => { // Success createTemplateModal.classList.add('hidden'); - + // Reload template gallery await templateGallery.load(); - + // Reset editing state editingTemplate = null; editingTemplateSource = null; @@ -1666,7 +1693,7 @@ document.addEventListener('DOMContentLoaded', () => { loadGallery(); loadTemplateGallery(); initializeSidebarResizer(sidebar, resizeHandle); - + // Restore last image if available try { const lastImage = localStorage.getItem('gemini-app-last-image'); @@ -1676,13 +1703,13 @@ document.addEventListener('DOMContentLoaded', () => { } catch (e) { console.warn('Failed to restore last image', e); } - + // Setup canvas language toggle const canvasLangInput = document.getElementById('canvas-lang-input'); if (canvasLangInput) { // Set initial state canvasLangInput.checked = i18n.currentLang === 'en'; - + canvasLangInput.addEventListener('change', (e) => { i18n.setLanguage(e.target.checked ? 'en' : 'vi'); // Update visual state @@ -1753,7 +1780,7 @@ document.addEventListener('DOMContentLoaded', () => { if (!btn.classList.contains('history-favorites-btn')) { btn.addEventListener('click', () => { const filterType = btn.dataset.filter; - + // Remove active from all date filter buttons (not favorites) historyFilterBtns.forEach(b => { if (!b.classList.contains('history-favorites-btn')) { @@ -1834,7 +1861,7 @@ document.addEventListener('DOMContentLoaded', () => { hasGeneratedImage = true; // Mark that we have an image setViewState('result'); - + // Persist image URL try { localStorage.setItem('gemini-app-last-image', imageUrl); @@ -1864,7 +1891,7 @@ document.addEventListener('DOMContentLoaded', () => { promptInput.value = metadata.prompt; refreshPromptHighlight(); } - + // If metadata doesn't have 'note' field, set to empty string instead of keeping current value if (metadata.hasOwnProperty('note')) { promptNoteInput.value = metadata.note || ''; @@ -1872,14 +1899,14 @@ document.addEventListener('DOMContentLoaded', () => { promptNoteInput.value = ''; } refreshNoteHighlight(); - + 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(); } @@ -1968,9 +1995,9 @@ document.addEventListener('DOMContentLoaded', () => { const targetTag = event.target?.tagName; if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return; if (event.target?.isContentEditable) return; - + event.preventDefault(); - + // Toggle template gallery if (templateGalleryState.classList.contains('hidden')) { setViewState('template-gallery'); @@ -2140,9 +2167,9 @@ document.addEventListener('DOMContentLoaded', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); - + const data = await response.json(); - + if (!response.ok) { throw new Error(data.error || 'Failed to download image'); } @@ -2155,7 +2182,7 @@ document.addEventListener('DOMContentLoaded', () => { alert('Không còn slot trống cho ảnh tham chiếu.'); } } else { - throw new Error('No image path returned'); + throw new Error('No image path returned'); } } catch (error) { diff --git a/templates/index.html b/templates/index.html index f35facf..ff5134b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -46,18 +46,16 @@ - + - + @@ -67,9 +65,8 @@ - + @@ -132,18 +129,16 @@ - + - + @@ -153,9 +148,8 @@ - + @@ -173,7 +167,7 @@ - @@ -498,6 +492,15 @@ rel="noreferrer">aistudio.google.com/api-keys
~_`lO81N%Fay1P#D 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 DOtbu{ literal 0 HcmV?d00001 diff --git a/__pycache__/whisk_client.cpython-314.pyc b/__pycache__/whisk_client.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff802374bddb88df91b3dac18c4d7f9d87c7ade2 GIT binary patch literal 10934 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 literal 0 HcmV?d00001 diff --git a/app.py b/app.py index 18e0904..cfff405 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,7 @@ from google import genai from google.genai import types from PIL import Image, PngImagePlugin import threading, time, subprocess, re +import whisk_client import logging @@ -393,12 +394,15 @@ def generate_image(): if not prompt: return jsonify({'error': 'Prompt is required'}), 400 - if not api_key: - return jsonify({'error': 'API Key is required.'}), 401 + # Determine if this is a Whisk request + is_whisk = 'whisk' in model.lower() or 'imagefx' in model.lower() + + if not is_whisk and not api_key: + return jsonify({'error': 'API Key is required for Gemini models.'}), 401 try: print("Đang gửi lệnh...", flush=True) - client = genai.Client(api_key=api_key) + # client initialization moved to Gemini block image_config_args = {} @@ -514,6 +518,133 @@ def generate_image(): continue model_name = model + + # ================================================================================== + # WHISK (IMAGEFX) HANDLING + # ================================================================================== + if is_whisk: + print(f"Detected Whisk/ImageFX model request: {model_name}", flush=True) + + # Extract cookies from request headers or form data + # Priority: Form Data 'cookies' > Request Header 'x-whisk-cookies' > Environment Variable + cookie_str = request.form.get('cookies') or request.headers.get('x-whisk-cookies') or os.environ.get('WHISK_COOKIES') + + if not cookie_str: + return jsonify({'error': 'Whisk cookies are required. Please provide them in the "cookies" form field or configuration.'}), 400 + + print("Sending request to Whisk...", flush=True) + try: + # Check for reference images + reference_image_path = None + + # final_reference_paths (populated above) contains URLs/paths to reference images. + # Can be new uploads or history items. + if final_reference_paths: + # Use the first one + ref_url = final_reference_paths[0] + + # Convert URL/Path to absolute local path + # ref_url might be "http://.../static/..." or "/static/..." + if '/static/' in ref_url: + rel_path = ref_url.split('/static/')[1] + possible_path = os.path.join(app.static_folder, rel_path) + if os.path.exists(possible_path): + reference_image_path = possible_path + print(f"Whisk: Using reference image at {reference_image_path}", flush=True) + elif os.path.exists(ref_url): + # It's already a path? + reference_image_path = ref_url + + # Call the client + try: + whisk_result = whisk_client.generate_image_whisk( + prompt=api_prompt, + cookie_str=cookie_str, + aspect_ratio=aspect_ratio, + resolution=resolution, + reference_image_path=reference_image_path + ) + except Exception as e: + # Re-raise to be caught by the outer block + raise e + + # Process result - whisk_client returns raw bytes + image_bytes = None + if isinstance(whisk_result, bytes): + image_bytes = whisk_result + elif isinstance(whisk_result, dict): + # Fallback if I ever change the client to return dict + if 'image_data' in whisk_result: + image_bytes = whisk_result['image_data'] + elif 'image_url' in whisk_result: + import requests + img_resp = requests.get(whisk_result['image_url']) + image_bytes = img_resp.content + + if not image_bytes: + raise ValueError("No image data returned from Whisk.") + + # Save and process image (Reuse existing logic) + image = Image.open(BytesIO(image_bytes)) + png_info = PngImagePlugin.PngInfo() + + date_str = datetime.now().strftime("%Y%m%d") + search_pattern = os.path.join(GENERATED_DIR, f"whisk_{date_str}_*.png") + existing_files = glob.glob(search_pattern) + max_id = 0 + for f in existing_files: + try: + basename = os.path.basename(f) + name_without_ext = os.path.splitext(basename)[0] + id_part = name_without_ext.split('_')[-1] + id_num = int(id_part) + if id_num > max_id: + max_id = id_num + except ValueError: + continue + + next_id = max_id + 1 + filename = f"whisk_{date_str}_{next_id}.png" + filepath = os.path.join(GENERATED_DIR, filename) + rel_path = os.path.join('generated', filename) + image_url = url_for('static', filename=rel_path) + + metadata = { + 'prompt': prompt, + 'note': note, + 'processed_prompt': api_prompt, + 'aspect_ratio': aspect_ratio or 'Auto', + 'resolution': resolution, + 'reference_images': final_reference_paths, + 'model': 'whisk' + } + png_info.add_text('sdvn_meta', json.dumps(metadata)) + + buffer = BytesIO() + image.save(buffer, format='PNG', pnginfo=png_info) + final_bytes = buffer.getvalue() + + with open(filepath, 'wb') as f: + f.write(final_bytes) + + image_data = base64.b64encode(final_bytes).decode('utf-8') + return jsonify({ + 'image': image_url, + 'image_data': image_data, + 'metadata': metadata, + }) + + except Exception as e: + print(f"Whisk error: {e}") + return jsonify({'error': f"Whisk Generation Error: {str(e)}"}), 500 + + # ================================================================================== + # STANDARD GEMINI HANDLING + # ================================================================================== + + # Initialize Client here, since API Key is required + client = genai.Client(api_key=api_key) + print(f"Đang tạo với model {model_name}...", flush=True) response = client.models.generate_content( model=model_name, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ef166c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + app: + build: . + platform: linux/amd64 + ports: + - "8558:8888" + volumes: + - ./static:/app/static + - ./prompts.json:/app/prompts.json + - ./user_prompts.json:/app/user_prompts.json + - ./gallery_favorites.json:/app/gallery_favorites.json + environment: + - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} # Optional for Whisk + - WHISK_COOKIES=${WHISK_COOKIES:-} + restart: unless-stopped diff --git a/static/script.js b/static/script.js index 6cc8494..a8598cc 100644 --- a/static/script.js +++ b/static/script.js @@ -132,10 +132,28 @@ document.addEventListener('DOMContentLoaded', () => { if (apiModelSelect) { apiModelSelect.addEventListener('change', () => { toggleResolutionVisibility(); + toggleCookiesVisibility(); persistSettings(); }); } + const whiskCookiesGroup = document.getElementById('whisk-cookies-group'); + const whiskCookiesInput = document.getElementById('whisk-cookies'); + + function toggleCookiesVisibility() { + if (whiskCookiesGroup && apiModelSelect) { + if (apiModelSelect.value === 'whisk') { + whiskCookiesGroup.classList.remove('hidden'); + } else { + whiskCookiesGroup.classList.add('hidden'); + } + } + } + + if (whiskCookiesInput) { + whiskCookiesInput.addEventListener('input', persistSettings); + } + // Load Settings function loadSettings() { try { @@ -156,6 +174,10 @@ document.addEventListener('DOMContentLoaded', () => { if (bodyFontSelect && settings.bodyFont) { bodyFontSelect.value = settings.bodyFont; } + if (whiskCookiesInput && settings.whiskCookies) { + whiskCookiesInput.value = settings.whiskCookies; + } + toggleCookiesVisibility(); return settings; } } catch (e) { @@ -169,7 +191,7 @@ document.addEventListener('DOMContentLoaded', () => { const referenceImages = (typeof slotManager !== 'undefined' && typeof slotManager.serializeReferenceImages === 'function') ? slotManager.serializeReferenceImages() : []; - + const settings = { apiKey: apiKeyInput.value, prompt: promptInput.value, @@ -180,6 +202,7 @@ document.addEventListener('DOMContentLoaded', () => { referenceImages, theme: currentTheme || DEFAULT_THEME, bodyFont: bodyFontSelect ? bodyFontSelect.value : DEFAULT_BODY_FONT, + whiskCookies: whiskCookiesInput ? whiskCookiesInput.value : '', }; try { localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); @@ -199,12 +222,16 @@ document.addEventListener('DOMContentLoaded', () => { const selectedModel = model || (apiModelSelect ? apiModelSelect.value : 'gemini-3-pro-image-preview'); formData.append('model', selectedModel); + if (whiskCookiesInput && whiskCookiesInput.value) { + formData.append('cookies', whiskCookiesInput.value); + } + // Add reference images using correct slotManager methods const referenceFiles = slotManager.getReferenceFiles(); referenceFiles.forEach(file => { formData.append('reference_images', file); }); - + const referencePaths = slotManager.getReferencePaths(); if (referencePaths && referencePaths.length > 0) { formData.append('reference_image_paths', JSON.stringify(referencePaths)); @@ -592,14 +619,14 @@ document.addEventListener('DOMContentLoaded', () => { // 2. Item currently being processed (isProcessingQueue) // 3. Items waiting for backend response (pendingRequests) const count = generationQueue.length + (isProcessingQueue ? 1 : 0) + pendingRequests; - - console.log('Queue counter update:', { - queue: generationQueue.length, - processing: isProcessingQueue, + + console.log('Queue counter update:', { + queue: generationQueue.length, + processing: isProcessingQueue, pending: pendingRequests, - total: count + total: count }); - + if (count > 0) { if (queueCounter) { queueCounter.classList.remove('hidden'); @@ -623,10 +650,10 @@ document.addEventListener('DOMContentLoaded', () => { const task = generationQueue.shift(); isProcessingQueue = true; updateQueueCounter(); // Show counter immediately - + try { setViewState('loading'); - + // Check if this task already has a result (immediate generation) if (task.immediateResult) { // Display the already-generated image @@ -730,7 +757,7 @@ document.addEventListener('DOMContentLoaded', () => { }); const data = await response.json(); - + // Mark fetch as completed and decrement pending // We do this BEFORE adding to queue to avoid double counting fetchCompleted = true; @@ -785,12 +812,12 @@ document.addEventListener('DOMContentLoaded', () => { } } catch (error) { console.error('Error in addToQueue:', error); - + // If fetch failed (didn't complete), we need to decrement pendingRequests if (!fetchCompleted) { pendingRequests--; } - + updateQueueCounter(); showError(error.message); } @@ -816,7 +843,7 @@ document.addEventListener('DOMContentLoaded', () => { const response = await fetch(url); const blob = await response.blob(); const blobUrl = window.URL.createObjectURL(blob); - + const tempLink = document.createElement('a'); tempLink.href = blobUrl; tempLink.download = filename; @@ -834,7 +861,7 @@ document.addEventListener('DOMContentLoaded', () => { if (imageDisplayArea) { imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false }); imageDisplayArea.addEventListener('pointerdown', handleCanvasPointerDown); - + // Drag and drop support imageDisplayArea.addEventListener('dragover', (e) => { e.preventDefault(); @@ -849,7 +876,7 @@ document.addEventListener('DOMContentLoaded', () => { 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]; @@ -858,7 +885,7 @@ document.addEventListener('DOMContentLoaded', () => { // Display image immediately const objectUrl = URL.createObjectURL(file); displayImage(objectUrl); - + // Extract and apply metadata const metadata = await extractMetadataFromBlob(file); if (metadata) { @@ -965,7 +992,7 @@ document.addEventListener('DOMContentLoaded', () => { const createTemplateModal = document.getElementById('create-template-modal'); const closeTemplateModalBtn = document.getElementById('close-template-modal'); const saveTemplateBtn = document.getElementById('save-template-btn'); - + const templateTitleInput = document.getElementById('template-title'); const templatePromptInput = document.getElementById('template-prompt'); const templateNoteInput = document.getElementById('template-note'); @@ -1189,11 +1216,11 @@ document.addEventListener('DOMContentLoaded', () => { } // Global function for opening edit modal (called from templateGallery.js) - window.openEditTemplateModal = async function(template) { + window.openEditTemplateModal = async function (template) { editingTemplate = template; editingTemplateSource = template.isUserTemplate ? 'user' : 'builtin'; editingBuiltinIndex = editingTemplateSource === 'builtin' ? template.builtinTemplateIndex : null; - + // Pre-fill with template data templateTitleInput.value = template.title || ''; templatePromptInput.value = template.prompt || ''; @@ -1206,18 +1233,18 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { const categories = new Set(); data.prompts.forEach(t => { if (t.category) { - const categoryText = typeof t.category === 'string' - ? t.category + const categoryText = typeof t.category === 'string' + ? t.category : (t.category.vi || t.category.en || ''); if (categoryText) categories.add(categoryText); } }); - + templateCategorySelect.innerHTML = ''; const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1226,15 +1253,15 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + // Set to template's category - const templateCategory = typeof template.category === 'string' - ? template.category + const templateCategory = typeof template.category === 'string' + ? template.category : (template.category.vi || template.category.en || ''); templateCategorySelect.value = templateCategory || 'User'; } @@ -1263,16 +1290,16 @@ document.addEventListener('DOMContentLoaded', () => { // Update button text saveTemplateBtn.innerHTML = 'Update Template'; - + createTemplateModal.classList.remove('hidden'); }; // Global function for opening create modal with empty values (called from templateGallery.js) - window.openCreateTemplateModal = async function() { + window.openCreateTemplateModal = async function () { editingTemplate = null; editingTemplateSource = 'user'; editingBuiltinIndex = null; - + setTemplateTags([]); if (templateTagInput) { templateTagInput.value = ''; @@ -1290,18 +1317,18 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { const categories = new Set(); data.prompts.forEach(t => { if (t.category) { - const categoryText = typeof t.category === 'string' - ? t.category + const categoryText = typeof t.category === 'string' + ? t.category : (t.category.vi || t.category.en || ''); if (categoryText) categories.add(categoryText); } }); - + templateCategorySelect.innerHTML = ''; const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1310,12 +1337,12 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + if (sortedCategories.includes('User')) { templateCategorySelect.value = 'User'; } else if (sortedCategories.length > 0) { @@ -1335,7 +1362,7 @@ document.addEventListener('DOMContentLoaded', () => { // Update button text saveTemplateBtn.innerHTML = 'Save Template'; - + createTemplateModal.classList.remove('hidden'); }; @@ -1345,7 +1372,7 @@ document.addEventListener('DOMContentLoaded', () => { editingTemplate = null; editingTemplateSource = 'user'; editingBuiltinIndex = null; - + // Pre-fill data templateTitleInput.value = ''; templatePromptInput.value = promptInput.value; @@ -1358,25 +1385,25 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { // Extract unique categories const categories = new Set(); data.prompts.forEach(template => { if (template.category) { // Handle both string and object categories - const categoryText = typeof template.category === 'string' - ? template.category + const categoryText = typeof template.category === 'string' + ? template.category : (template.category.vi || template.category.en || ''); if (categoryText) { categories.add(categoryText); } } }); - + // Clear existing options except "new" templateCategorySelect.innerHTML = ''; - + // Add sorted categories const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1385,13 +1412,13 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + // Add "new category" option at the end const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + // Set default to first category or "User" if it exists if (sortedCategories.includes('User')) { templateCategorySelect.value = 'User'; @@ -1465,7 +1492,7 @@ document.addEventListener('DOMContentLoaded', () => { templatePreviewDropzone.addEventListener('click', (e) => { // Don't toggle if clicking on the input itself if (e.target === templatePreviewUrlInput) return; - + if (!isUrlInputMode) { // Switch to URL input mode isUrlInputMode = true; @@ -1520,7 +1547,7 @@ document.addEventListener('DOMContentLoaded', () => { } }); } - + templatePreviewDropzone.addEventListener('dragover', (e) => { e.preventDefault(); templatePreviewDropzone.classList.add('drag-over'); @@ -1534,7 +1561,7 @@ document.addEventListener('DOMContentLoaded', () => { templatePreviewDropzone.addEventListener('drop', (e) => { e.preventDefault(); templatePreviewDropzone.classList.remove('drag-over'); - + const files = e.dataTransfer.files; if (files.length > 0) { const file = files[0]; @@ -1559,7 +1586,7 @@ document.addEventListener('DOMContentLoaded', () => { const note = templateNoteInput.value.trim(); const mode = templateModeSelect.value; let category = templateCategorySelect.value; - + if (category === 'new') { category = templateCategoryInput.value.trim(); } @@ -1619,10 +1646,10 @@ document.addEventListener('DOMContentLoaded', () => { // Success createTemplateModal.classList.add('hidden'); - + // Reload template gallery await templateGallery.load(); - + // Reset editing state editingTemplate = null; editingTemplateSource = null; @@ -1666,7 +1693,7 @@ document.addEventListener('DOMContentLoaded', () => { loadGallery(); loadTemplateGallery(); initializeSidebarResizer(sidebar, resizeHandle); - + // Restore last image if available try { const lastImage = localStorage.getItem('gemini-app-last-image'); @@ -1676,13 +1703,13 @@ document.addEventListener('DOMContentLoaded', () => { } catch (e) { console.warn('Failed to restore last image', e); } - + // Setup canvas language toggle const canvasLangInput = document.getElementById('canvas-lang-input'); if (canvasLangInput) { // Set initial state canvasLangInput.checked = i18n.currentLang === 'en'; - + canvasLangInput.addEventListener('change', (e) => { i18n.setLanguage(e.target.checked ? 'en' : 'vi'); // Update visual state @@ -1753,7 +1780,7 @@ document.addEventListener('DOMContentLoaded', () => { if (!btn.classList.contains('history-favorites-btn')) { btn.addEventListener('click', () => { const filterType = btn.dataset.filter; - + // Remove active from all date filter buttons (not favorites) historyFilterBtns.forEach(b => { if (!b.classList.contains('history-favorites-btn')) { @@ -1834,7 +1861,7 @@ document.addEventListener('DOMContentLoaded', () => { hasGeneratedImage = true; // Mark that we have an image setViewState('result'); - + // Persist image URL try { localStorage.setItem('gemini-app-last-image', imageUrl); @@ -1864,7 +1891,7 @@ document.addEventListener('DOMContentLoaded', () => { promptInput.value = metadata.prompt; refreshPromptHighlight(); } - + // If metadata doesn't have 'note' field, set to empty string instead of keeping current value if (metadata.hasOwnProperty('note')) { promptNoteInput.value = metadata.note || ''; @@ -1872,14 +1899,14 @@ document.addEventListener('DOMContentLoaded', () => { promptNoteInput.value = ''; } refreshNoteHighlight(); - + 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(); } @@ -1968,9 +1995,9 @@ document.addEventListener('DOMContentLoaded', () => { const targetTag = event.target?.tagName; if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return; if (event.target?.isContentEditable) return; - + event.preventDefault(); - + // Toggle template gallery if (templateGalleryState.classList.contains('hidden')) { setViewState('template-gallery'); @@ -2140,9 +2167,9 @@ document.addEventListener('DOMContentLoaded', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); - + const data = await response.json(); - + if (!response.ok) { throw new Error(data.error || 'Failed to download image'); } @@ -2155,7 +2182,7 @@ document.addEventListener('DOMContentLoaded', () => { alert('Không còn slot trống cho ảnh tham chiếu.'); } } else { - throw new Error('No image path returned'); + throw new Error('No image path returned'); } } catch (error) { diff --git a/templates/index.html b/templates/index.html index f35facf..ff5134b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -46,18 +46,16 @@ - + - + @@ -67,9 +65,8 @@ - + @@ -132,18 +129,16 @@ - + - + @@ -153,9 +148,8 @@ - + @@ -173,7 +167,7 @@ - @@ -498,6 +492,15 @@ rel="noreferrer">aistudio.google.com/api-keys
TC^VB^}a^oP6955cp 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 DOtbu{ literal 0 HcmV?d00001 diff --git a/__pycache__/whisk_client.cpython-314.pyc b/__pycache__/whisk_client.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff802374bddb88df91b3dac18c4d7f9d87c7ade2 GIT binary patch literal 10934 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 literal 0 HcmV?d00001 diff --git a/app.py b/app.py index 18e0904..cfff405 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,7 @@ from google import genai from google.genai import types from PIL import Image, PngImagePlugin import threading, time, subprocess, re +import whisk_client import logging @@ -393,12 +394,15 @@ def generate_image(): if not prompt: return jsonify({'error': 'Prompt is required'}), 400 - if not api_key: - return jsonify({'error': 'API Key is required.'}), 401 + # Determine if this is a Whisk request + is_whisk = 'whisk' in model.lower() or 'imagefx' in model.lower() + + if not is_whisk and not api_key: + return jsonify({'error': 'API Key is required for Gemini models.'}), 401 try: print("Đang gửi lệnh...", flush=True) - client = genai.Client(api_key=api_key) + # client initialization moved to Gemini block image_config_args = {} @@ -514,6 +518,133 @@ def generate_image(): continue model_name = model + + # ================================================================================== + # WHISK (IMAGEFX) HANDLING + # ================================================================================== + if is_whisk: + print(f"Detected Whisk/ImageFX model request: {model_name}", flush=True) + + # Extract cookies from request headers or form data + # Priority: Form Data 'cookies' > Request Header 'x-whisk-cookies' > Environment Variable + cookie_str = request.form.get('cookies') or request.headers.get('x-whisk-cookies') or os.environ.get('WHISK_COOKIES') + + if not cookie_str: + return jsonify({'error': 'Whisk cookies are required. Please provide them in the "cookies" form field or configuration.'}), 400 + + print("Sending request to Whisk...", flush=True) + try: + # Check for reference images + reference_image_path = None + + # final_reference_paths (populated above) contains URLs/paths to reference images. + # Can be new uploads or history items. + if final_reference_paths: + # Use the first one + ref_url = final_reference_paths[0] + + # Convert URL/Path to absolute local path + # ref_url might be "http://.../static/..." or "/static/..." + if '/static/' in ref_url: + rel_path = ref_url.split('/static/')[1] + possible_path = os.path.join(app.static_folder, rel_path) + if os.path.exists(possible_path): + reference_image_path = possible_path + print(f"Whisk: Using reference image at {reference_image_path}", flush=True) + elif os.path.exists(ref_url): + # It's already a path? + reference_image_path = ref_url + + # Call the client + try: + whisk_result = whisk_client.generate_image_whisk( + prompt=api_prompt, + cookie_str=cookie_str, + aspect_ratio=aspect_ratio, + resolution=resolution, + reference_image_path=reference_image_path + ) + except Exception as e: + # Re-raise to be caught by the outer block + raise e + + # Process result - whisk_client returns raw bytes + image_bytes = None + if isinstance(whisk_result, bytes): + image_bytes = whisk_result + elif isinstance(whisk_result, dict): + # Fallback if I ever change the client to return dict + if 'image_data' in whisk_result: + image_bytes = whisk_result['image_data'] + elif 'image_url' in whisk_result: + import requests + img_resp = requests.get(whisk_result['image_url']) + image_bytes = img_resp.content + + if not image_bytes: + raise ValueError("No image data returned from Whisk.") + + # Save and process image (Reuse existing logic) + image = Image.open(BytesIO(image_bytes)) + png_info = PngImagePlugin.PngInfo() + + date_str = datetime.now().strftime("%Y%m%d") + search_pattern = os.path.join(GENERATED_DIR, f"whisk_{date_str}_*.png") + existing_files = glob.glob(search_pattern) + max_id = 0 + for f in existing_files: + try: + basename = os.path.basename(f) + name_without_ext = os.path.splitext(basename)[0] + id_part = name_without_ext.split('_')[-1] + id_num = int(id_part) + if id_num > max_id: + max_id = id_num + except ValueError: + continue + + next_id = max_id + 1 + filename = f"whisk_{date_str}_{next_id}.png" + filepath = os.path.join(GENERATED_DIR, filename) + rel_path = os.path.join('generated', filename) + image_url = url_for('static', filename=rel_path) + + metadata = { + 'prompt': prompt, + 'note': note, + 'processed_prompt': api_prompt, + 'aspect_ratio': aspect_ratio or 'Auto', + 'resolution': resolution, + 'reference_images': final_reference_paths, + 'model': 'whisk' + } + png_info.add_text('sdvn_meta', json.dumps(metadata)) + + buffer = BytesIO() + image.save(buffer, format='PNG', pnginfo=png_info) + final_bytes = buffer.getvalue() + + with open(filepath, 'wb') as f: + f.write(final_bytes) + + image_data = base64.b64encode(final_bytes).decode('utf-8') + return jsonify({ + 'image': image_url, + 'image_data': image_data, + 'metadata': metadata, + }) + + except Exception as e: + print(f"Whisk error: {e}") + return jsonify({'error': f"Whisk Generation Error: {str(e)}"}), 500 + + # ================================================================================== + # STANDARD GEMINI HANDLING + # ================================================================================== + + # Initialize Client here, since API Key is required + client = genai.Client(api_key=api_key) + print(f"Đang tạo với model {model_name}...", flush=True) response = client.models.generate_content( model=model_name, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ef166c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + app: + build: . + platform: linux/amd64 + ports: + - "8558:8888" + volumes: + - ./static:/app/static + - ./prompts.json:/app/prompts.json + - ./user_prompts.json:/app/user_prompts.json + - ./gallery_favorites.json:/app/gallery_favorites.json + environment: + - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} # Optional for Whisk + - WHISK_COOKIES=${WHISK_COOKIES:-} + restart: unless-stopped diff --git a/static/script.js b/static/script.js index 6cc8494..a8598cc 100644 --- a/static/script.js +++ b/static/script.js @@ -132,10 +132,28 @@ document.addEventListener('DOMContentLoaded', () => { if (apiModelSelect) { apiModelSelect.addEventListener('change', () => { toggleResolutionVisibility(); + toggleCookiesVisibility(); persistSettings(); }); } + const whiskCookiesGroup = document.getElementById('whisk-cookies-group'); + const whiskCookiesInput = document.getElementById('whisk-cookies'); + + function toggleCookiesVisibility() { + if (whiskCookiesGroup && apiModelSelect) { + if (apiModelSelect.value === 'whisk') { + whiskCookiesGroup.classList.remove('hidden'); + } else { + whiskCookiesGroup.classList.add('hidden'); + } + } + } + + if (whiskCookiesInput) { + whiskCookiesInput.addEventListener('input', persistSettings); + } + // Load Settings function loadSettings() { try { @@ -156,6 +174,10 @@ document.addEventListener('DOMContentLoaded', () => { if (bodyFontSelect && settings.bodyFont) { bodyFontSelect.value = settings.bodyFont; } + if (whiskCookiesInput && settings.whiskCookies) { + whiskCookiesInput.value = settings.whiskCookies; + } + toggleCookiesVisibility(); return settings; } } catch (e) { @@ -169,7 +191,7 @@ document.addEventListener('DOMContentLoaded', () => { const referenceImages = (typeof slotManager !== 'undefined' && typeof slotManager.serializeReferenceImages === 'function') ? slotManager.serializeReferenceImages() : []; - + const settings = { apiKey: apiKeyInput.value, prompt: promptInput.value, @@ -180,6 +202,7 @@ document.addEventListener('DOMContentLoaded', () => { referenceImages, theme: currentTheme || DEFAULT_THEME, bodyFont: bodyFontSelect ? bodyFontSelect.value : DEFAULT_BODY_FONT, + whiskCookies: whiskCookiesInput ? whiskCookiesInput.value : '', }; try { localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); @@ -199,12 +222,16 @@ document.addEventListener('DOMContentLoaded', () => { const selectedModel = model || (apiModelSelect ? apiModelSelect.value : 'gemini-3-pro-image-preview'); formData.append('model', selectedModel); + if (whiskCookiesInput && whiskCookiesInput.value) { + formData.append('cookies', whiskCookiesInput.value); + } + // Add reference images using correct slotManager methods const referenceFiles = slotManager.getReferenceFiles(); referenceFiles.forEach(file => { formData.append('reference_images', file); }); - + const referencePaths = slotManager.getReferencePaths(); if (referencePaths && referencePaths.length > 0) { formData.append('reference_image_paths', JSON.stringify(referencePaths)); @@ -592,14 +619,14 @@ document.addEventListener('DOMContentLoaded', () => { // 2. Item currently being processed (isProcessingQueue) // 3. Items waiting for backend response (pendingRequests) const count = generationQueue.length + (isProcessingQueue ? 1 : 0) + pendingRequests; - - console.log('Queue counter update:', { - queue: generationQueue.length, - processing: isProcessingQueue, + + console.log('Queue counter update:', { + queue: generationQueue.length, + processing: isProcessingQueue, pending: pendingRequests, - total: count + total: count }); - + if (count > 0) { if (queueCounter) { queueCounter.classList.remove('hidden'); @@ -623,10 +650,10 @@ document.addEventListener('DOMContentLoaded', () => { const task = generationQueue.shift(); isProcessingQueue = true; updateQueueCounter(); // Show counter immediately - + try { setViewState('loading'); - + // Check if this task already has a result (immediate generation) if (task.immediateResult) { // Display the already-generated image @@ -730,7 +757,7 @@ document.addEventListener('DOMContentLoaded', () => { }); const data = await response.json(); - + // Mark fetch as completed and decrement pending // We do this BEFORE adding to queue to avoid double counting fetchCompleted = true; @@ -785,12 +812,12 @@ document.addEventListener('DOMContentLoaded', () => { } } catch (error) { console.error('Error in addToQueue:', error); - + // If fetch failed (didn't complete), we need to decrement pendingRequests if (!fetchCompleted) { pendingRequests--; } - + updateQueueCounter(); showError(error.message); } @@ -816,7 +843,7 @@ document.addEventListener('DOMContentLoaded', () => { const response = await fetch(url); const blob = await response.blob(); const blobUrl = window.URL.createObjectURL(blob); - + const tempLink = document.createElement('a'); tempLink.href = blobUrl; tempLink.download = filename; @@ -834,7 +861,7 @@ document.addEventListener('DOMContentLoaded', () => { if (imageDisplayArea) { imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false }); imageDisplayArea.addEventListener('pointerdown', handleCanvasPointerDown); - + // Drag and drop support imageDisplayArea.addEventListener('dragover', (e) => { e.preventDefault(); @@ -849,7 +876,7 @@ document.addEventListener('DOMContentLoaded', () => { 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]; @@ -858,7 +885,7 @@ document.addEventListener('DOMContentLoaded', () => { // Display image immediately const objectUrl = URL.createObjectURL(file); displayImage(objectUrl); - + // Extract and apply metadata const metadata = await extractMetadataFromBlob(file); if (metadata) { @@ -965,7 +992,7 @@ document.addEventListener('DOMContentLoaded', () => { const createTemplateModal = document.getElementById('create-template-modal'); const closeTemplateModalBtn = document.getElementById('close-template-modal'); const saveTemplateBtn = document.getElementById('save-template-btn'); - + const templateTitleInput = document.getElementById('template-title'); const templatePromptInput = document.getElementById('template-prompt'); const templateNoteInput = document.getElementById('template-note'); @@ -1189,11 +1216,11 @@ document.addEventListener('DOMContentLoaded', () => { } // Global function for opening edit modal (called from templateGallery.js) - window.openEditTemplateModal = async function(template) { + window.openEditTemplateModal = async function (template) { editingTemplate = template; editingTemplateSource = template.isUserTemplate ? 'user' : 'builtin'; editingBuiltinIndex = editingTemplateSource === 'builtin' ? template.builtinTemplateIndex : null; - + // Pre-fill with template data templateTitleInput.value = template.title || ''; templatePromptInput.value = template.prompt || ''; @@ -1206,18 +1233,18 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { const categories = new Set(); data.prompts.forEach(t => { if (t.category) { - const categoryText = typeof t.category === 'string' - ? t.category + const categoryText = typeof t.category === 'string' + ? t.category : (t.category.vi || t.category.en || ''); if (categoryText) categories.add(categoryText); } }); - + templateCategorySelect.innerHTML = ''; const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1226,15 +1253,15 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + // Set to template's category - const templateCategory = typeof template.category === 'string' - ? template.category + const templateCategory = typeof template.category === 'string' + ? template.category : (template.category.vi || template.category.en || ''); templateCategorySelect.value = templateCategory || 'User'; } @@ -1263,16 +1290,16 @@ document.addEventListener('DOMContentLoaded', () => { // Update button text saveTemplateBtn.innerHTML = 'Update Template'; - + createTemplateModal.classList.remove('hidden'); }; // Global function for opening create modal with empty values (called from templateGallery.js) - window.openCreateTemplateModal = async function() { + window.openCreateTemplateModal = async function () { editingTemplate = null; editingTemplateSource = 'user'; editingBuiltinIndex = null; - + setTemplateTags([]); if (templateTagInput) { templateTagInput.value = ''; @@ -1290,18 +1317,18 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { const categories = new Set(); data.prompts.forEach(t => { if (t.category) { - const categoryText = typeof t.category === 'string' - ? t.category + const categoryText = typeof t.category === 'string' + ? t.category : (t.category.vi || t.category.en || ''); if (categoryText) categories.add(categoryText); } }); - + templateCategorySelect.innerHTML = ''; const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1310,12 +1337,12 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + if (sortedCategories.includes('User')) { templateCategorySelect.value = 'User'; } else if (sortedCategories.length > 0) { @@ -1335,7 +1362,7 @@ document.addEventListener('DOMContentLoaded', () => { // Update button text saveTemplateBtn.innerHTML = 'Save Template'; - + createTemplateModal.classList.remove('hidden'); }; @@ -1345,7 +1372,7 @@ document.addEventListener('DOMContentLoaded', () => { editingTemplate = null; editingTemplateSource = 'user'; editingBuiltinIndex = null; - + // Pre-fill data templateTitleInput.value = ''; templatePromptInput.value = promptInput.value; @@ -1358,25 +1385,25 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { // Extract unique categories const categories = new Set(); data.prompts.forEach(template => { if (template.category) { // Handle both string and object categories - const categoryText = typeof template.category === 'string' - ? template.category + const categoryText = typeof template.category === 'string' + ? template.category : (template.category.vi || template.category.en || ''); if (categoryText) { categories.add(categoryText); } } }); - + // Clear existing options except "new" templateCategorySelect.innerHTML = ''; - + // Add sorted categories const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1385,13 +1412,13 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + // Add "new category" option at the end const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + // Set default to first category or "User" if it exists if (sortedCategories.includes('User')) { templateCategorySelect.value = 'User'; @@ -1465,7 +1492,7 @@ document.addEventListener('DOMContentLoaded', () => { templatePreviewDropzone.addEventListener('click', (e) => { // Don't toggle if clicking on the input itself if (e.target === templatePreviewUrlInput) return; - + if (!isUrlInputMode) { // Switch to URL input mode isUrlInputMode = true; @@ -1520,7 +1547,7 @@ document.addEventListener('DOMContentLoaded', () => { } }); } - + templatePreviewDropzone.addEventListener('dragover', (e) => { e.preventDefault(); templatePreviewDropzone.classList.add('drag-over'); @@ -1534,7 +1561,7 @@ document.addEventListener('DOMContentLoaded', () => { templatePreviewDropzone.addEventListener('drop', (e) => { e.preventDefault(); templatePreviewDropzone.classList.remove('drag-over'); - + const files = e.dataTransfer.files; if (files.length > 0) { const file = files[0]; @@ -1559,7 +1586,7 @@ document.addEventListener('DOMContentLoaded', () => { const note = templateNoteInput.value.trim(); const mode = templateModeSelect.value; let category = templateCategorySelect.value; - + if (category === 'new') { category = templateCategoryInput.value.trim(); } @@ -1619,10 +1646,10 @@ document.addEventListener('DOMContentLoaded', () => { // Success createTemplateModal.classList.add('hidden'); - + // Reload template gallery await templateGallery.load(); - + // Reset editing state editingTemplate = null; editingTemplateSource = null; @@ -1666,7 +1693,7 @@ document.addEventListener('DOMContentLoaded', () => { loadGallery(); loadTemplateGallery(); initializeSidebarResizer(sidebar, resizeHandle); - + // Restore last image if available try { const lastImage = localStorage.getItem('gemini-app-last-image'); @@ -1676,13 +1703,13 @@ document.addEventListener('DOMContentLoaded', () => { } catch (e) { console.warn('Failed to restore last image', e); } - + // Setup canvas language toggle const canvasLangInput = document.getElementById('canvas-lang-input'); if (canvasLangInput) { // Set initial state canvasLangInput.checked = i18n.currentLang === 'en'; - + canvasLangInput.addEventListener('change', (e) => { i18n.setLanguage(e.target.checked ? 'en' : 'vi'); // Update visual state @@ -1753,7 +1780,7 @@ document.addEventListener('DOMContentLoaded', () => { if (!btn.classList.contains('history-favorites-btn')) { btn.addEventListener('click', () => { const filterType = btn.dataset.filter; - + // Remove active from all date filter buttons (not favorites) historyFilterBtns.forEach(b => { if (!b.classList.contains('history-favorites-btn')) { @@ -1834,7 +1861,7 @@ document.addEventListener('DOMContentLoaded', () => { hasGeneratedImage = true; // Mark that we have an image setViewState('result'); - + // Persist image URL try { localStorage.setItem('gemini-app-last-image', imageUrl); @@ -1864,7 +1891,7 @@ document.addEventListener('DOMContentLoaded', () => { promptInput.value = metadata.prompt; refreshPromptHighlight(); } - + // If metadata doesn't have 'note' field, set to empty string instead of keeping current value if (metadata.hasOwnProperty('note')) { promptNoteInput.value = metadata.note || ''; @@ -1872,14 +1899,14 @@ document.addEventListener('DOMContentLoaded', () => { promptNoteInput.value = ''; } refreshNoteHighlight(); - + 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(); } @@ -1968,9 +1995,9 @@ document.addEventListener('DOMContentLoaded', () => { const targetTag = event.target?.tagName; if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return; if (event.target?.isContentEditable) return; - + event.preventDefault(); - + // Toggle template gallery if (templateGalleryState.classList.contains('hidden')) { setViewState('template-gallery'); @@ -2140,9 +2167,9 @@ document.addEventListener('DOMContentLoaded', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); - + const data = await response.json(); - + if (!response.ok) { throw new Error(data.error || 'Failed to download image'); } @@ -2155,7 +2182,7 @@ document.addEventListener('DOMContentLoaded', () => { alert('Không còn slot trống cho ảnh tham chiếu.'); } } else { - throw new Error('No image path returned'); + throw new Error('No image path returned'); } } catch (error) { diff --git a/templates/index.html b/templates/index.html index f35facf..ff5134b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -46,18 +46,16 @@ - + - + @@ -67,9 +65,8 @@ - + @@ -132,18 +129,16 @@ - + - + @@ -153,9 +148,8 @@ - + @@ -173,7 +167,7 @@ - @@ -498,6 +492,15 @@ rel="noreferrer">aistudio.google.com/api-keys
jWMw}43q|g91jQ#2SYs1MlU6Kh6@H+ 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 literal 0 HcmV?d00001 diff --git a/app.py b/app.py index 18e0904..cfff405 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,7 @@ from google import genai from google.genai import types from PIL import Image, PngImagePlugin import threading, time, subprocess, re +import whisk_client import logging @@ -393,12 +394,15 @@ def generate_image(): if not prompt: return jsonify({'error': 'Prompt is required'}), 400 - if not api_key: - return jsonify({'error': 'API Key is required.'}), 401 + # Determine if this is a Whisk request + is_whisk = 'whisk' in model.lower() or 'imagefx' in model.lower() + + if not is_whisk and not api_key: + return jsonify({'error': 'API Key is required for Gemini models.'}), 401 try: print("Đang gửi lệnh...", flush=True) - client = genai.Client(api_key=api_key) + # client initialization moved to Gemini block image_config_args = {} @@ -514,6 +518,133 @@ def generate_image(): continue model_name = model + + # ================================================================================== + # WHISK (IMAGEFX) HANDLING + # ================================================================================== + if is_whisk: + print(f"Detected Whisk/ImageFX model request: {model_name}", flush=True) + + # Extract cookies from request headers or form data + # Priority: Form Data 'cookies' > Request Header 'x-whisk-cookies' > Environment Variable + cookie_str = request.form.get('cookies') or request.headers.get('x-whisk-cookies') or os.environ.get('WHISK_COOKIES') + + if not cookie_str: + return jsonify({'error': 'Whisk cookies are required. Please provide them in the "cookies" form field or configuration.'}), 400 + + print("Sending request to Whisk...", flush=True) + try: + # Check for reference images + reference_image_path = None + + # final_reference_paths (populated above) contains URLs/paths to reference images. + # Can be new uploads or history items. + if final_reference_paths: + # Use the first one + ref_url = final_reference_paths[0] + + # Convert URL/Path to absolute local path + # ref_url might be "http://.../static/..." or "/static/..." + if '/static/' in ref_url: + rel_path = ref_url.split('/static/')[1] + possible_path = os.path.join(app.static_folder, rel_path) + if os.path.exists(possible_path): + reference_image_path = possible_path + print(f"Whisk: Using reference image at {reference_image_path}", flush=True) + elif os.path.exists(ref_url): + # It's already a path? + reference_image_path = ref_url + + # Call the client + try: + whisk_result = whisk_client.generate_image_whisk( + prompt=api_prompt, + cookie_str=cookie_str, + aspect_ratio=aspect_ratio, + resolution=resolution, + reference_image_path=reference_image_path + ) + except Exception as e: + # Re-raise to be caught by the outer block + raise e + + # Process result - whisk_client returns raw bytes + image_bytes = None + if isinstance(whisk_result, bytes): + image_bytes = whisk_result + elif isinstance(whisk_result, dict): + # Fallback if I ever change the client to return dict + if 'image_data' in whisk_result: + image_bytes = whisk_result['image_data'] + elif 'image_url' in whisk_result: + import requests + img_resp = requests.get(whisk_result['image_url']) + image_bytes = img_resp.content + + if not image_bytes: + raise ValueError("No image data returned from Whisk.") + + # Save and process image (Reuse existing logic) + image = Image.open(BytesIO(image_bytes)) + png_info = PngImagePlugin.PngInfo() + + date_str = datetime.now().strftime("%Y%m%d") + search_pattern = os.path.join(GENERATED_DIR, f"whisk_{date_str}_*.png") + existing_files = glob.glob(search_pattern) + max_id = 0 + for f in existing_files: + try: + basename = os.path.basename(f) + name_without_ext = os.path.splitext(basename)[0] + id_part = name_without_ext.split('_')[-1] + id_num = int(id_part) + if id_num > max_id: + max_id = id_num + except ValueError: + continue + + next_id = max_id + 1 + filename = f"whisk_{date_str}_{next_id}.png" + filepath = os.path.join(GENERATED_DIR, filename) + rel_path = os.path.join('generated', filename) + image_url = url_for('static', filename=rel_path) + + metadata = { + 'prompt': prompt, + 'note': note, + 'processed_prompt': api_prompt, + 'aspect_ratio': aspect_ratio or 'Auto', + 'resolution': resolution, + 'reference_images': final_reference_paths, + 'model': 'whisk' + } + png_info.add_text('sdvn_meta', json.dumps(metadata)) + + buffer = BytesIO() + image.save(buffer, format='PNG', pnginfo=png_info) + final_bytes = buffer.getvalue() + + with open(filepath, 'wb') as f: + f.write(final_bytes) + + image_data = base64.b64encode(final_bytes).decode('utf-8') + return jsonify({ + 'image': image_url, + 'image_data': image_data, + 'metadata': metadata, + }) + + except Exception as e: + print(f"Whisk error: {e}") + return jsonify({'error': f"Whisk Generation Error: {str(e)}"}), 500 + + # ================================================================================== + # STANDARD GEMINI HANDLING + # ================================================================================== + + # Initialize Client here, since API Key is required + client = genai.Client(api_key=api_key) + print(f"Đang tạo với model {model_name}...", flush=True) response = client.models.generate_content( model=model_name, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ef166c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + app: + build: . + platform: linux/amd64 + ports: + - "8558:8888" + volumes: + - ./static:/app/static + - ./prompts.json:/app/prompts.json + - ./user_prompts.json:/app/user_prompts.json + - ./gallery_favorites.json:/app/gallery_favorites.json + environment: + - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} # Optional for Whisk + - WHISK_COOKIES=${WHISK_COOKIES:-} + restart: unless-stopped diff --git a/static/script.js b/static/script.js index 6cc8494..a8598cc 100644 --- a/static/script.js +++ b/static/script.js @@ -132,10 +132,28 @@ document.addEventListener('DOMContentLoaded', () => { if (apiModelSelect) { apiModelSelect.addEventListener('change', () => { toggleResolutionVisibility(); + toggleCookiesVisibility(); persistSettings(); }); } + const whiskCookiesGroup = document.getElementById('whisk-cookies-group'); + const whiskCookiesInput = document.getElementById('whisk-cookies'); + + function toggleCookiesVisibility() { + if (whiskCookiesGroup && apiModelSelect) { + if (apiModelSelect.value === 'whisk') { + whiskCookiesGroup.classList.remove('hidden'); + } else { + whiskCookiesGroup.classList.add('hidden'); + } + } + } + + if (whiskCookiesInput) { + whiskCookiesInput.addEventListener('input', persistSettings); + } + // Load Settings function loadSettings() { try { @@ -156,6 +174,10 @@ document.addEventListener('DOMContentLoaded', () => { if (bodyFontSelect && settings.bodyFont) { bodyFontSelect.value = settings.bodyFont; } + if (whiskCookiesInput && settings.whiskCookies) { + whiskCookiesInput.value = settings.whiskCookies; + } + toggleCookiesVisibility(); return settings; } } catch (e) { @@ -169,7 +191,7 @@ document.addEventListener('DOMContentLoaded', () => { const referenceImages = (typeof slotManager !== 'undefined' && typeof slotManager.serializeReferenceImages === 'function') ? slotManager.serializeReferenceImages() : []; - + const settings = { apiKey: apiKeyInput.value, prompt: promptInput.value, @@ -180,6 +202,7 @@ document.addEventListener('DOMContentLoaded', () => { referenceImages, theme: currentTheme || DEFAULT_THEME, bodyFont: bodyFontSelect ? bodyFontSelect.value : DEFAULT_BODY_FONT, + whiskCookies: whiskCookiesInput ? whiskCookiesInput.value : '', }; try { localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); @@ -199,12 +222,16 @@ document.addEventListener('DOMContentLoaded', () => { const selectedModel = model || (apiModelSelect ? apiModelSelect.value : 'gemini-3-pro-image-preview'); formData.append('model', selectedModel); + if (whiskCookiesInput && whiskCookiesInput.value) { + formData.append('cookies', whiskCookiesInput.value); + } + // Add reference images using correct slotManager methods const referenceFiles = slotManager.getReferenceFiles(); referenceFiles.forEach(file => { formData.append('reference_images', file); }); - + const referencePaths = slotManager.getReferencePaths(); if (referencePaths && referencePaths.length > 0) { formData.append('reference_image_paths', JSON.stringify(referencePaths)); @@ -592,14 +619,14 @@ document.addEventListener('DOMContentLoaded', () => { // 2. Item currently being processed (isProcessingQueue) // 3. Items waiting for backend response (pendingRequests) const count = generationQueue.length + (isProcessingQueue ? 1 : 0) + pendingRequests; - - console.log('Queue counter update:', { - queue: generationQueue.length, - processing: isProcessingQueue, + + console.log('Queue counter update:', { + queue: generationQueue.length, + processing: isProcessingQueue, pending: pendingRequests, - total: count + total: count }); - + if (count > 0) { if (queueCounter) { queueCounter.classList.remove('hidden'); @@ -623,10 +650,10 @@ document.addEventListener('DOMContentLoaded', () => { const task = generationQueue.shift(); isProcessingQueue = true; updateQueueCounter(); // Show counter immediately - + try { setViewState('loading'); - + // Check if this task already has a result (immediate generation) if (task.immediateResult) { // Display the already-generated image @@ -730,7 +757,7 @@ document.addEventListener('DOMContentLoaded', () => { }); const data = await response.json(); - + // Mark fetch as completed and decrement pending // We do this BEFORE adding to queue to avoid double counting fetchCompleted = true; @@ -785,12 +812,12 @@ document.addEventListener('DOMContentLoaded', () => { } } catch (error) { console.error('Error in addToQueue:', error); - + // If fetch failed (didn't complete), we need to decrement pendingRequests if (!fetchCompleted) { pendingRequests--; } - + updateQueueCounter(); showError(error.message); } @@ -816,7 +843,7 @@ document.addEventListener('DOMContentLoaded', () => { const response = await fetch(url); const blob = await response.blob(); const blobUrl = window.URL.createObjectURL(blob); - + const tempLink = document.createElement('a'); tempLink.href = blobUrl; tempLink.download = filename; @@ -834,7 +861,7 @@ document.addEventListener('DOMContentLoaded', () => { if (imageDisplayArea) { imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false }); imageDisplayArea.addEventListener('pointerdown', handleCanvasPointerDown); - + // Drag and drop support imageDisplayArea.addEventListener('dragover', (e) => { e.preventDefault(); @@ -849,7 +876,7 @@ document.addEventListener('DOMContentLoaded', () => { 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]; @@ -858,7 +885,7 @@ document.addEventListener('DOMContentLoaded', () => { // Display image immediately const objectUrl = URL.createObjectURL(file); displayImage(objectUrl); - + // Extract and apply metadata const metadata = await extractMetadataFromBlob(file); if (metadata) { @@ -965,7 +992,7 @@ document.addEventListener('DOMContentLoaded', () => { const createTemplateModal = document.getElementById('create-template-modal'); const closeTemplateModalBtn = document.getElementById('close-template-modal'); const saveTemplateBtn = document.getElementById('save-template-btn'); - + const templateTitleInput = document.getElementById('template-title'); const templatePromptInput = document.getElementById('template-prompt'); const templateNoteInput = document.getElementById('template-note'); @@ -1189,11 +1216,11 @@ document.addEventListener('DOMContentLoaded', () => { } // Global function for opening edit modal (called from templateGallery.js) - window.openEditTemplateModal = async function(template) { + window.openEditTemplateModal = async function (template) { editingTemplate = template; editingTemplateSource = template.isUserTemplate ? 'user' : 'builtin'; editingBuiltinIndex = editingTemplateSource === 'builtin' ? template.builtinTemplateIndex : null; - + // Pre-fill with template data templateTitleInput.value = template.title || ''; templatePromptInput.value = template.prompt || ''; @@ -1206,18 +1233,18 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { const categories = new Set(); data.prompts.forEach(t => { if (t.category) { - const categoryText = typeof t.category === 'string' - ? t.category + const categoryText = typeof t.category === 'string' + ? t.category : (t.category.vi || t.category.en || ''); if (categoryText) categories.add(categoryText); } }); - + templateCategorySelect.innerHTML = ''; const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1226,15 +1253,15 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + // Set to template's category - const templateCategory = typeof template.category === 'string' - ? template.category + const templateCategory = typeof template.category === 'string' + ? template.category : (template.category.vi || template.category.en || ''); templateCategorySelect.value = templateCategory || 'User'; } @@ -1263,16 +1290,16 @@ document.addEventListener('DOMContentLoaded', () => { // Update button text saveTemplateBtn.innerHTML = 'Update Template'; - + createTemplateModal.classList.remove('hidden'); }; // Global function for opening create modal with empty values (called from templateGallery.js) - window.openCreateTemplateModal = async function() { + window.openCreateTemplateModal = async function () { editingTemplate = null; editingTemplateSource = 'user'; editingBuiltinIndex = null; - + setTemplateTags([]); if (templateTagInput) { templateTagInput.value = ''; @@ -1290,18 +1317,18 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { const categories = new Set(); data.prompts.forEach(t => { if (t.category) { - const categoryText = typeof t.category === 'string' - ? t.category + const categoryText = typeof t.category === 'string' + ? t.category : (t.category.vi || t.category.en || ''); if (categoryText) categories.add(categoryText); } }); - + templateCategorySelect.innerHTML = ''; const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1310,12 +1337,12 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + if (sortedCategories.includes('User')) { templateCategorySelect.value = 'User'; } else if (sortedCategories.length > 0) { @@ -1335,7 +1362,7 @@ document.addEventListener('DOMContentLoaded', () => { // Update button text saveTemplateBtn.innerHTML = 'Save Template'; - + createTemplateModal.classList.remove('hidden'); }; @@ -1345,7 +1372,7 @@ document.addEventListener('DOMContentLoaded', () => { editingTemplate = null; editingTemplateSource = 'user'; editingBuiltinIndex = null; - + // Pre-fill data templateTitleInput.value = ''; templatePromptInput.value = promptInput.value; @@ -1358,25 +1385,25 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch('/prompts'); const data = await response.json(); - + if (data.prompts) { // Extract unique categories const categories = new Set(); data.prompts.forEach(template => { if (template.category) { // Handle both string and object categories - const categoryText = typeof template.category === 'string' - ? template.category + const categoryText = typeof template.category === 'string' + ? template.category : (template.category.vi || template.category.en || ''); if (categoryText) { categories.add(categoryText); } } }); - + // Clear existing options except "new" templateCategorySelect.innerHTML = ''; - + // Add sorted categories const sortedCategories = Array.from(categories).sort(); sortedCategories.forEach(cat => { @@ -1385,13 +1412,13 @@ document.addEventListener('DOMContentLoaded', () => { option.textContent = cat; templateCategorySelect.appendChild(option); }); - + // Add "new category" option at the end const newOption = document.createElement('option'); newOption.value = 'new'; newOption.textContent = '+ New Category'; templateCategorySelect.appendChild(newOption); - + // Set default to first category or "User" if it exists if (sortedCategories.includes('User')) { templateCategorySelect.value = 'User'; @@ -1465,7 +1492,7 @@ document.addEventListener('DOMContentLoaded', () => { templatePreviewDropzone.addEventListener('click', (e) => { // Don't toggle if clicking on the input itself if (e.target === templatePreviewUrlInput) return; - + if (!isUrlInputMode) { // Switch to URL input mode isUrlInputMode = true; @@ -1520,7 +1547,7 @@ document.addEventListener('DOMContentLoaded', () => { } }); } - + templatePreviewDropzone.addEventListener('dragover', (e) => { e.preventDefault(); templatePreviewDropzone.classList.add('drag-over'); @@ -1534,7 +1561,7 @@ document.addEventListener('DOMContentLoaded', () => { templatePreviewDropzone.addEventListener('drop', (e) => { e.preventDefault(); templatePreviewDropzone.classList.remove('drag-over'); - + const files = e.dataTransfer.files; if (files.length > 0) { const file = files[0]; @@ -1559,7 +1586,7 @@ document.addEventListener('DOMContentLoaded', () => { const note = templateNoteInput.value.trim(); const mode = templateModeSelect.value; let category = templateCategorySelect.value; - + if (category === 'new') { category = templateCategoryInput.value.trim(); } @@ -1619,10 +1646,10 @@ document.addEventListener('DOMContentLoaded', () => { // Success createTemplateModal.classList.add('hidden'); - + // Reload template gallery await templateGallery.load(); - + // Reset editing state editingTemplate = null; editingTemplateSource = null; @@ -1666,7 +1693,7 @@ document.addEventListener('DOMContentLoaded', () => { loadGallery(); loadTemplateGallery(); initializeSidebarResizer(sidebar, resizeHandle); - + // Restore last image if available try { const lastImage = localStorage.getItem('gemini-app-last-image'); @@ -1676,13 +1703,13 @@ document.addEventListener('DOMContentLoaded', () => { } catch (e) { console.warn('Failed to restore last image', e); } - + // Setup canvas language toggle const canvasLangInput = document.getElementById('canvas-lang-input'); if (canvasLangInput) { // Set initial state canvasLangInput.checked = i18n.currentLang === 'en'; - + canvasLangInput.addEventListener('change', (e) => { i18n.setLanguage(e.target.checked ? 'en' : 'vi'); // Update visual state @@ -1753,7 +1780,7 @@ document.addEventListener('DOMContentLoaded', () => { if (!btn.classList.contains('history-favorites-btn')) { btn.addEventListener('click', () => { const filterType = btn.dataset.filter; - + // Remove active from all date filter buttons (not favorites) historyFilterBtns.forEach(b => { if (!b.classList.contains('history-favorites-btn')) { @@ -1834,7 +1861,7 @@ document.addEventListener('DOMContentLoaded', () => { hasGeneratedImage = true; // Mark that we have an image setViewState('result'); - + // Persist image URL try { localStorage.setItem('gemini-app-last-image', imageUrl); @@ -1864,7 +1891,7 @@ document.addEventListener('DOMContentLoaded', () => { promptInput.value = metadata.prompt; refreshPromptHighlight(); } - + // If metadata doesn't have 'note' field, set to empty string instead of keeping current value if (metadata.hasOwnProperty('note')) { promptNoteInput.value = metadata.note || ''; @@ -1872,14 +1899,14 @@ document.addEventListener('DOMContentLoaded', () => { promptNoteInput.value = ''; } refreshNoteHighlight(); - + 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(); } @@ -1968,9 +1995,9 @@ document.addEventListener('DOMContentLoaded', () => { const targetTag = event.target?.tagName; if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return; if (event.target?.isContentEditable) return; - + event.preventDefault(); - + // Toggle template gallery if (templateGalleryState.classList.contains('hidden')) { setViewState('template-gallery'); @@ -2140,9 +2167,9 @@ document.addEventListener('DOMContentLoaded', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); - + const data = await response.json(); - + if (!response.ok) { throw new Error(data.error || 'Failed to download image'); } @@ -2155,7 +2182,7 @@ document.addEventListener('DOMContentLoaded', () => { alert('Không còn slot trống cho ảnh tham chiếu.'); } } else { - throw new Error('No image path returned'); + throw new Error('No image path returned'); } } catch (error) { diff --git a/templates/index.html b/templates/index.html index f35facf..ff5134b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -46,18 +46,16 @@ - + - + @@ -67,9 +65,8 @@ - + @@ -132,18 +129,16 @@ - + - + @@ -153,9 +148,8 @@ - + @@ -173,7 +167,7 @@ - @@ -498,6 +492,15 @@ rel="noreferrer">aistudio.google.com/api-keys
+ F12 trên labs.google > Network > Request bất kỳ > Copy Request Headers > Cookie. +