|
|
import os |
|
|
import io |
|
|
import uuid |
|
|
import tempfile |
|
|
from pathlib import Path |
|
|
from flask import Flask, request, jsonify, send_from_directory, render_template_string, abort |
|
|
from PIL import Image, UnidentifiedImageError |
|
|
from google import genai |
|
|
from google.genai import types |
|
|
from google.api_core import exceptions as google_exceptions |
|
|
|
|
|
app = Flask(__name__) |
|
|
app.config['UPLOAD_FOLDER'] = 'output_gemini' |
|
|
Path(app.config['UPLOAD_FOLDER']).mkdir(exist_ok=True) |
|
|
|
|
|
|
|
|
def save_binary_file(file_name, data): |
|
|
with open(file_name, "wb") as f: |
|
|
f.write(data) |
|
|
return file_name |
|
|
|
|
|
def process_image_with_gemini(client, image, instruction, request_id) -> tuple[str | None, str, str]: |
|
|
request_folder = Path(app.config['UPLOAD_FOLDER']) / request_id |
|
|
request_folder.mkdir(exist_ok=True) |
|
|
|
|
|
input_image_path = request_folder / "input.jpg" |
|
|
try: |
|
|
image.save(input_image_path, format='JPEG') |
|
|
except Exception as e: |
|
|
return None, "", f"Ошибка сохранения входного изображения: {e}" |
|
|
|
|
|
try: |
|
|
with tempfile.TemporaryDirectory() as temp_dir: |
|
|
temp_image_path = Path(temp_dir) / "temp_input_image.jpg" |
|
|
if image.mode != 'RGB': |
|
|
image = image.convert('RGB') |
|
|
image.save(temp_image_path, format='JPEG') |
|
|
|
|
|
|
|
|
files = [ |
|
|
client.files.upload(file=str(temp_image_path)), |
|
|
] |
|
|
|
|
|
model = "gemini-2.0-flash-exp-image-generation" |
|
|
contents = [ |
|
|
types.Content( |
|
|
role="user", |
|
|
parts=[ |
|
|
types.Part.from_uri( |
|
|
file_uri=files[0].uri, |
|
|
mime_type="image/jpeg", |
|
|
), |
|
|
types.Part.from_text(text=instruction), |
|
|
], |
|
|
), |
|
|
] |
|
|
|
|
|
safety_settings = [ |
|
|
types.SafetySetting(category=cat, threshold="BLOCK_NONE") |
|
|
for cat in [ |
|
|
"HARM_CATEGORY_HARASSMENT", |
|
|
"HARM_CATEGORY_HATE_SPEECH", |
|
|
"HARM_CATEGORY_SEXUALLY_EXPLICIT", |
|
|
"HARM_CATEGORY_DANGEROUS_CONTENT", |
|
|
"HARM_CATEGORY_CIVIC_INTEGRITY", |
|
|
] |
|
|
] |
|
|
|
|
|
generate_content_config = types.GenerateContentConfig( |
|
|
temperature=1, |
|
|
top_p=0.95, |
|
|
top_k=40, |
|
|
max_output_tokens=8192, |
|
|
response_modalities=["image", "text"], |
|
|
safety_settings=safety_settings, |
|
|
response_mime_type="text/plain", |
|
|
) |
|
|
|
|
|
response_text = "" |
|
|
edited_image_filename = None |
|
|
edited_image_saved = False |
|
|
last_chunk = None |
|
|
|
|
|
for chunk in client.models.generate_content_stream( |
|
|
model=model, |
|
|
contents=contents, |
|
|
config=generate_content_config, |
|
|
): |
|
|
last_chunk = chunk |
|
|
if ( |
|
|
not chunk.candidates |
|
|
or not chunk.candidates[0].content |
|
|
or not chunk.candidates[0].content.parts |
|
|
): |
|
|
if chunk and hasattr(chunk, 'prompt_feedback') and chunk.prompt_feedback and chunk.prompt_feedback.block_reason: |
|
|
block_reason_str = chunk.prompt_feedback.block_reason.name |
|
|
safety_ratings_info = "Нет данных" |
|
|
if chunk.prompt_feedback.safety_ratings: |
|
|
safety_ratings_info = ", ".join([f"{r.category.name}: {r.probability.name}" for r in chunk.prompt_feedback.safety_ratings]) |
|
|
return None, "", f"Запрос заблокирован системой безопасности (внутри потока): {block_reason_str}. Рейтинги безопасности: {safety_ratings_info}" |
|
|
elif chunk and chunk.candidates and chunk.candidates[0].finish_reason.name != 'STOP': |
|
|
finish_reason_str = chunk.candidates[0].finish_reason.name |
|
|
safety_ratings_info = "Нет данных" |
|
|
if chunk.candidates[0].safety_ratings: |
|
|
safety_ratings_info = ", ".join([f"{r.category.name}: {r.probability.name}" for r in chunk.candidates[0].safety_ratings]) |
|
|
return None, "", f"Генерация остановлена преждевременно (внутри потока) по причине: {finish_reason_str}. Рейтинги безопасности: {safety_ratings_info}" |
|
|
continue |
|
|
|
|
|
|
|
|
part = chunk.candidates[0].content.parts[0] |
|
|
|
|
|
if hasattr(part, "inline_data") and part.inline_data and part.inline_data.data: |
|
|
if not edited_image_saved: |
|
|
edited_image_filename = "edited.jpg" |
|
|
edited_image_path = request_folder / edited_image_filename |
|
|
try: |
|
|
save_binary_file( |
|
|
str(edited_image_path), |
|
|
part.inline_data.data, |
|
|
) |
|
|
edited_image_saved = True |
|
|
except Exception as save_err: |
|
|
print(f"Error saving generated image: {save_err}") |
|
|
edited_image_filename = None |
|
|
edited_image_saved = False |
|
|
response_text += f"\n[Ошибка сохранения сгенерированного изображения: {save_err}]" |
|
|
elif hasattr(part, "text"): |
|
|
response_text += part.text |
|
|
|
|
|
try: |
|
|
for f in files: |
|
|
if hasattr(f, 'name') and f.name: |
|
|
client.files.delete(file=f.name) |
|
|
else: |
|
|
print(f"Warning: Could not get file name to delete from Gemini upload response: {f}") |
|
|
except Exception as cleanup_err: |
|
|
print(f"Warning: Could not delete temporary file from Gemini: {cleanup_err}") |
|
|
|
|
|
|
|
|
if edited_image_filename and edited_image_saved: |
|
|
output_image_url = f"/output/{request_id}/{edited_image_filename}" |
|
|
return output_image_url, response_text or "", "Успех" |
|
|
elif response_text: |
|
|
return None, response_text, "Изображение не сгенерировано, получен только текст." |
|
|
else: |
|
|
if last_chunk: |
|
|
if hasattr(last_chunk, 'prompt_feedback') and last_chunk.prompt_feedback and last_chunk.prompt_feedback.block_reason: |
|
|
block_reason_str = last_chunk.prompt_feedback.block_reason.name |
|
|
safety_ratings_info = "Нет данных" |
|
|
if last_chunk.prompt_feedback.safety_ratings: |
|
|
safety_ratings_info = ", ".join([f"{r.category.name}: {r.probability.name}" for r in last_chunk.prompt_feedback.safety_ratings]) |
|
|
return None, "", f"Запрос заблокирован: {block_reason_str}. Рейтинги безопасности: {safety_ratings_info}" |
|
|
elif last_chunk.candidates and last_chunk.candidates[0].finish_reason.name != 'STOP': |
|
|
finish_reason_str = last_chunk.candidates[0].finish_reason.name |
|
|
safety_ratings_info = "Нет данных" |
|
|
if last_chunk.candidates[0].safety_ratings: |
|
|
safety_ratings_info = ", ".join([f"{r.category.name}: {r.probability.name}" for r in last_chunk.candidates[0].safety_ratings]) |
|
|
return None, "", f"Генерация остановлена по причине: {finish_reason_str}. Рейтинги безопасности: {safety_ratings_info}" |
|
|
return None, "", "Не удалось сгенерировать изображение или текст." |
|
|
|
|
|
except google_exceptions.PermissionDenied as pd: |
|
|
print(f"PermissionDenied: {pd}") |
|
|
return None, "", f"Ошибка доступа к API Google. Убедитесь, что API-ключ имеет необходимые разрешения. Подробности: {pd}" |
|
|
except google_exceptions.ResourceExhausted as re: |
|
|
print(f"ResourceExhausted: {re}") |
|
|
return None, "", "Превышен лимит запросов к Google API. Пожалуйста, попробуйте позже." |
|
|
except google_exceptions.BlockedPrompt as bpe: |
|
|
print(f"BlockedPromptException (from google.api_core): {bpe}") |
|
|
return None, "", f"Запрос был заблокирован системой безопасности (BlockedPrompt): {bpe}" |
|
|
except google_exceptions.InvalidArgument as ia: |
|
|
print(f"InvalidArgument: {ia}") |
|
|
return None, "", f"Неверный аргумент запроса к Google API: {ia}. Проверьте инструкцию или формат изображения." |
|
|
except google_exceptions.GoogleAPIError as gae: |
|
|
print(f"GoogleAPIError: {gae}") |
|
|
message = f"Ошибка Google API: {gae}" |
|
|
if gae.status_code == 429: |
|
|
message = "Превышен лимит запросов. Пожалуйста, попробуйте позже." |
|
|
elif gae.status_code == 403: |
|
|
message = "Ошибка доступа (Permission Denied). Убедитесь, что API-ключ имеет необходимые разрешения." |
|
|
return None, "", message |
|
|
except Exception as e: |
|
|
error_message = str(e) |
|
|
print(f"Error during Gemini processing: {error_message}") |
|
|
import traceback |
|
|
traceback.print_exc() |
|
|
return None, "", f"Ошибка обработки: {error_message}" |
|
|
|
|
|
|
|
|
@app.route('/') |
|
|
def index(): |
|
|
html_template = """ |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Eva - редактор фото</title> |
|
|
<style> |
|
|
:root { |
|
|
--ios-blue: #007AFF; |
|
|
--ios-light-gray: #f2f2f7; |
|
|
--ios-mid-gray: #e5e5ea; |
|
|
--ios-dark-gray: #8e8e93; |
|
|
--ios-text-black: #1c1c1e; |
|
|
--ios-background-light: #ffffff; |
|
|
--ios-border-radius: 10px; |
|
|
--ios-button-height: 44px; |
|
|
--container-max-width: 600px; |
|
|
} |
|
|
|
|
|
body { |
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; |
|
|
background-color: var(--ios-light-gray); |
|
|
margin: 0; |
|
|
padding: 20px; |
|
|
color: var(--ios-text-black); |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
min-height: 100vh; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
.container { |
|
|
background-color: var(--ios-background-light); |
|
|
padding: 25px; |
|
|
border-radius: var(--ios-border-radius); |
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); |
|
|
width: 100%; |
|
|
max-width: var(--container-max-width); |
|
|
box-sizing: border-box; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
h1 { |
|
|
font-size: 28px; |
|
|
font-weight: 600; |
|
|
text-align: center; |
|
|
margin-bottom: 10px; |
|
|
color: var(--ios-text-black); |
|
|
} |
|
|
|
|
|
.instruction-text { |
|
|
font-size: 16px; |
|
|
color: var(--ios-dark-gray); |
|
|
text-align: center; |
|
|
margin-bottom: 25px; |
|
|
} |
|
|
|
|
|
.form-group { |
|
|
margin-bottom: 20px; |
|
|
} |
|
|
|
|
|
label { |
|
|
display: block; |
|
|
font-size: 15px; |
|
|
font-weight: 500; |
|
|
margin-bottom: 8px; |
|
|
color: var(--ios-text-black); |
|
|
} |
|
|
|
|
|
input[type="file"], |
|
|
textarea, |
|
|
input[type="text"] { |
|
|
width: 100%; |
|
|
padding: 12px; |
|
|
border: 1px solid var(--ios-mid-gray); |
|
|
border-radius: var(--ios-border-radius); |
|
|
font-size: 16px; |
|
|
box-sizing: border-box; |
|
|
background-color: #ffffff; |
|
|
color: var(--ios-text-black); |
|
|
min-height: var(--ios-button-height); |
|
|
} |
|
|
|
|
|
input[type="file"] { |
|
|
padding: 10px; |
|
|
} |
|
|
|
|
|
input[type="file"]::file-selector-button { |
|
|
font-family: inherit; |
|
|
font-weight: 500; |
|
|
padding: 6px 12px; |
|
|
border-radius: 6px; |
|
|
border: none; |
|
|
background-color: var(--ios-mid-gray); |
|
|
color: var(--ios-text-black); |
|
|
cursor: pointer; |
|
|
transition: background-color 0.2s ease; |
|
|
margin-right: 10px; |
|
|
} |
|
|
|
|
|
input[type="file"]::file-selector-button:hover { |
|
|
background-color: #d1d1d6; |
|
|
} |
|
|
|
|
|
|
|
|
textarea { |
|
|
resize: vertical; |
|
|
min-height: 80px; |
|
|
} |
|
|
|
|
|
.template-buttons { |
|
|
display: flex; |
|
|
gap: 10px; |
|
|
margin-bottom: 15px; |
|
|
flex-wrap: wrap; |
|
|
} |
|
|
|
|
|
.template-buttons button { |
|
|
flex-grow: 1; |
|
|
min-width: 150px; |
|
|
height: auto; |
|
|
padding: 12px; |
|
|
font-size: 15px; |
|
|
background-color: var(--ios-mid-gray); |
|
|
color: var(--ios-text-black); |
|
|
font-weight: 500; |
|
|
border: none; |
|
|
border-radius: var(--ios-border-radius); |
|
|
cursor: pointer; |
|
|
transition: background-color 0.2s ease; |
|
|
} |
|
|
|
|
|
.template-buttons button:hover { |
|
|
background-color: #d1d1d6; |
|
|
} |
|
|
|
|
|
button, .download-button { |
|
|
width: 100%; |
|
|
height: var(--ios-button-height); |
|
|
background-color: var(--ios-blue); |
|
|
color: white; |
|
|
font-size: 17px; |
|
|
font-weight: 600; |
|
|
border: none; |
|
|
border-radius: var(--ios-border-radius); |
|
|
cursor: pointer; |
|
|
transition: background-color 0.2s ease; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
text-decoration: none; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
button:hover:not(:disabled), .download-button:hover { |
|
|
background-color: #005bb5; |
|
|
} |
|
|
|
|
|
button:disabled { |
|
|
background-color: var(--ios-mid-gray); |
|
|
cursor: not-allowed; |
|
|
opacity: 0.7; |
|
|
} |
|
|
|
|
|
.output-section { |
|
|
margin-top: 30px; |
|
|
border-top: 1px solid var(--ios-mid-gray); |
|
|
padding-top: 25px; |
|
|
} |
|
|
|
|
|
.output-section h2 { |
|
|
font-size: 20px; |
|
|
font-weight: 600; |
|
|
margin-bottom: 15px; |
|
|
color: var(--ios-text-black); |
|
|
} |
|
|
|
|
|
#outputImageContainer { |
|
|
margin-bottom: 20px; |
|
|
min-height: 100px; |
|
|
background-color: var(--ios-light-gray); |
|
|
border-radius: var(--ios-border-radius); |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
overflow: hidden; |
|
|
padding: 10px; |
|
|
} |
|
|
|
|
|
#outputImage { |
|
|
max-width: 100%; |
|
|
max-height: 400px; |
|
|
display: block; |
|
|
border-radius: var(--ios-border-radius); |
|
|
object-fit: contain; |
|
|
} |
|
|
|
|
|
#outputImageContainer p { |
|
|
color: var(--ios-dark-gray); |
|
|
font-size: 14px; |
|
|
text-align: center; |
|
|
} |
|
|
|
|
|
#responseText, #status { |
|
|
background-color: var(--ios-light-gray); |
|
|
padding: 12px; |
|
|
border-radius: var(--ios-border-radius); |
|
|
font-size: 15px; |
|
|
color: var(--ios-text-black); |
|
|
min-height: 30px; |
|
|
white-space: pre-wrap; |
|
|
word-wrap: break-word; |
|
|
margin-top: 10px; |
|
|
} |
|
|
|
|
|
.loader { |
|
|
border: 4px solid var(--ios-light-gray); |
|
|
border-radius: 50%; |
|
|
border-top: 4px solid var(--ios-blue); |
|
|
width: 20px; |
|
|
height: 20px; |
|
|
animation: spin 1s linear infinite; |
|
|
margin-right: 10px; |
|
|
display: none; |
|
|
} |
|
|
|
|
|
@keyframes spin { |
|
|
0% { transform: rotate(0deg); } |
|
|
100% { transform: rotate(360deg); } |
|
|
} |
|
|
|
|
|
#downloadBtn { |
|
|
margin-top: 15px; |
|
|
} |
|
|
|
|
|
.footer { |
|
|
margin-top: 30px; |
|
|
text-align: center; |
|
|
font-size: 12px; |
|
|
color: var(--ios-dark-gray); |
|
|
} |
|
|
|
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<h1>Eva - редактор фото</h1> |
|
|
<p class="instruction-text">Загрузите фото, напишите в поле что нужно исправить и нажмите ПУСК</p> |
|
|
|
|
|
<form id="editForm" enctype="multipart/form-data"> |
|
|
<div class="form-group"> |
|
|
<label for="apiKeyInput">Ваш API Key:</label> |
|
|
<input type="text" id="apiKeyInput" placeholder="Введите ваш API Key" required> |
|
|
<p style="font-size: 12px; color: var(--ios-dark-gray); margin-top: 5px;">Ключ сохраняется только на вашем устройстве в браузере.</p> |
|
|
</div> |
|
|
|
|
|
<div class="form-group"> |
|
|
<label for="imageInput">Загрузить изображение:</label> |
|
|
<input type="file" id="imageInput" name="image" accept="image/jpeg, image/png, image/webp" required> |
|
|
</div> |
|
|
|
|
|
<div class="form-group"> |
|
|
<label for="instructionInput">Инструкция (что исправить):</label> |
|
|
<div class="template-buttons"> |
|
|
<button type="button" id="templateBtn1">Одежда на модели</button> |
|
|
<button type="button" id="templateBtn2">Фото товара</button> |
|
|
</div> |
|
|
<textarea id="instructionInput" name="instruction" rows="3" placeholder="например: сделай небо голубее, добавь улыбку..." required></textarea> |
|
|
</div> |
|
|
|
|
|
<button type="submit" id="submitBtn"> |
|
|
<div class="loader" id="loader"></div> |
|
|
<span>ПУСК</span> |
|
|
</button> |
|
|
</form> |
|
|
|
|
|
<div class="output-section"> |
|
|
<h2>Результат:</h2> |
|
|
<div id="outputImageContainer"> |
|
|
<p>Здесь появится отредактированное изображение</p> |
|
|
<img id="outputImage" src="" alt="Отредактированное изображение" style="display: none;"> |
|
|
</div> |
|
|
|
|
|
<button id="downloadBtn" class="download-button" style="display: none;"> |
|
|
<span>Скачать изображение</span> |
|
|
</button> |
|
|
|
|
|
<label for="responseText">Ответ:</label> |
|
|
<div id="responseText"></div> |
|
|
|
|
|
<label for="status">Статус:</label> |
|
|
<div id="status">Ожидание ввода...</div> |
|
|
</div> |
|
|
<div class="footer"> |
|
|
Обработка может занять до 60 секунд. Используйте собственный AI API Key. |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
const form = document.getElementById('editForm'); |
|
|
const submitBtn = document.getElementById('submitBtn'); |
|
|
const loader = document.getElementById('loader'); |
|
|
const btnText = submitBtn.querySelector('span'); |
|
|
const outputImage = document.getElementById('outputImage'); |
|
|
const outputImageContainer = document.getElementById('outputImageContainer'); |
|
|
const placeholderText = outputImageContainer.querySelector('p'); |
|
|
const responseTextDiv = document.getElementById('responseText'); |
|
|
const statusDiv = document.getElementById('status'); |
|
|
const imageInput = document.getElementById('imageInput'); |
|
|
const instructionInput = document.getElementById('instructionInput'); |
|
|
const apiKeyInput = document.getElementById('apiKeyInput'); |
|
|
const downloadBtn = document.getElementById('downloadBtn'); |
|
|
const templateBtn1 = document.getElementById('templateBtn1'); |
|
|
const templateBtn2 = document.getElementById('templateBtn2'); |
|
|
|
|
|
let currentImageUrlForDownload = null; |
|
|
|
|
|
const apiKeyStorageKey = 'googleApiKey'; |
|
|
|
|
|
function updateStatus(message, color = 'var(--ios-dark-gray)') { |
|
|
statusDiv.textContent = message; |
|
|
statusDiv.style.color = color; |
|
|
} |
|
|
|
|
|
function loadApiKey() { |
|
|
const savedKey = localStorage.getItem(apiKeyStorageKey); |
|
|
if (savedKey) { |
|
|
apiKeyInput.value = savedKey; |
|
|
} |
|
|
} |
|
|
|
|
|
function saveApiKey(key) { |
|
|
localStorage.setItem(apiKeyStorageKey, key); |
|
|
} |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', loadApiKey); |
|
|
|
|
|
|
|
|
downloadBtn.addEventListener('click', async (event) => { |
|
|
event.preventDefault(); |
|
|
if (!currentImageUrlForDownload) { |
|
|
updateStatus('Ошибка: URL изображения для скачивания отсутствует.', 'red'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const filename = 'eva_edited_image.jpg'; |
|
|
const cleanImageUrl = currentImageUrlForDownload.split('?t=')[0]; |
|
|
|
|
|
updateStatus('Подготовка к скачиванию...', 'var(--ios-dark-gray)'); |
|
|
|
|
|
if (window.Telegram && window.Telegram.WebApp && typeof window.Telegram.WebApp.openLink === 'function') { |
|
|
try { |
|
|
window.Telegram.WebApp.openLink(cleanImageUrl); |
|
|
updateStatus('Изображение передано в Telegram для открытия/скачивания. Если скачивание не началось, сохраните вручную.', 'var(--ios-dark-gray)'); |
|
|
return; |
|
|
} catch (telegramApiError) { |
|
|
console.warn('Telegram.WebApp.openLink failed:', telegramApiError); |
|
|
updateStatus('Telegram API не сработал, пробуем стандартный метод...', 'orange'); |
|
|
} |
|
|
} |
|
|
|
|
|
const newWindow = window.open(cleanImageUrl, '_blank'); |
|
|
if (newWindow) { |
|
|
updateStatus('Попытка открыть изображение для скачивания в новой вкладке/окне. Если скачивание не началось автоматически, сохраните изображение из новой вкладки вручную.', 'var(--ios-dark-gray)'); |
|
|
} else { |
|
|
updateStatus('Не удалось открыть новую вкладку (возможно, заблокирована). Пробуем альтернативный метод (blob)...', 'orange'); |
|
|
|
|
|
try { |
|
|
const response = await fetch(currentImageUrlForDownload); |
|
|
if (!response.ok) { |
|
|
throw new Error(`HTTP ошибка ${response.status}`); |
|
|
} |
|
|
const blob = await response.blob(); |
|
|
const objectUrl = URL.createObjectURL(blob); |
|
|
|
|
|
const a = document.createElement('a'); |
|
|
a.href = objectUrl; |
|
|
a.download = filename; |
|
|
document.body.appendChild(a); |
|
|
a.click(); |
|
|
document.body.removeChild(a); |
|
|
URL.revokeObjectURL(objectUrl); |
|
|
updateStatus('Альтернативный метод скачивания (через blob) запущен. Проверьте загрузки.', 'green'); |
|
|
} catch (blobDownloadError) { |
|
|
console.error('Blob download method also failed:', blobDownloadError); |
|
|
updateStatus(`Ошибка при всех попытках скачивания: ${blobDownloadError.message}. Попробуйте скопировать ссылку и открыть вручную.`, 'red'); |
|
|
|
|
|
const directLinkAnchor = document.createElement('a'); |
|
|
directLinkAnchor.href = cleanImageUrl; |
|
|
directLinkAnchor.textContent = "Прямая ссылка на изображение"; |
|
|
directLinkAnchor.target = "_blank"; |
|
|
|
|
|
const p = document.createElement('p'); |
|
|
p.textContent = 'Не удалось скачать автоматически. '; |
|
|
p.appendChild(directLinkAnchor); |
|
|
|
|
|
responseTextDiv.innerHTML = ''; |
|
|
responseTextDiv.appendChild(p); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
templateBtn1.addEventListener('click', () => { |
|
|
instructionInput.value = 'Покажи как эта одежда будет смотреться на девушке/мужчине, профессиональное фото на белом фоне'; |
|
|
}); |
|
|
|
|
|
templateBtn2.addEventListener('click', () => { |
|
|
instructionInput.value = 'Сделай из этого фото профессиональное фото товара на белом фоне'; |
|
|
}); |
|
|
|
|
|
|
|
|
form.addEventListener('submit', async (event) => { |
|
|
event.preventDefault(); |
|
|
|
|
|
const imageFile = imageInput.files[0]; |
|
|
const instruction = instructionInput.value.trim(); |
|
|
const apiKey = apiKeyInput.value.trim(); |
|
|
|
|
|
if (!apiKey) { |
|
|
updateStatus('Ошибка: Пожалуйста, введите ваш Google API Key.', 'red'); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!imageFile) { |
|
|
updateStatus('Ошибка: Пожалуйста, загрузите изображение.', 'red'); |
|
|
return; |
|
|
} |
|
|
if (!instruction) { |
|
|
updateStatus('Ошибка: Пожалуйста, введите инструкцию.', 'red'); |
|
|
return; |
|
|
} |
|
|
|
|
|
saveApiKey(apiKey); |
|
|
|
|
|
submitBtn.disabled = true; |
|
|
loader.style.display = 'inline-block'; |
|
|
btnText.textContent = 'Обработка...'; |
|
|
updateStatus('Идет обработка, пожалуйста, подождите...'); |
|
|
responseTextDiv.textContent = ''; |
|
|
outputImage.style.display = 'none'; |
|
|
if (placeholderText) placeholderText.style.display = 'block'; |
|
|
downloadBtn.style.display = 'none'; |
|
|
currentImageUrlForDownload = null; |
|
|
|
|
|
|
|
|
const formData = new FormData(); |
|
|
formData.append('image', imageFile); |
|
|
formData.append('instruction', instruction); |
|
|
formData.append('api_key', apiKey); |
|
|
|
|
|
|
|
|
try { |
|
|
const response = await fetch('/process', { |
|
|
method: 'POST', |
|
|
body: formData, |
|
|
}); |
|
|
|
|
|
const result = await response.json(); |
|
|
|
|
|
updateStatus(result.status || 'Неизвестный статус', |
|
|
(result.status && (result.status.startsWith('Ошибка') || result.status.startsWith('Запрос заблокирован') || result.status.startsWith('Превышен лимит') || result.status.startsWith('Не удалось сгенерировать') || result.status.startsWith('Генерация остановлена') || result.status.includes('API Google') || result.status.includes('Google API'))) ? 'red' : |
|
|
(result.status === 'Успех' ? 'green' : 'var(--ios-dark-gray)') |
|
|
); |
|
|
responseTextDiv.textContent = result.response_text || ''; |
|
|
|
|
|
if (result.output_image_url) { |
|
|
currentImageUrlForDownload = result.output_image_url + '?t=' + new Date().getTime(); |
|
|
outputImage.src = currentImageUrlForDownload; |
|
|
outputImage.style.display = 'block'; |
|
|
if (placeholderText) placeholderText.style.display = 'none'; |
|
|
downloadBtn.style.display = 'flex'; |
|
|
|
|
|
} else { |
|
|
outputImage.src = ''; |
|
|
outputImage.style.display = 'none'; |
|
|
if (placeholderText) placeholderText.style.display = 'block'; |
|
|
downloadBtn.style.display = 'none'; |
|
|
currentImageUrlForDownload = null; |
|
|
} |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Error:', error); |
|
|
updateStatus(`Критическая ошибка связи с сервером: ${error.message}`, 'red'); |
|
|
responseTextDiv.textContent = ''; |
|
|
outputImage.src = ''; |
|
|
outputImage.style.display = 'none'; |
|
|
if (placeholderText) placeholderText.style.display = 'block'; |
|
|
downloadBtn.style.display = 'none'; |
|
|
currentImageUrlForDownload = null; |
|
|
} finally { |
|
|
submitBtn.disabled = false; |
|
|
loader.style.display = 'none'; |
|
|
btnText.textContent = 'ПУСК'; |
|
|
} |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
return render_template_string(html_template) |
|
|
|
|
|
@app.route('/process', methods=['POST']) |
|
|
def handle_process(): |
|
|
api_key = request.form.get('api_key') |
|
|
if not api_key: |
|
|
return jsonify({"status": "Ошибка: Google API Key не предоставлен.", "response_text": "", "output_image_url": None}), 400 |
|
|
|
|
|
if 'image' not in request.files: |
|
|
return jsonify({"status": "Ошибка: Файл изображения не найден", "response_text": "", "output_image_url": None}), 400 |
|
|
if 'instruction' not in request.form: |
|
|
return jsonify({"status": "Ошибка: Инструкция не найдена", "response_text": "", "output_image_url": None}), 400 |
|
|
|
|
|
image_file = request.files['image'] |
|
|
instruction = request.form['instruction'] |
|
|
|
|
|
if image_file.filename == '': |
|
|
return jsonify({"status": "Ошибка: Файл изображения не выбран", "response_text": "", "output_image_url": None}), 400 |
|
|
|
|
|
if not instruction or instruction.strip() == "": |
|
|
return jsonify({"status": "Ошибка: Пожалуйста, предоставьте инструкцию.", "response_text": "", "output_image_url": None}), 400 |
|
|
|
|
|
|
|
|
try: |
|
|
current_client = genai.Client(api_key=api_key) |
|
|
except Exception as e: |
|
|
print(f"Error initializing Gemini Client with provided key: {e}") |
|
|
return jsonify({"status": f"Ошибка инициализации AI клиента с вашим ключом: {e}", "response_text": "", "output_image_url": None}), 400 |
|
|
|
|
|
|
|
|
try: |
|
|
img = Image.open(image_file.stream) |
|
|
MAX_DIMENSION = 2048 |
|
|
if img.width > MAX_DIMENSION or img.height > MAX_DIMENSION: |
|
|
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION)) |
|
|
if img.mode not in ('RGB', 'RGBA', 'L', 'P'): |
|
|
img = img.convert('RGB') |
|
|
elif img.mode == 'RGBA': |
|
|
img = img.convert('RGB') |
|
|
|
|
|
except UnidentifiedImageError: |
|
|
print(f"Error opening image: UnidentifiedImageError") |
|
|
return jsonify({"status": "Ошибка: Не удалось распознать формат файла изображения. Пожалуйста, используйте JPEG, PNG или WEBP.", "response_text": "", "output_image_url": None}), 400 |
|
|
except Exception as e: |
|
|
print(f"Error processing image input: {e}") |
|
|
import traceback |
|
|
traceback.print_exc() |
|
|
return jsonify({"status": f"Ошибка: Не удалось обработать файл изображения. {e}", "response_text": "", "output_image_url": None}), 400 |
|
|
|
|
|
request_id = f"request_{uuid.uuid4().hex[:12]}" |
|
|
|
|
|
try: |
|
|
output_image_url, response_text, status_message = process_image_with_gemini(current_client, img, instruction, request_id) |
|
|
return jsonify({ |
|
|
"status": status_message, |
|
|
"response_text": response_text, |
|
|
"output_image_url": output_image_url |
|
|
}) |
|
|
except Exception as e: |
|
|
print(f"Unexpected error in /process route: {str(e)}") |
|
|
import traceback |
|
|
traceback.print_exc() |
|
|
return jsonify({"status": f"Неожиданная ошибка сервера при вызове Gemini: {str(e)}", "response_text": "", "output_image_url": None}), 500 |
|
|
|
|
|
|
|
|
@app.route('/output/<path:req_id>/<path:filename>') |
|
|
def serve_output_file(req_id, filename): |
|
|
if any(c in req_id or c in filename for c in ['..', '/', '\\', ':']): |
|
|
abort(404) |
|
|
|
|
|
directory = Path(app.config['UPLOAD_FOLDER']) / req_id |
|
|
|
|
|
try: |
|
|
safe_directory = directory.resolve(strict=True) |
|
|
except FileNotFoundError: |
|
|
abort(404) |
|
|
except Exception as e: |
|
|
print(f"Error resolving directory path {directory}: {e}") |
|
|
abort(500) |
|
|
|
|
|
upload_folder_abs = Path(app.config['UPLOAD_FOLDER']).resolve() |
|
|
|
|
|
if not safe_directory.is_dir() or not str(safe_directory).startswith(str(upload_folder_abs)): |
|
|
abort(404) |
|
|
|
|
|
safe_filename = Path(filename).name |
|
|
safe_filepath = safe_directory / safe_filename |
|
|
|
|
|
if not safe_filepath.is_file(): |
|
|
abort(404) |
|
|
|
|
|
try: |
|
|
return send_from_directory(safe_directory, safe_filename, as_attachment=True, download_name="eva_edited_image.jpg") |
|
|
except FileNotFoundError: |
|
|
abort(404) |
|
|
except Exception as e: |
|
|
print(f"Error serving file {safe_filepath}: {e}") |
|
|
abort(500) |
|
|
|
|
|
|
|
|
if __name__ == '__main__': |
|
|
print("Starting Eva Flask App...") |
|
|
app.run(host='0.0.0.0', port=7860, debug=False) |