mirror of
https://git.linux-kernel.at/oliver/ivatar.git
synced 2025-11-19 14:38:02 +00:00
Play around with AI avatars - nothing serious yet
This commit is contained in:
27
config.py
27
config.py
@@ -296,6 +296,33 @@ TRUSTED_DEFAULT_URLS = list(map(map_legacy_config, TRUSTED_DEFAULT_URLS))
|
|||||||
BLUESKY_IDENTIFIER = os.environ.get("BLUESKY_IDENTIFIER", None)
|
BLUESKY_IDENTIFIER = os.environ.get("BLUESKY_IDENTIFIER", None)
|
||||||
BLUESKY_APP_PASSWORD = os.environ.get("BLUESKY_APP_PASSWORD", None)
|
BLUESKY_APP_PASSWORD = os.environ.get("BLUESKY_APP_PASSWORD", None)
|
||||||
|
|
||||||
|
# Celery Configuration
|
||||||
|
# Try Redis first, fallback to memory broker for development
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
|
||||||
|
redis.Redis(host="localhost", port=6379, db=0).ping()
|
||||||
|
CELERY_BROKER_URL = "redis://localhost:6379/0"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
# Fallback to memory broker for development
|
||||||
|
CELERY_BROKER_URL = "memory://"
|
||||||
|
print("Warning: Redis not available, using memory broker for development")
|
||||||
|
|
||||||
|
CELERY_RESULT_BACKEND = "django-db"
|
||||||
|
CELERY_ACCEPT_CONTENT = ["json"]
|
||||||
|
CELERY_TASK_SERIALIZER = "json"
|
||||||
|
CELERY_RESULT_SERIALIZER = "json"
|
||||||
|
CELERY_TIMEZONE = "UTC"
|
||||||
|
CELERY_TASK_TRACK_STARTED = True
|
||||||
|
CELERY_TASK_TIME_LIMIT = 300 # 5 minutes
|
||||||
|
CELERY_TASK_SOFT_TIME_LIMIT = 240 # 4 minutes
|
||||||
|
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
|
||||||
|
CELERY_TASK_ACKS_LATE = True
|
||||||
|
CELERY_RESULT_EXPIRES = 3600 # 1 hour
|
||||||
|
CELERY_WORKER_CONCURRENCY = (
|
||||||
|
1 # Max 1 parallel avatar generation task for local development
|
||||||
|
)
|
||||||
|
|
||||||
# This MUST BE THE LAST!
|
# This MUST BE THE LAST!
|
||||||
if os.path.isfile(os.path.join(BASE_DIR, "config_local.py")):
|
if os.path.isfile(os.path.join(BASE_DIR, "config_local.py")):
|
||||||
from config_local import * # noqa # flake8: noqa # NOQA # pragma: no cover
|
from config_local import * # noqa # flake8: noqa # NOQA # pragma: no cover
|
||||||
|
|||||||
@@ -3,4 +3,10 @@
|
|||||||
Module init
|
Module init
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# This will make sure the app is always imported when
|
||||||
|
# Django starts so that shared_task will use this app.
|
||||||
|
from .celery import app as celery_app
|
||||||
|
|
||||||
|
__all__ = ("celery_app",)
|
||||||
|
|
||||||
app_label = __name__ # pylint: disable=invalid-name
|
app_label = __name__ # pylint: disable=invalid-name
|
||||||
|
|||||||
461
ivatar/ai_service.py
Normal file
461
ivatar/ai_service.py
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
AI service module for text-to-image avatar generation
|
||||||
|
Supports Stable Diffusion (local and API) for professional avatar generation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
from PIL import Image
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AIServiceError(Exception):
|
||||||
|
"""Custom exception for AI service errors"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StableDiffusionService:
|
||||||
|
"""
|
||||||
|
Service for generating images using Stable Diffusion (local or API)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Model-specific token limits
|
||||||
|
TOKEN_LIMITS = {
|
||||||
|
"stable_diffusion": 77, # CLIP tokenizer limit
|
||||||
|
"stable_diffusion_v2": 77,
|
||||||
|
"stable_diffusion_xl": 77,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.api_url = getattr(settings, "STABLE_DIFFUSION_API_URL", None)
|
||||||
|
self.api_key = getattr(settings, "STABLE_DIFFUSION_API_KEY", None)
|
||||||
|
self.timeout = getattr(settings, "STABLE_DIFFUSION_TIMEOUT", 60)
|
||||||
|
self._pipe = None # Cache for local model
|
||||||
|
self._tokenizer = None # Cache for tokenizer
|
||||||
|
|
||||||
|
def generate_image(
|
||||||
|
self, prompt, size=(512, 512), quality="medium", allow_nsfw=False
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate an image from text prompt using Stable Diffusion
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt (str): Text description of the desired image
|
||||||
|
size (tuple): Image dimensions (width, height)
|
||||||
|
quality (str): Generation quality ('low', 'medium', 'high')
|
||||||
|
allow_nsfw (bool): Whether to allow potentially NSFW content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL.Image: Generated image
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AIServiceError: If generation fails or prompt is too long
|
||||||
|
"""
|
||||||
|
# Validate prompt length first
|
||||||
|
validation = self.validate_prompt(prompt)
|
||||||
|
if not validation["valid"]:
|
||||||
|
raise AIServiceError(validation["warning"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.api_url and self.api_key:
|
||||||
|
return self._generate_via_api(prompt, size, quality, allow_nsfw)
|
||||||
|
else:
|
||||||
|
return self._generate_locally(prompt, size, quality, allow_nsfw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate image: {e}")
|
||||||
|
raise AIServiceError(f"Image generation failed: {str(e)}")
|
||||||
|
|
||||||
|
def validate_prompt(self, prompt, model="stable_diffusion"):
|
||||||
|
"""
|
||||||
|
Validate prompt length against model token limits
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt (str): Text prompt to validate
|
||||||
|
model (str): Model name to check limits for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Validation result with 'valid', 'token_count', 'limit', 'warning'
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
token_count = self._count_tokens(prompt)
|
||||||
|
limit = self.TOKEN_LIMITS.get(model, 77)
|
||||||
|
|
||||||
|
is_valid = token_count <= limit
|
||||||
|
warning = None
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
warning = f"Prompt too long: {token_count} tokens (limit: {limit}). Please shorten your prompt."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": is_valid,
|
||||||
|
"token_count": token_count,
|
||||||
|
"limit": limit,
|
||||||
|
"warning": warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Token counting failed: {e}")
|
||||||
|
return {
|
||||||
|
"valid": True, # Allow generation if counting fails
|
||||||
|
"token_count": 0,
|
||||||
|
"limit": 77,
|
||||||
|
"warning": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _count_tokens(self, prompt):
|
||||||
|
"""
|
||||||
|
Count tokens in a prompt using CLIP tokenizer
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._tokenizer is None:
|
||||||
|
from transformers import CLIPTokenizer
|
||||||
|
|
||||||
|
self._tokenizer = CLIPTokenizer.from_pretrained(
|
||||||
|
"openai/clip-vit-base-patch32"
|
||||||
|
)
|
||||||
|
|
||||||
|
tokens = self._tokenizer(prompt, return_tensors="pt", truncation=False)[
|
||||||
|
"input_ids"
|
||||||
|
]
|
||||||
|
return tokens.shape[1]
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# Fallback: more accurate estimation
|
||||||
|
# CLIP tokenizer typically produces ~1.3 tokens per word for English
|
||||||
|
words = len(prompt.split())
|
||||||
|
return int(words * 1.3)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Token counting error: {e}")
|
||||||
|
# Fallback: more accurate estimation
|
||||||
|
words = len(prompt.split())
|
||||||
|
return int(words * 1.3)
|
||||||
|
|
||||||
|
def _is_black_image(self, image):
|
||||||
|
"""
|
||||||
|
Check if an image is completely black (common NSFW response from APIs)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image (PIL.Image): Image to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if image is completely black
|
||||||
|
"""
|
||||||
|
# Convert to RGB if necessary
|
||||||
|
if image.mode != "RGB":
|
||||||
|
image = image.convert("RGB")
|
||||||
|
|
||||||
|
# Get image data
|
||||||
|
pixels = list(image.getdata())
|
||||||
|
|
||||||
|
# Check if all pixels are black (0, 0, 0)
|
||||||
|
black_pixels = sum(1 for r, g, b in pixels if r == 0 and g == 0 and b == 0)
|
||||||
|
total_pixels = len(pixels)
|
||||||
|
|
||||||
|
# Consider it a black image if more than 95% of pixels are black
|
||||||
|
return (black_pixels / total_pixels) > 0.95
|
||||||
|
|
||||||
|
def _generate_via_api(self, prompt, size, quality, allow_nsfw=False):
|
||||||
|
"""
|
||||||
|
Generate image via Stable Diffusion API (Replicate, Hugging Face, etc.)
|
||||||
|
"""
|
||||||
|
# Enhanced prompt for avatar generation
|
||||||
|
enhanced_prompt = f"""professional avatar portrait, {prompt}, high quality, detailed, clean background, centered composition, profile picture style, photorealistic"""
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"prompt": enhanced_prompt,
|
||||||
|
"width": size[0],
|
||||||
|
"height": size[1],
|
||||||
|
"num_inference_steps": 25
|
||||||
|
if quality == "high"
|
||||||
|
else (20 if quality == "medium" else 15),
|
||||||
|
"guidance_scale": 7.5, # Balanced for quality and speed
|
||||||
|
"negative_prompt": "blurry, low quality, distorted, ugly, deformed, bad anatomy",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add NSFW safety setting if supported by the API
|
||||||
|
if allow_nsfw:
|
||||||
|
payload["safety_tolerance"] = 2 # Some APIs support this
|
||||||
|
payload["nsfw"] = True # Some APIs support this
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
self.api_url, json=payload, headers=headers, timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_msg = f"Stable Diffusion API request failed: {response.status_code}"
|
||||||
|
try:
|
||||||
|
error_detail = response.json()
|
||||||
|
error_msg += f" - {error_detail}"
|
||||||
|
|
||||||
|
# Check for NSFW content detection
|
||||||
|
if isinstance(error_detail, dict):
|
||||||
|
error_text = str(error_detail).lower()
|
||||||
|
if (
|
||||||
|
"nsfw" in error_text
|
||||||
|
or "inappropriate" in error_text
|
||||||
|
or "black image" in error_text
|
||||||
|
):
|
||||||
|
if allow_nsfw:
|
||||||
|
# If user allowed NSFW but still got blocked, provide a different message
|
||||||
|
raise AIServiceError(
|
||||||
|
"Content warning: The AI service still detected inappropriate content even with relaxed settings. Please try a different prompt or contact support if you believe this is an error."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise AIServiceError(
|
||||||
|
"Content warning: The AI service detected potentially inappropriate content in your prompt. Please modify your description to be more appropriate for all ages and try again."
|
||||||
|
)
|
||||||
|
elif isinstance(error_detail, str):
|
||||||
|
if (
|
||||||
|
"nsfw" in error_detail.lower()
|
||||||
|
or "inappropriate" in error_detail.lower()
|
||||||
|
or "black image" in error_detail.lower()
|
||||||
|
):
|
||||||
|
if allow_nsfw:
|
||||||
|
raise AIServiceError(
|
||||||
|
"Content warning: The AI service still detected inappropriate content even with relaxed settings. Please try a different prompt or contact support if you believe this is an error."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise AIServiceError(
|
||||||
|
"Content warning: The AI service detected potentially inappropriate content in your prompt. Please modify your description to be more appropriate for all ages and try again."
|
||||||
|
)
|
||||||
|
|
||||||
|
except AIServiceError:
|
||||||
|
# Re-raise our custom NSFW error
|
||||||
|
raise
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
error_msg += f" - {response.text}"
|
||||||
|
|
||||||
|
# Also check response text for NSFW warnings
|
||||||
|
if (
|
||||||
|
"nsfw" in response.text.lower()
|
||||||
|
or "inappropriate" in response.text.lower()
|
||||||
|
or "black image" in response.text.lower()
|
||||||
|
):
|
||||||
|
if allow_nsfw:
|
||||||
|
raise AIServiceError(
|
||||||
|
"Content warning: The AI service still detected inappropriate content even with relaxed settings. Please try a different prompt or contact support if you believe this is an error."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise AIServiceError(
|
||||||
|
"Content warning: The AI service detected potentially inappropriate content in your prompt. Please modify your description to be more appropriate for all ages and try again."
|
||||||
|
)
|
||||||
|
|
||||||
|
raise AIServiceError(error_msg)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
if "image" in result:
|
||||||
|
# Decode base64 image
|
||||||
|
image_data = base64.b64decode(result["image"])
|
||||||
|
image = Image.open(BytesIO(image_data))
|
||||||
|
|
||||||
|
# Check if the image is completely black (common NSFW response)
|
||||||
|
if not allow_nsfw and self._is_black_image(image):
|
||||||
|
raise AIServiceError(
|
||||||
|
"Content warning: The AI service detected potentially inappropriate content in your prompt and returned a black image. Please modify your description to be more appropriate for all ages and try again."
|
||||||
|
)
|
||||||
|
|
||||||
|
return image
|
||||||
|
else:
|
||||||
|
raise AIServiceError("No image data in API response")
|
||||||
|
|
||||||
|
def _generate_locally(self, prompt, size, quality, allow_nsfw=False):
|
||||||
|
"""
|
||||||
|
Generate image using local Stable Diffusion installation
|
||||||
|
This requires diffusers library and a local model
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from diffusers import StableDiffusionPipeline
|
||||||
|
import torch
|
||||||
|
|
||||||
|
# Enhanced prompt for avatar generation
|
||||||
|
enhanced_prompt = f"""professional avatar portrait, {prompt}, high quality, detailed, clean background, centered composition, profile picture style, photorealistic"""
|
||||||
|
|
||||||
|
# Use cached model if available, otherwise load it
|
||||||
|
if self._pipe is None:
|
||||||
|
logger.info("Loading Stable Diffusion model (first time or cache miss)")
|
||||||
|
self._pipe = StableDiffusionPipeline.from_pretrained(
|
||||||
|
"runwayml/stable-diffusion-v1-5",
|
||||||
|
torch_dtype=torch.float16
|
||||||
|
if torch.cuda.is_available()
|
||||||
|
else torch.float32,
|
||||||
|
)
|
||||||
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
self._pipe = self._pipe.to("cuda")
|
||||||
|
else:
|
||||||
|
logger.info("Using cached Stable Diffusion model")
|
||||||
|
|
||||||
|
pipe = self._pipe
|
||||||
|
|
||||||
|
# Disable safety checker if NSFW override is enabled
|
||||||
|
if allow_nsfw:
|
||||||
|
pipe.safety_checker = None
|
||||||
|
pipe.requires_safety_checker = False
|
||||||
|
|
||||||
|
# Generate image with optimized settings for speed
|
||||||
|
image = pipe(
|
||||||
|
enhanced_prompt,
|
||||||
|
height=size[1],
|
||||||
|
width=size[0],
|
||||||
|
num_inference_steps=25
|
||||||
|
if quality == "high"
|
||||||
|
else (20 if quality == "medium" else 15),
|
||||||
|
guidance_scale=7.5, # Balanced for quality and speed
|
||||||
|
negative_prompt="blurry, low quality, distorted, ugly, deformed, bad anatomy",
|
||||||
|
).images[0]
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.warning(
|
||||||
|
"diffusers library not installed, falling back to placeholder"
|
||||||
|
)
|
||||||
|
return self._generate_placeholder(prompt, size)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Local Stable Diffusion generation failed: {e}")
|
||||||
|
return self._generate_placeholder(prompt, size)
|
||||||
|
|
||||||
|
def _generate_placeholder(self, prompt, size):
|
||||||
|
"""
|
||||||
|
Generate a placeholder image when Stable Diffusion is not available
|
||||||
|
"""
|
||||||
|
logger.info("Generating placeholder image")
|
||||||
|
|
||||||
|
# Create a more sophisticated placeholder
|
||||||
|
img = Image.new("RGBA", size, color=(240, 248, 255, 255))
|
||||||
|
|
||||||
|
from PIL import ImageDraw, ImageFont
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
try:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
font = None
|
||||||
|
|
||||||
|
# Add title
|
||||||
|
title = "AI Avatar (Stable Diffusion)"
|
||||||
|
draw.text((10, 10), title, fill="darkblue", font=font)
|
||||||
|
|
||||||
|
# Add prompt
|
||||||
|
prompt_text = f"Prompt: {prompt[:50]}..."
|
||||||
|
draw.text((10, 40), prompt_text, fill="black", font=font)
|
||||||
|
|
||||||
|
# Add note
|
||||||
|
note = "Install Stable Diffusion for real generation"
|
||||||
|
draw.text((10, 70), note, fill="darkgreen", font=font)
|
||||||
|
|
||||||
|
# Create a more sophisticated avatar placeholder
|
||||||
|
center_x, center_y = size[0] // 2, size[1] // 2 + 20
|
||||||
|
radius = min(size) // 4
|
||||||
|
|
||||||
|
# Face circle
|
||||||
|
draw.ellipse(
|
||||||
|
[
|
||||||
|
center_x - radius,
|
||||||
|
center_y - radius,
|
||||||
|
center_x + radius,
|
||||||
|
center_y + radius,
|
||||||
|
],
|
||||||
|
outline="purple",
|
||||||
|
width=3,
|
||||||
|
fill=(255, 240, 245),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Eyes
|
||||||
|
eye_radius = radius // 4
|
||||||
|
draw.ellipse(
|
||||||
|
[
|
||||||
|
center_x - radius // 2 - eye_radius,
|
||||||
|
center_y - radius // 2 - eye_radius,
|
||||||
|
center_x - radius // 2 + eye_radius,
|
||||||
|
center_y - radius // 2 + eye_radius,
|
||||||
|
],
|
||||||
|
fill="blue",
|
||||||
|
)
|
||||||
|
draw.ellipse(
|
||||||
|
[
|
||||||
|
center_x + radius // 2 - eye_radius,
|
||||||
|
center_y - radius // 2 - eye_radius,
|
||||||
|
center_x + radius // 2 + eye_radius,
|
||||||
|
center_y - radius // 2 + eye_radius,
|
||||||
|
],
|
||||||
|
fill="blue",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Smile
|
||||||
|
smile_y = center_y + radius // 3
|
||||||
|
draw.arc(
|
||||||
|
[
|
||||||
|
center_x - radius // 2,
|
||||||
|
smile_y - radius // 4,
|
||||||
|
center_x + radius // 2,
|
||||||
|
smile_y + radius // 4,
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
180,
|
||||||
|
fill="red",
|
||||||
|
width=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def validate_avatar_prompt(prompt, model="stable_diffusion"):
|
||||||
|
"""
|
||||||
|
Convenience function to validate avatar prompts
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt (str): Text description of the avatar
|
||||||
|
model (str): AI model to use
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Validation result with 'valid', 'token_count', 'limit', 'warning'
|
||||||
|
"""
|
||||||
|
if model == "stable_diffusion":
|
||||||
|
service = StableDiffusionService()
|
||||||
|
return service.validate_prompt(prompt, model)
|
||||||
|
else:
|
||||||
|
# For other models, assume they're valid
|
||||||
|
return {"valid": True, "token_count": 0, "limit": 0, "warning": None}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_avatar_image(
|
||||||
|
prompt,
|
||||||
|
model="stable_diffusion",
|
||||||
|
size=(512, 512),
|
||||||
|
quality="medium",
|
||||||
|
allow_nsfw=False,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Convenience function to generate avatar images
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt (str): Text description of the avatar
|
||||||
|
model (str): AI model to use (currently only 'stable_diffusion')
|
||||||
|
size (tuple): Image dimensions
|
||||||
|
quality (str): Generation quality ('low', 'medium', 'high')
|
||||||
|
allow_nsfw (bool): Whether to allow potentially NSFW content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL.Image: Generated avatar image
|
||||||
|
"""
|
||||||
|
if model == "stable_diffusion":
|
||||||
|
service = StableDiffusionService()
|
||||||
|
return service.generate_image(prompt, size, quality, allow_nsfw)
|
||||||
|
else:
|
||||||
|
raise AIServiceError(
|
||||||
|
f"Unsupported model: {model}. Only 'stable_diffusion' is currently supported."
|
||||||
|
)
|
||||||
56
ivatar/celery.py
Normal file
56
ivatar/celery.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Celery configuration for ivatar
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from celery import Celery
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Set the default Django settings module for the 'celery' program.
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ivatar.settings")
|
||||||
|
|
||||||
|
app = Celery("ivatar")
|
||||||
|
|
||||||
|
# Using a string here means the worker doesn't have to serialize
|
||||||
|
# the configuration object to child processes.
|
||||||
|
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||||
|
|
||||||
|
# Load task modules from all registered Django apps.
|
||||||
|
app.autodiscover_tasks()
|
||||||
|
|
||||||
|
# Celery configuration
|
||||||
|
app.conf.update(
|
||||||
|
# Task routing - use default queue for simplicity
|
||||||
|
task_default_queue="default",
|
||||||
|
task_routes={
|
||||||
|
"ivatar.tasks.generate_avatar_task": {"queue": "default"},
|
||||||
|
"ivatar.tasks.update_queue_positions": {"queue": "default"},
|
||||||
|
"ivatar.tasks.cleanup_old_tasks": {"queue": "default"},
|
||||||
|
},
|
||||||
|
# Worker configuration
|
||||||
|
worker_prefetch_multiplier=1,
|
||||||
|
task_acks_late=True,
|
||||||
|
# Result backend
|
||||||
|
result_backend="django-db",
|
||||||
|
result_expires=3600, # 1 hour
|
||||||
|
# Task time limits
|
||||||
|
task_time_limit=300, # 5 minutes
|
||||||
|
task_soft_time_limit=240, # 4 minutes
|
||||||
|
# Task serialization
|
||||||
|
task_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
result_serializer="json",
|
||||||
|
# Timezone
|
||||||
|
timezone="UTC",
|
||||||
|
enable_utc=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set worker concurrency from Django settings
|
||||||
|
if hasattr(settings, "CELERY_WORKER_CONCURRENCY"):
|
||||||
|
app.conf.worker_concurrency = settings.CELERY_WORKER_CONCURRENCY
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(bind=True)
|
||||||
|
def debug_task(self):
|
||||||
|
print(f"Request: {self.request!r}")
|
||||||
@@ -217,6 +217,89 @@ class UploadLibravatarExportForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateAvatarForm(forms.Form):
|
||||||
|
"""
|
||||||
|
Form for generating avatars using AI text-to-image
|
||||||
|
"""
|
||||||
|
|
||||||
|
MODEL_CHOICES = [
|
||||||
|
("stable_diffusion", "Stable Diffusion"),
|
||||||
|
# Future models can be added here
|
||||||
|
]
|
||||||
|
|
||||||
|
prompt = forms.CharField(
|
||||||
|
label=_("Avatar Description"),
|
||||||
|
max_length=500,
|
||||||
|
widget=forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
"rows": 3,
|
||||||
|
"placeholder": _(
|
||||||
|
'Describe the avatar you want to create, e.g., "A friendly robot with blue eyes"'
|
||||||
|
),
|
||||||
|
"id": "id_prompt",
|
||||||
|
"data-token-limit": "77",
|
||||||
|
"data-model": "stable_diffusion",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
help_text=_(
|
||||||
|
"Describe the avatar you want to generate. Be specific about appearance, style, and mood.<br><small class='text-muted'>Stable Diffusion has a 77-token limit. Keep your description concise for best results.</small>"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
model = forms.ChoiceField(
|
||||||
|
label=_("AI Model"),
|
||||||
|
choices=MODEL_CHOICES,
|
||||||
|
initial="stable_diffusion",
|
||||||
|
help_text=_("Select the AI model to use for generation."),
|
||||||
|
)
|
||||||
|
|
||||||
|
quality = forms.ChoiceField(
|
||||||
|
label=_("Generation Quality"),
|
||||||
|
choices=[
|
||||||
|
("low", _("Low (faster, lower quality)")),
|
||||||
|
("medium", _("Medium (balanced)")),
|
||||||
|
("high", _("High (slower, better quality)")),
|
||||||
|
],
|
||||||
|
initial="medium",
|
||||||
|
help_text=_("Higher quality takes longer but produces better results."),
|
||||||
|
)
|
||||||
|
|
||||||
|
not_porn = forms.BooleanField(
|
||||||
|
label=_("Suitable for all ages (no offensive content)"),
|
||||||
|
required=True,
|
||||||
|
error_messages={
|
||||||
|
"required": _(
|
||||||
|
'We only host "G-rated" images and so this field must be checked.'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
can_distribute = forms.BooleanField(
|
||||||
|
label=_("Can be freely copied"),
|
||||||
|
required=True,
|
||||||
|
error_messages={
|
||||||
|
"required": _(
|
||||||
|
"This field must be checked since we need to be able to distribute photos to third parties."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_prompt(self):
|
||||||
|
"""Validate prompt length against token limits"""
|
||||||
|
prompt = self.cleaned_data.get("prompt", "")
|
||||||
|
model = self.cleaned_data.get("model", "stable_diffusion")
|
||||||
|
|
||||||
|
if prompt:
|
||||||
|
from ivatar.ai_service import validate_avatar_prompt
|
||||||
|
|
||||||
|
validation = validate_avatar_prompt(prompt, model)
|
||||||
|
|
||||||
|
if not validation["valid"]:
|
||||||
|
raise forms.ValidationError(validation["warning"])
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
class DeleteAccountForm(forms.Form):
|
class DeleteAccountForm(forms.Form):
|
||||||
password = forms.CharField(
|
password = forms.CharField(
|
||||||
label=_("Password"), required=False, widget=forms.PasswordInput()
|
label=_("Password"), required=False, widget=forms.PasswordInput()
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 5.2.1 on 2025-09-17 10:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("ivataraccount", "0020_confirmedopenid_bluesky_handle"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="photo",
|
||||||
|
name="ai_generated",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, help_text="Whether this photo was generated by AI"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="photo",
|
||||||
|
name="ai_model",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The AI model used for generation",
|
||||||
|
max_length=50,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="photo",
|
||||||
|
name="ai_prompt",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The prompt used to generate this image",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="photo",
|
||||||
|
name="ai_quality",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The quality setting used",
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
80
ivatar/ivataraccount/migrations/0022_add_generation_task.py
Normal file
80
ivatar/ivataraccount/migrations/0022_add_generation_task.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 5.2.1 on 2025-09-17 10:25
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("ivataraccount", "0021_add_ai_generation_fields"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="GenerationTask",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ip_address",
|
||||||
|
models.GenericIPAddressField(null=True, unpack_ipv4=True),
|
||||||
|
),
|
||||||
|
("add_date", models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
("prompt", models.TextField()),
|
||||||
|
("model", models.CharField(max_length=50)),
|
||||||
|
("quality", models.CharField(max_length=20)),
|
||||||
|
("allow_nsfw", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("processing", "Processing"),
|
||||||
|
("completed", "Completed"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
("cancelled", "Cancelled"),
|
||||||
|
],
|
||||||
|
default="pending",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("progress", models.IntegerField(default=0)),
|
||||||
|
("queue_position", models.IntegerField(default=0)),
|
||||||
|
("task_id", models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
("error_message", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"generated_photo",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="ivataraccount.photo",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Generation Task",
|
||||||
|
"verbose_name_plural": "Generation Tasks",
|
||||||
|
"ordering": ["-add_date"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
22
ivatar/ivataraccount/migrations/0023_add_ai_invalid_field.py
Normal file
22
ivatar/ivataraccount/migrations/0023_add_ai_invalid_field.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 5.2.1 on 2025-09-17 10:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("ivataraccount", "0022_add_generation_task"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="photo",
|
||||||
|
name="ai_invalid",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether this AI-generated image is invalid (black, etc.)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -118,6 +118,42 @@ class BaseAccountModel(models.Model):
|
|||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationTask(BaseAccountModel):
|
||||||
|
"""
|
||||||
|
Model to track avatar generation tasks in the queue
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
("pending", _("Pending")),
|
||||||
|
("processing", _("Processing")),
|
||||||
|
("completed", _("Completed")),
|
||||||
|
("failed", _("Failed")),
|
||||||
|
("cancelled", _("Cancelled")),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
prompt = models.TextField()
|
||||||
|
model = models.CharField(max_length=50)
|
||||||
|
quality = models.CharField(max_length=20)
|
||||||
|
allow_nsfw = models.BooleanField(default=False)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
|
||||||
|
progress = models.IntegerField(default=0) # 0-100
|
||||||
|
queue_position = models.IntegerField(default=0)
|
||||||
|
task_id = models.CharField(max_length=255, blank=True, null=True) # Celery task ID
|
||||||
|
error_message = models.TextField(blank=True, null=True)
|
||||||
|
generated_photo = models.ForeignKey(
|
||||||
|
"Photo", on_delete=models.SET_NULL, blank=True, null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-add_date"]
|
||||||
|
verbose_name = _("Generation Task")
|
||||||
|
verbose_name_plural = _("Generation Tasks")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Task {self.pk}: {self.prompt[:50]}... ({self.status})"
|
||||||
|
|
||||||
|
|
||||||
class Photo(BaseAccountModel):
|
class Photo(BaseAccountModel):
|
||||||
"""
|
"""
|
||||||
Model holding the photos and information about them
|
Model holding the photos and information about them
|
||||||
@@ -128,6 +164,27 @@ class Photo(BaseAccountModel):
|
|||||||
format = models.CharField(max_length=4)
|
format = models.CharField(max_length=4)
|
||||||
access_count = models.BigIntegerField(default=0, editable=False)
|
access_count = models.BigIntegerField(default=0, editable=False)
|
||||||
|
|
||||||
|
# AI Generation metadata
|
||||||
|
ai_generated = models.BooleanField(
|
||||||
|
default=False, help_text=_("Whether this photo was generated by AI")
|
||||||
|
)
|
||||||
|
ai_prompt = models.TextField(
|
||||||
|
blank=True, null=True, help_text=_("The prompt used to generate this image")
|
||||||
|
)
|
||||||
|
ai_model = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text=_("The AI model used for generation"),
|
||||||
|
)
|
||||||
|
ai_quality = models.CharField(
|
||||||
|
max_length=20, blank=True, null=True, help_text=_("The quality setting used")
|
||||||
|
)
|
||||||
|
ai_invalid = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text=_("Whether this AI-generated image is invalid (black, etc.)"),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta: # pylint: disable=too-few-public-methods
|
class Meta: # pylint: disable=too-few-public-methods
|
||||||
"""
|
"""
|
||||||
Class attributes
|
Class attributes
|
||||||
@@ -136,6 +193,49 @@ class Photo(BaseAccountModel):
|
|||||||
verbose_name = _("photo")
|
verbose_name = _("photo")
|
||||||
verbose_name_plural = _("photos")
|
verbose_name_plural = _("photos")
|
||||||
|
|
||||||
|
def is_valid_avatar(self):
|
||||||
|
"""
|
||||||
|
Check if this photo is a valid avatar (not black/invalid)
|
||||||
|
"""
|
||||||
|
if not self.ai_generated:
|
||||||
|
return True # Non-AI photos are assumed valid
|
||||||
|
|
||||||
|
# If we've already marked it as invalid, return False
|
||||||
|
if self.ai_invalid:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
# Load the image data
|
||||||
|
image_data = io.BytesIO(self.data)
|
||||||
|
image = Image.open(image_data)
|
||||||
|
|
||||||
|
# Convert to RGB if needed
|
||||||
|
if image.mode != "RGB":
|
||||||
|
image = image.convert("RGB")
|
||||||
|
|
||||||
|
# Check if image is predominantly black (common NSFW response)
|
||||||
|
pixels = list(image.getdata())
|
||||||
|
black_pixels = sum(1 for r, g, b in pixels if r == 0 and g == 0 and b == 0)
|
||||||
|
total_pixels = len(pixels)
|
||||||
|
|
||||||
|
# If more than 95% black pixels, consider it invalid
|
||||||
|
black_ratio = black_pixels / total_pixels
|
||||||
|
is_valid = black_ratio < 0.95
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
if not is_valid:
|
||||||
|
self.ai_invalid = True
|
||||||
|
self.save(update_fields=["ai_invalid"])
|
||||||
|
|
||||||
|
return is_valid
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# If we can't analyze the image, assume it's valid
|
||||||
|
return True
|
||||||
|
|
||||||
def import_image(self, service_name, email_address):
|
def import_image(self, service_name, email_address):
|
||||||
"""
|
"""
|
||||||
Allow to import image from other (eg. Gravatar) service
|
Allow to import image from other (eg. Gravatar) service
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
<input type="text" name="bluesky_handle" required value="" placeholder="{% trans 'Bluesky handle' %}" class="form-control" id="id_bluesky_handle">
|
<input type="text" name="bluesky_handle" required value="" placeholder="{% trans 'Bluesky handle' %}" class="form-control" id="id_bluesky_handle">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">{% trans 'Assign Bluesky handle' %}</button>
|
<button type="submit" class="btn btn-primary">{% trans 'Assign Bluesky Handle' %}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
<input type="text" name="bluesky_handle" required value="" placeholder="{% trans 'Bluesky handle' %}" class="form-control" id="id_bluesky_handle">
|
<input type="text" name="bluesky_handle" required value="" placeholder="{% trans 'Bluesky handle' %}" class="form-control" id="id_bluesky_handle">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">{% trans 'Assign Bluesky handle' %}</button>
|
<button type="submit" class="btn btn-primary">{% trans 'Assign Bluesky Handle' %}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
318
ivatar/ivataraccount/templates/avatar_gallery.html
Normal file
318
ivatar/ivataraccount/templates/avatar_gallery.html
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Avatar Gallery' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.avatar-gallery-card {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-gallery-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-image-container {
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-image {
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-section {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-section h4 {
|
||||||
|
color: #495057;
|
||||||
|
border-bottom: 2px solid #007bff;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix button display issues - override base CSS */
|
||||||
|
.btn {
|
||||||
|
display: inline-block !important;
|
||||||
|
padding: 0.375rem 0.75rem !important;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||||
|
min-width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.5rem !important;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
min-width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-primary {
|
||||||
|
color: #007bff !important;
|
||||||
|
border-color: #007bff !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-primary:hover {
|
||||||
|
color: #fff !important;
|
||||||
|
background-color: #007bff !important;
|
||||||
|
border-color: #007bff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-secondary {
|
||||||
|
color: #6c757d !important;
|
||||||
|
border-color: #6c757d !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-secondary:hover {
|
||||||
|
color: #fff !important;
|
||||||
|
background-color: #6c757d !important;
|
||||||
|
border-color: #6c757d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure FontAwesome icons display */
|
||||||
|
.fa {
|
||||||
|
font-family: "FontAwesome" !important;
|
||||||
|
font-weight: normal;
|
||||||
|
display: inline-block;
|
||||||
|
font-style: normal;
|
||||||
|
font-variant: normal;
|
||||||
|
text-rendering: auto;
|
||||||
|
line-height: 1;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suppress Firefox font warnings */
|
||||||
|
@font-face {
|
||||||
|
font-family: "FontAwesome";
|
||||||
|
src: url("../fonts/fontawesome-webfont.woff2?v=4.7.0") format("woff2"),
|
||||||
|
url("../fonts/fontawesome-webfont.woff?v=4.7.0") format("woff"),
|
||||||
|
url("../fonts/fontawesome-webfont.ttf?v=4.7.0") format("truetype"),
|
||||||
|
url("../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular") format("svg");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<h2>{% trans 'Avatar Gallery' %}</h2>
|
||||||
|
<p class="lead">
|
||||||
|
{% trans 'Browse recently generated avatars for inspiration. Click on any avatar to reuse its prompt.' %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- User's Own Avatars -->
|
||||||
|
{% if user_avatars %}
|
||||||
|
<div class="mb-4 gallery-section">
|
||||||
|
<h4><i class="fa fa-user"></i> {% trans 'Your Recent Avatars' %}</h4>
|
||||||
|
<div class="row">
|
||||||
|
{% for avatar in user_avatars %}
|
||||||
|
<div class="col-md-3 col-sm-4 col-6 mb-3">
|
||||||
|
<div class="card h-100 avatar-gallery-card">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="avatar-image-container text-center d-flex justify-content-center align-items-center">
|
||||||
|
<img src="{% url 'raw_image' avatar.pk %}"
|
||||||
|
alt="Avatar"
|
||||||
|
class="img-fluid rounded avatar-image"
|
||||||
|
style="width: 120px; height: 120px; object-fit: contain;">
|
||||||
|
</div>
|
||||||
|
<div class="card-text">
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ avatar.add_date|date:"M d, Y" }}
|
||||||
|
</small>
|
||||||
|
<p class="small mb-2" style="height: 40px; overflow: hidden;">
|
||||||
|
{{ avatar.ai_prompt|truncatechars:60 }}
|
||||||
|
</p>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{% url 'reuse_prompt' avatar.pk %}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fa fa-recycle"></i> {% trans 'Reuse' %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'avatar_preview' avatar.pk %}"
|
||||||
|
class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="fa fa-eye"></i> {% trans 'View' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Community Gallery -->
|
||||||
|
<div class="mb-4 gallery-section">
|
||||||
|
<h4><i class="fa fa-users"></i> {% trans 'Community Gallery' %}</h4>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans 'Recently generated avatars from all users (last 30)' %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if avatars %}
|
||||||
|
<div class="row">
|
||||||
|
{% for avatar in avatars %}
|
||||||
|
<div class="col-md-3 col-sm-4 col-6 mb-3">
|
||||||
|
<div class="card h-100 avatar-gallery-card">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="avatar-image-container text-center d-flex justify-content-center align-items-center">
|
||||||
|
<img src="{% url 'raw_image' avatar.pk %}"
|
||||||
|
alt="Avatar"
|
||||||
|
class="img-fluid rounded avatar-image"
|
||||||
|
style="width: 120px; height: 120px; object-fit: contain;">
|
||||||
|
</div>
|
||||||
|
<div class="card-text">
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ avatar.add_date|date:"M d, Y" }}
|
||||||
|
{% if avatar.user == user %}
|
||||||
|
<span class="badge badge-info">{% trans 'Yours' %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
<p class="small mb-2" style="height: 40px; overflow: hidden;">
|
||||||
|
{{ avatar.ai_prompt|truncatechars:60 }}
|
||||||
|
</p>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{% url 'reuse_prompt' avatar.pk %}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fa fa-recycle"></i> {% trans 'Reuse' %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'avatar_preview' avatar.pk %}"
|
||||||
|
class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="fa fa-eye"></i> {% trans 'View' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if is_paginated %}
|
||||||
|
<nav aria-label="Gallery pagination">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page=1">{% trans 'First' %}</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">{% trans 'Previous' %}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">
|
||||||
|
{% trans 'Page' %} {{ page_obj.number }} {% trans 'of' %} {{ page_obj.paginator.num_pages }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}">{% trans 'Next' %}</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">{% trans 'Last' %}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fa fa-image fa-3x text-muted mb-3"></i>
|
||||||
|
<h5>{% trans 'No Avatars Yet' %}</h5>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans 'No AI-generated avatars have been created yet. Be the first to generate one!' %}
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'generate_avatar' %}" class="btn btn-primary">
|
||||||
|
<i class="fa fa-magic"></i> {% trans 'Generate Avatar' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<a href="{% url 'generate_avatar' %}" class="btn btn-primary btn-lg">
|
||||||
|
<i class="fa fa-magic"></i> {% trans 'Generate New Avatar' %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'profile' %}" class="btn btn-outline-secondary btn-lg">
|
||||||
|
<i class="fa fa-user"></i> {% trans 'Back to Profile' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tips -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<h5>{% trans 'Gallery Tips' %}</h5>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||||
|
{% trans 'Click "Reuse" to copy a prompt and modify it for your own avatar' %}
|
||||||
|
</li>
|
||||||
|
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||||
|
{% trans 'Click "View" to see the full avatar and assign it to your emails' %}
|
||||||
|
</li>
|
||||||
|
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||||
|
{% trans 'Use the gallery to find inspiration for your own avatar descriptions' %}
|
||||||
|
</li>
|
||||||
|
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||||
|
{% trans 'Your own avatars are shown at the top for quick access' %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Add click animation to reuse buttons
|
||||||
|
const reuseButtons = document.querySelectorAll('a[href*="reuse_prompt"]');
|
||||||
|
reuseButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
this.innerHTML = '<i class="fa fa-spinner fa-spin"></i> {% trans "Loading..." %}';
|
||||||
|
this.classList.add('disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add click animation to view buttons
|
||||||
|
const viewButtons = document.querySelectorAll('a[href*="avatar_preview"]');
|
||||||
|
viewButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
this.innerHTML = '<i class="fa fa-spinner fa-spin"></i> {% trans "Loading..." %}';
|
||||||
|
this.classList.add('disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
198
ivatar/ivataraccount/templates/avatar_preview.html
Normal file
198
ivatar/ivataraccount/templates/avatar_preview.html
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Avatar Preview' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<h2>{% trans 'Avatar Preview' %}</h2>
|
||||||
|
<p class="lead">
|
||||||
|
{% trans 'Here\'s your generated avatar. You can refine it or assign it to your email addresses.' %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Avatar Display -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h4>{% trans 'Generated Avatar' %}</h4>
|
||||||
|
<div class="avatar-preview-container mb-3">
|
||||||
|
<img src="{{ photo_url }}" alt="Generated Avatar" class="img-fluid rounded" style="max-width: 400px; max-height: 400px;">
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans 'Generated on' %} {{ photo.add_date|date:"F d, Y \a\t H:i" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Refinement Form -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5><i class="fa fa-magic"></i> {% trans 'Refine Avatar' %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans 'Not satisfied with the result? Modify your description and generate a new avatar.' %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Custom form rendering for better control -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_prompt">{{ form.prompt.label }}</label>
|
||||||
|
<textarea name="prompt"
|
||||||
|
id="id_prompt"
|
||||||
|
class="form-control"
|
||||||
|
rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
data-token-limit="77"
|
||||||
|
data-model="stable_diffusion"
|
||||||
|
placeholder="{{ form.prompt.field.widget.attrs.placeholder }}"
|
||||||
|
required>{{ form.prompt.value|default:'' }}</textarea>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{{ form.prompt.help_text|safe }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_model">{{ form.model.label }}</label>
|
||||||
|
<select name="model" id="id_model" class="form-control">
|
||||||
|
{% for value, label in form.model.field.choices %}
|
||||||
|
<option value="{{ value }}" {% if value == form.model.value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">{{ form.model.help_text }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_quality">{{ form.quality.label }}</label>
|
||||||
|
<select name="quality" id="id_quality" class="form-control">
|
||||||
|
{% for value, label in form.quality.field.choices %}
|
||||||
|
<option value="{{ value }}" {% if value == form.quality.value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">{{ form.quality.help_text }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_not_porn">{{ form.not_porn.label }}</label>
|
||||||
|
<input type="checkbox" name="not_porn" class="form-control" required id="id_not_porn" {% if form.not_porn.value %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_can_distribute">{{ form.can_distribute.label }}</label>
|
||||||
|
<input type="checkbox" name="can_distribute" class="form-control" required id="id_can_distribute" {% if form.can_distribute.value %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fa fa-refresh"></i> {% trans 'Regenerate Avatar' %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignment Options -->
|
||||||
|
{% if confirmed_emails %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5><i class="fa fa-envelope"></i> {% trans 'Assign to Email Addresses' %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans 'Assign this avatar to one or more of your confirmed email addresses.' %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="list-group">
|
||||||
|
{% for email in confirmed_emails %}
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{{ email.email }}</strong>
|
||||||
|
{% if email.photo_id == photo.pk %}
|
||||||
|
<span class="badge badge-success ml-2">{% trans 'Currently assigned' %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if email.photo_id != photo.pk %}
|
||||||
|
<a href="{% url 'assign_photo_email' email.pk %}?photo_id={{ photo.pk }}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fa fa-link"></i> {% trans 'Assign' %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-success">
|
||||||
|
<i class="fa fa-check"></i> {% trans 'Assigned' %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5>{% trans 'No Email Addresses' %}</h5>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans 'You need to add and confirm email addresses before you can assign avatars to them.' %}
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'add_email' %}" class="btn btn-primary">
|
||||||
|
<i class="fa fa-plus"></i> {% trans 'Add Email Address' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<a href="{% url 'generate_avatar' %}" class="btn btn-outline-primary btn-block">
|
||||||
|
<i class="fa fa-plus"></i> {% trans 'Generate New Avatar' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<a href="{% url 'profile' %}" class="btn btn-outline-secondary btn-block">
|
||||||
|
<i class="fa fa-user"></i> {% trans 'Back to Profile' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tips -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<h5>{% trans 'Tips for Better Results' %}</h5>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||||
|
{% trans 'Be more specific: "A friendly robot with blue LED eyes and silver metallic body"' %}
|
||||||
|
</li>
|
||||||
|
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||||
|
{% trans 'Add style keywords: "cartoon style", "realistic", "anime", "pixel art"' %}
|
||||||
|
</li>
|
||||||
|
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||||
|
{% trans 'Include mood: "cheerful", "serious", "mysterious", "professional"' %}
|
||||||
|
</li>
|
||||||
|
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||||
|
{% trans 'Specify lighting: "soft lighting", "dramatic shadows", "bright and clear"' %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{% endblock %}
|
||||||
145
ivatar/ivataraccount/templates/generate_avatar.html
Normal file
145
ivatar/ivataraccount/templates/generate_avatar.html
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
{% load bootstrap4 %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Generate AI Avatar' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<h2>{% trans 'Generate AI Avatar' %}</h2>
|
||||||
|
<p class="lead">
|
||||||
|
{% trans 'Create a unique avatar using artificial intelligence. Describe what you want and our AI will generate it for you.' %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Custom form rendering for better control -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_prompt">{{ form.prompt.label }}</label>
|
||||||
|
<textarea name="prompt"
|
||||||
|
id="id_prompt"
|
||||||
|
class="form-control"
|
||||||
|
rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
data-token-limit="77"
|
||||||
|
data-model="stable_diffusion"
|
||||||
|
placeholder="{{ form.prompt.field.widget.attrs.placeholder }}"
|
||||||
|
required>{{ form.prompt.value|default:'' }}</textarea>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{{ form.prompt.help_text|safe }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_model">{{ form.model.label }}</label>
|
||||||
|
<select name="model" id="id_model" class="form-control">
|
||||||
|
{% for value, label in form.model.field.choices %}
|
||||||
|
<option value="{{ value }}" {% if value == form.model.value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">{{ form.model.help_text }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_quality">{{ form.quality.label }}</label>
|
||||||
|
<select name="quality" id="id_quality" class="form-control">
|
||||||
|
{% for value, label in form.quality.field.choices %}
|
||||||
|
<option value="{{ value }}" {% if value == form.quality.value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">{{ form.quality.help_text }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_not_porn">{{ form.not_porn.label }}</label>
|
||||||
|
<input type="checkbox" name="not_porn" class="form-control" required id="id_not_porn" {% if form.not_porn.value %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_can_distribute">{{ form.can_distribute.label }}</label>
|
||||||
|
<input type="checkbox" name="can_distribute" class="form-control" required id="id_can_distribute" {% if form.can_distribute.value %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="fa fa-magic"></i> {% trans 'Generate Avatar' %}
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'profile' %}" class="btn btn-secondary btn-lg">
|
||||||
|
{% trans 'Cancel' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h4>{% trans 'Tips for Better Results' %}</h4>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||||
|
{% trans 'Be specific about appearance, style, and mood' %}
|
||||||
|
</li>
|
||||||
|
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||||
|
{% trans 'Include details like hair color, clothing, or facial expressions' %}
|
||||||
|
</li>
|
||||||
|
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||||
|
{% trans 'Try different art styles: "cartoon", "realistic", "anime", "pixel art"' %}
|
||||||
|
</li>
|
||||||
|
<li><i class="fa fa-lightbulb-o text-warning"></i>
|
||||||
|
{% trans 'Keep descriptions appropriate for all ages' %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h4>{% trans 'Example Prompts' %}</h4>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>{% trans 'Character Avatar' %}</h6>
|
||||||
|
<p class="text-muted small">
|
||||||
|
"A friendly robot with blue eyes and a silver body, cartoon style"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>{% trans 'Abstract Avatar' %}</h6>
|
||||||
|
<p class="text-muted small">
|
||||||
|
"Colorful geometric shapes forming a face, modern art style"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 alert alert-info">
|
||||||
|
<h5><i class="fa fa-info-circle"></i> {% trans 'Important Notes' %}</h5>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li>{% trans 'Avatar generation may take 30-60 seconds depending on server load' %}</li>
|
||||||
|
<li>{% trans 'Generated avatars are automatically saved to your account' %}</li>
|
||||||
|
<li>{% trans 'You can assign the generated avatar to any of your email addresses' %}</li>
|
||||||
|
<li>{% trans 'All generated content must be appropriate for all ages' %}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{% endblock %}
|
||||||
405
ivatar/ivataraccount/templates/generation_status.html
Normal file
405
ivatar/ivataraccount/templates/generation_status.html
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Avatar Generation Status' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<h2>{% trans 'Avatar Generation Status' %}</h2>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5><i class="fa fa-magic"></i> {% trans 'Generation Task' %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p><strong>{% trans 'Prompt:' %}</strong></p>
|
||||||
|
<p class="text-muted">{{ task.prompt }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p><strong>{% trans 'Model:' %}</strong> {{ task.model|title }}</p>
|
||||||
|
<p><strong>{% trans 'Quality:' %}</strong> {{ task.quality|title }}</p>
|
||||||
|
<p><strong>{% trans 'Status:' %}</strong>
|
||||||
|
<span class="badge badge-{% if task.status == 'completed' %}success{% elif task.status == 'failed' %}danger{% elif task.status == 'processing' %}warning{% else %}secondary{% endif %} status-badge">
|
||||||
|
{{ task.get_status_display }}
|
||||||
|
{% if task.status == 'processing' %}
|
||||||
|
<i class="fa fa-spinner fa-spin ml-1"></i>
|
||||||
|
{% elif task.status == 'pending' %}
|
||||||
|
<i class="fa fa-clock ml-1"></i>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="live-indicator ml-2">
|
||||||
|
<i class="fa fa-circle text-success"></i>
|
||||||
|
<small class="text-muted">Live</small>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if task.status == 'processing' %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {{ task.progress }}%"
|
||||||
|
aria-valuenow="{{ task.progress }}"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100">
|
||||||
|
{{ task.progress }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-info mt-2">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fa fa-magic"></i> {% trans 'Generating your avatar...' %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-right">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fa fa-refresh fa-spin"></i>
|
||||||
|
<span class="last-updated">{% trans 'Updated just now' %}</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif task.status == 'pending' %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fa fa-clock"></i>
|
||||||
|
{% trans 'Your avatar is in the queue. Position:' %} <strong>{{ queue_position }}</strong>
|
||||||
|
{% if queue_length > 1 %}
|
||||||
|
{% trans 'out of' %} {{ queue_length }} {% trans 'tasks' %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar" role="progressbar" style="width: 0%" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
0%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{% trans 'Waiting in queue...' %}</small>
|
||||||
|
</div>
|
||||||
|
{% elif task.status == 'completed' %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<i class="fa fa-check-circle"></i>
|
||||||
|
{% trans 'Avatar generated successfully!' %}
|
||||||
|
</div>
|
||||||
|
{% if task.generated_photo %}
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="{% url 'avatar_preview' task.generated_photo.pk %}" class="btn btn-primary btn-lg">
|
||||||
|
<i class="fa fa-eye"></i> {% trans 'View Avatar' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif task.status == 'failed' %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<i class="fa fa-exclamation-triangle"></i>
|
||||||
|
{% trans 'Avatar generation failed.' %}
|
||||||
|
{% if task.error_message %}
|
||||||
|
<br><small>{{ task.error_message }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="{% url 'generate_avatar' %}" class="btn btn-primary">
|
||||||
|
<i class="fa fa-redo"></i> {% trans 'Try Again' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Queue Information -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5><i class="fa fa-list"></i> {% trans 'Queue Information' %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<h3 class="text-primary">{{ processing_count }}</h3>
|
||||||
|
<p class="text-muted">{% trans 'Currently Processing' %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<h3 class="text-warning">{{ queue_length }}</h3>
|
||||||
|
<p class="text-muted">{% trans 'Pending Tasks' %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<h3 class="text-info">2</h3>
|
||||||
|
<p class="text-muted">{% trans 'Max Parallel Jobs' %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Your Recent Tasks -->
|
||||||
|
{% if user_tasks %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5><i class="fa fa-history"></i> {% trans 'Your Recent Tasks' %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans 'Prompt' %}</th>
|
||||||
|
<th>{% trans 'Status' %}</th>
|
||||||
|
<th>{% trans 'Created' %}</th>
|
||||||
|
<th>{% trans 'Actions' %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user_task in user_tasks %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="text-truncate d-inline-block" style="max-width: 200px;" title="{{ user_task.prompt }}">
|
||||||
|
{{ user_task.prompt }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-{% if user_task.status == 'completed' %}success{% elif user_task.status == 'failed' %}danger{% elif user_task.status == 'processing' %}warning{% else %}secondary{% endif %}">
|
||||||
|
{{ user_task.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ user_task.add_date|date:"M d, H:i" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if user_task.status == 'completed' and user_task.generated_photo %}
|
||||||
|
<a href="{% url 'avatar_preview' user_task.generated_photo.pk %}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fa fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% elif user_task.status == 'failed' %}
|
||||||
|
<a href="{% url 'generate_avatar' %}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="fa fa-redo"></i>
|
||||||
|
</a>
|
||||||
|
{% elif user_task.status == 'pending' or user_task.status == 'processing' %}
|
||||||
|
<a href="{% url 'generation_status' user_task.pk %}" class="btn btn-sm btn-outline-info">
|
||||||
|
<i class="fa fa-info-circle"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a href="{% url 'generate_avatar' %}" class="btn btn-primary btn-lg">
|
||||||
|
<i class="fa fa-plus-circle"></i> {% trans 'Generate New Avatar' %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'profile' %}" class="btn btn-secondary btn-lg">
|
||||||
|
<i class="fa fa-user"></i> {% trans 'Back to Profile' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const taskId = {{ task.pk }};
|
||||||
|
let refreshInterval;
|
||||||
|
|
||||||
|
function updateStatus() {
|
||||||
|
fetch(`/accounts/api/task_status/${taskId}/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
console.error('Error fetching status:', data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status badge with live indicators
|
||||||
|
const statusBadge = document.querySelector('.status-badge');
|
||||||
|
if (statusBadge) {
|
||||||
|
statusBadge.textContent = data.status_display;
|
||||||
|
statusBadge.className = 'badge badge-' +
|
||||||
|
(data.status === 'completed' ? 'success' :
|
||||||
|
data.status === 'failed' ? 'danger' :
|
||||||
|
data.status === 'processing' ? 'warning' : 'secondary') + ' status-badge';
|
||||||
|
|
||||||
|
// Add appropriate icons
|
||||||
|
const existingIcon = statusBadge.querySelector('i');
|
||||||
|
if (existingIcon) existingIcon.remove();
|
||||||
|
|
||||||
|
if (data.status === 'processing') {
|
||||||
|
statusBadge.innerHTML += ' <i class="fa fa-spinner fa-spin ml-1"></i>';
|
||||||
|
} else if (data.status === 'pending') {
|
||||||
|
statusBadge.innerHTML += ' <i class="fa fa-clock ml-1"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update live indicator
|
||||||
|
const liveIndicator = document.querySelector('.live-indicator i');
|
||||||
|
if (liveIndicator) {
|
||||||
|
liveIndicator.classList.remove('text-success', 'text-warning', 'text-danger');
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
liveIndicator.classList.add('text-success');
|
||||||
|
} else if (data.status === 'failed') {
|
||||||
|
liveIndicator.classList.add('text-danger');
|
||||||
|
} else {
|
||||||
|
liveIndicator.classList.add('text-warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last updated timestamp
|
||||||
|
const lastUpdated = document.querySelector('.last-updated');
|
||||||
|
if (lastUpdated) {
|
||||||
|
const now = new Date();
|
||||||
|
const timeString = now.toLocaleTimeString();
|
||||||
|
lastUpdated.textContent = `Updated at ${timeString}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress bar
|
||||||
|
const progressBar = document.querySelector('.progress-bar');
|
||||||
|
if (progressBar) {
|
||||||
|
progressBar.style.width = data.progress + '%';
|
||||||
|
progressBar.setAttribute('aria-valuenow', data.progress);
|
||||||
|
progressBar.textContent = data.progress + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update queue information
|
||||||
|
const queueInfo = document.querySelector('.alert-info');
|
||||||
|
if (queueInfo && data.status === 'pending') {
|
||||||
|
queueInfo.innerHTML = `
|
||||||
|
<i class="fa fa-clock"></i>
|
||||||
|
Your avatar is in the queue. Position: <strong>${data.queue_position}</strong>
|
||||||
|
${data.queue_length > 1 ? `out of ${data.queue_length} tasks` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update queue stats
|
||||||
|
const processingCount = document.querySelector('.text-primary');
|
||||||
|
const pendingCount = document.querySelector('.text-warning');
|
||||||
|
if (processingCount) processingCount.textContent = data.processing_count;
|
||||||
|
if (pendingCount) pendingCount.textContent = data.queue_length;
|
||||||
|
|
||||||
|
// Handle completion
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
if (data.generated_photo_id) {
|
||||||
|
// Redirect to avatar preview
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/accounts/avatar_preview/${data.generated_photo_id}/`;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle failure
|
||||||
|
if (data.status === 'failed') {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
const errorDiv = document.querySelector('.alert-danger');
|
||||||
|
if (errorDiv && data.error_message) {
|
||||||
|
errorDiv.innerHTML = `
|
||||||
|
<i class="fa fa-exclamation-triangle"></i>
|
||||||
|
Avatar generation failed.
|
||||||
|
<br><small>${data.error_message}</small>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start auto-refresh if task is pending or processing
|
||||||
|
const taskStatus = '{{ task.status }}';
|
||||||
|
if (taskStatus === 'pending' || taskStatus === 'processing') {
|
||||||
|
refreshInterval = setInterval(updateStatus, 1000); // Update every 1 second for live updates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add visual feedback for status changes
|
||||||
|
const statusBadge = document.querySelector('.badge');
|
||||||
|
if (statusBadge && taskStatus === 'processing') {
|
||||||
|
statusBadge.classList.add('pulse');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pulse {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-animated {
|
||||||
|
animation: progress-bar-stripes 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-indicator i {
|
||||||
|
animation: live-pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes live-pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-updated {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced progress bar */
|
||||||
|
.progress {
|
||||||
|
height: 25px;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
background: linear-gradient(45deg, #007bff, #0056b3);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-bar-stripes {
|
||||||
|
0% { background-position: 1rem 0; }
|
||||||
|
100% { background-position: 0 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -27,6 +27,14 @@ from .views import ResendConfirmationMailView
|
|||||||
from .views import IvatarLoginView
|
from .views import IvatarLoginView
|
||||||
from .views import DeleteAccountView
|
from .views import DeleteAccountView
|
||||||
from .views import ExportView
|
from .views import ExportView
|
||||||
|
from .views import (
|
||||||
|
GenerateAvatarView,
|
||||||
|
AvatarPreviewView,
|
||||||
|
AvatarGalleryView,
|
||||||
|
ReusePromptView,
|
||||||
|
GenerationStatusView,
|
||||||
|
task_status_api,
|
||||||
|
)
|
||||||
|
|
||||||
# Define URL patterns, self documenting
|
# Define URL patterns, self documenting
|
||||||
# To see the fancy, colorful evaluation of these use:
|
# To see the fancy, colorful evaluation of these use:
|
||||||
@@ -77,6 +85,26 @@ urlpatterns = [ # pylint: disable=invalid-name
|
|||||||
ProfileView.as_view(),
|
ProfileView.as_view(),
|
||||||
name="profile_with_profile_username",
|
name="profile_with_profile_username",
|
||||||
),
|
),
|
||||||
|
path("generate_avatar/", GenerateAvatarView.as_view(), name="generate_avatar"),
|
||||||
|
re_path(
|
||||||
|
r"generation_status/(?P<task_id>\d+)/",
|
||||||
|
GenerationStatusView.as_view(),
|
||||||
|
name="generation_status",
|
||||||
|
),
|
||||||
|
re_path(
|
||||||
|
r"api/task_status/(?P<task_id>\d+)/", task_status_api, name="task_status_api"
|
||||||
|
),
|
||||||
|
re_path(
|
||||||
|
r"avatar_preview/(?P<photo_id>\d+)/",
|
||||||
|
AvatarPreviewView.as_view(),
|
||||||
|
name="avatar_preview",
|
||||||
|
),
|
||||||
|
path("avatar_gallery/", AvatarGalleryView.as_view(), name="avatar_gallery"),
|
||||||
|
re_path(
|
||||||
|
r"reuse_prompt/(?P<photo_id>\d+)/",
|
||||||
|
ReusePromptView.as_view(),
|
||||||
|
name="reuse_prompt",
|
||||||
|
),
|
||||||
path("add_email/", AddEmailView.as_view(), name="add_email"),
|
path("add_email/", AddEmailView.as_view(), name="add_email"),
|
||||||
path("add_openid/", AddOpenIDView.as_view(), name="add_openid"),
|
path("add_openid/", AddOpenIDView.as_view(), name="add_openid"),
|
||||||
path("upload_photo/", UploadPhotoView.as_view(), name="upload_photo"),
|
path("upload_photo/", UploadPhotoView.as_view(), name="upload_photo"),
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ from django.utils.decorators import method_decorator
|
|||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.views.generic.edit import FormView, UpdateView
|
from django.views.generic.edit import FormView, UpdateView
|
||||||
from django.views.generic.base import View, TemplateView
|
from django.views.generic.base import View, TemplateView, RedirectView
|
||||||
from django.views.generic.detail import DetailView
|
from django.views.generic.detail import DetailView
|
||||||
|
from django.views.generic.list import ListView
|
||||||
from django.contrib.auth import authenticate, login
|
from django.contrib.auth import authenticate, login
|
||||||
from django.contrib.auth.forms import UserCreationForm, SetPasswordForm
|
from django.contrib.auth.forms import UserCreationForm, SetPasswordForm
|
||||||
from django.contrib.auth.views import LoginView
|
from django.contrib.auth.views import LoginView
|
||||||
@@ -30,9 +31,11 @@ from django.contrib.auth.views import (
|
|||||||
PasswordResetView as PasswordResetViewOriginal,
|
PasswordResetView as PasswordResetViewOriginal,
|
||||||
)
|
)
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.http import HttpResponseRedirect, HttpResponse
|
from django.http import HttpResponseRedirect, HttpResponse, JsonResponse
|
||||||
from django.urls import reverse_lazy, reverse
|
from django.urls import reverse_lazy, reverse
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
import logging
|
||||||
from django_openid_auth.models import UserOpenID
|
from django_openid_auth.models import UserOpenID
|
||||||
|
|
||||||
from openid import oidutil
|
from openid import oidutil
|
||||||
@@ -54,12 +57,17 @@ from .gravatar import get_photo as get_gravatar_photo
|
|||||||
|
|
||||||
from .forms import AddEmailForm, UploadPhotoForm, AddOpenIDForm
|
from .forms import AddEmailForm, UploadPhotoForm, AddOpenIDForm
|
||||||
from .forms import UpdatePreferenceForm, UploadLibravatarExportForm
|
from .forms import UpdatePreferenceForm, UploadLibravatarExportForm
|
||||||
from .forms import DeleteAccountForm
|
from .forms import DeleteAccountForm, GenerateAvatarForm
|
||||||
from .models import UnconfirmedEmail, ConfirmedEmail, Photo
|
from .models import UnconfirmedEmail, ConfirmedEmail, Photo
|
||||||
from .models import UnconfirmedOpenId, ConfirmedOpenId, DjangoOpenIDStore
|
from .models import UnconfirmedOpenId, ConfirmedOpenId, DjangoOpenIDStore
|
||||||
from .models import UserPreference
|
from .models import UserPreference
|
||||||
from .models import file_format
|
from .models import file_format
|
||||||
from .read_libravatar_export import read_gzdata as libravatar_read_gzdata
|
from .read_libravatar_export import read_gzdata as libravatar_read_gzdata
|
||||||
|
from ivatar.ai_service import generate_avatar_image, AIServiceError
|
||||||
|
from ivatar.tasks import generate_avatar_task, update_queue_positions
|
||||||
|
from ivatar.ivataraccount.models import GenerationTask
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def openid_logging(message, level=0):
|
def openid_logging(message, level=0):
|
||||||
@@ -1367,3 +1375,358 @@ class ExportView(SuccessMessageMixin, TemplateView):
|
|||||||
] = f'attachment; filename="libravatar-export_{user.username}.xml.gz"'
|
] = f'attachment; filename="libravatar-export_{user.username}.xml.gz"'
|
||||||
response.write(bytesobj.read())
|
response.write(bytesobj.read())
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateAvatarView(SuccessMessageMixin, FormView):
|
||||||
|
"""
|
||||||
|
View for generating avatars using AI text-to-image
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "generate_avatar.html"
|
||||||
|
form_class = GenerateAvatarForm
|
||||||
|
success_message = _("Avatar generated successfully!")
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
"""Pre-populate form with reused prompt if available"""
|
||||||
|
initial = super().get_initial()
|
||||||
|
|
||||||
|
# Check for reused prompt from gallery
|
||||||
|
reuse_prompt = self.request.session.get("reuse_prompt", "")
|
||||||
|
if reuse_prompt:
|
||||||
|
initial["prompt"] = reuse_prompt
|
||||||
|
# Clear the reused prompt from session
|
||||||
|
del self.request.session["reuse_prompt"]
|
||||||
|
|
||||||
|
return initial
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""
|
||||||
|
Handle form submission and queue avatar generation
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get form data
|
||||||
|
prompt = form.cleaned_data["prompt"]
|
||||||
|
model = form.cleaned_data["model"]
|
||||||
|
quality = form.cleaned_data["quality"]
|
||||||
|
|
||||||
|
# Create generation task
|
||||||
|
task = GenerationTask.objects.create(
|
||||||
|
user=self.request.user,
|
||||||
|
prompt=prompt,
|
||||||
|
model=model,
|
||||||
|
quality=quality,
|
||||||
|
allow_nsfw=False, # Always false - no NSFW override allowed
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update queue positions
|
||||||
|
update_queue_positions.delay()
|
||||||
|
|
||||||
|
# Queue the generation task
|
||||||
|
celery_task = generate_avatar_task.delay(
|
||||||
|
task_id=task.pk,
|
||||||
|
user_id=self.request.user.pk,
|
||||||
|
prompt=prompt,
|
||||||
|
model=model,
|
||||||
|
quality=quality,
|
||||||
|
allow_nsfw=False, # Always false - no NSFW override allowed
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store task ID
|
||||||
|
task.task_id = celery_task.id
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
# Store prompt in session for refinement
|
||||||
|
self.request.session["last_avatar_prompt"] = prompt
|
||||||
|
self.request.session["user_consent_given"] = True
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
self.request,
|
||||||
|
_("Avatar generation queued! You'll be notified when it's ready."),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect to task status page
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse_lazy("generation_status", kwargs={"task_id": task.pk})
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in avatar generation: {e}")
|
||||||
|
messages.error(
|
||||||
|
self.request, _("An unexpected error occurred. Please try again.")
|
||||||
|
)
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationStatusView(TemplateView):
|
||||||
|
"""
|
||||||
|
View for showing avatar generation status and progress
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "generation_status.html"
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
task_id = kwargs.get("task_id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = GenerationTask.objects.get(pk=task_id, user=self.request.user)
|
||||||
|
context["task"] = task
|
||||||
|
|
||||||
|
# Get queue information
|
||||||
|
pending_tasks = GenerationTask.objects.filter(status="pending").order_by(
|
||||||
|
"add_date"
|
||||||
|
)
|
||||||
|
processing_tasks = GenerationTask.objects.filter(status="processing")
|
||||||
|
|
||||||
|
context["queue_length"] = pending_tasks.count()
|
||||||
|
context["processing_count"] = processing_tasks.count()
|
||||||
|
context["queue_position"] = task.queue_position
|
||||||
|
|
||||||
|
# Get user's other tasks
|
||||||
|
user_tasks = GenerationTask.objects.filter(user=self.request.user).order_by(
|
||||||
|
"-add_date"
|
||||||
|
)[:5]
|
||||||
|
context["user_tasks"] = user_tasks
|
||||||
|
|
||||||
|
except GenerationTask.DoesNotExist:
|
||||||
|
messages.error(self.request, _("Generation task not found."))
|
||||||
|
return HttpResponseRedirect(reverse_lazy("profile"))
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarPreviewView(SuccessMessageMixin, FormView):
|
||||||
|
"""
|
||||||
|
View for previewing generated avatars and allowing refinements
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "avatar_preview.html"
|
||||||
|
form_class = GenerateAvatarForm
|
||||||
|
success_message = _("Avatar regenerated successfully!")
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Add photo and related data to context"""
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
photo = Photo.objects.get(
|
||||||
|
pk=self.kwargs["photo_id"], user=self.request.user
|
||||||
|
)
|
||||||
|
context["photo"] = photo
|
||||||
|
context["photo_url"] = reverse("raw_image", kwargs={"pk": photo.pk})
|
||||||
|
|
||||||
|
# Get user's confirmed emails for assignment
|
||||||
|
context["confirmed_emails"] = self.request.user.confirmedemail_set.all()
|
||||||
|
|
||||||
|
except Photo.DoesNotExist:
|
||||||
|
messages.error(self.request, _("Avatar not found."))
|
||||||
|
return HttpResponseRedirect(reverse_lazy("profile"))
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
"""Pre-populate form with current prompt if available"""
|
||||||
|
initial = super().get_initial()
|
||||||
|
initial["model"] = "stable_diffusion"
|
||||||
|
initial["quality"] = "medium"
|
||||||
|
|
||||||
|
# Try to get the prompt from the session or URL parameters
|
||||||
|
prompt = self.request.session.get("last_avatar_prompt", "")
|
||||||
|
if prompt:
|
||||||
|
initial["prompt"] = prompt
|
||||||
|
|
||||||
|
# Pre-check consent checkboxes since user already gave consent
|
||||||
|
if self.request.session.get("user_consent_given", False):
|
||||||
|
initial["not_porn"] = True
|
||||||
|
initial["can_distribute"] = True
|
||||||
|
|
||||||
|
return initial
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""
|
||||||
|
Handle refinement - generate new avatar with modified prompt
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Generate new avatar with refined prompt
|
||||||
|
prompt = form.cleaned_data["prompt"]
|
||||||
|
model = form.cleaned_data["model"]
|
||||||
|
quality = form.cleaned_data["quality"]
|
||||||
|
|
||||||
|
generated_image = generate_avatar_image(
|
||||||
|
prompt=prompt,
|
||||||
|
model=model,
|
||||||
|
size=(512, 512),
|
||||||
|
quality=quality,
|
||||||
|
allow_nsfw=False, # Always false - no NSFW override allowed
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert PIL image to bytes
|
||||||
|
img_buffer = BytesIO()
|
||||||
|
generated_image.save(img_buffer, format="PNG")
|
||||||
|
img_data = img_buffer.getvalue()
|
||||||
|
|
||||||
|
# Create new Photo object
|
||||||
|
new_photo = Photo()
|
||||||
|
new_photo.user = self.request.user
|
||||||
|
new_photo.ip_address = get_client_ip(self.request)[0]
|
||||||
|
new_photo.data = img_data
|
||||||
|
new_photo.format = "png"
|
||||||
|
|
||||||
|
# Store AI generation metadata
|
||||||
|
new_photo.ai_generated = True
|
||||||
|
new_photo.ai_prompt = prompt
|
||||||
|
new_photo.ai_model = model
|
||||||
|
new_photo.ai_quality = "medium" # Default quality
|
||||||
|
|
||||||
|
new_photo.save()
|
||||||
|
|
||||||
|
# Store the new prompt and preserve consent in session for further refinement
|
||||||
|
self.request.session["last_avatar_prompt"] = prompt
|
||||||
|
self.request.session["user_consent_given"] = True
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
self.request,
|
||||||
|
_(
|
||||||
|
"Avatar regenerated successfully! You can refine it further or assign it to your email addresses."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect to preview the new avatar
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse_lazy("avatar_preview", kwargs={"photo_id": new_photo.pk})
|
||||||
|
)
|
||||||
|
|
||||||
|
except Photo.DoesNotExist:
|
||||||
|
messages.error(self.request, _("Original avatar not found."))
|
||||||
|
return HttpResponseRedirect(reverse_lazy("profile"))
|
||||||
|
except AIServiceError as e:
|
||||||
|
messages.error(
|
||||||
|
self.request,
|
||||||
|
_("Failed to regenerate avatar: %(error)s") % {"error": str(e)},
|
||||||
|
)
|
||||||
|
return self.form_invalid(form)
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(
|
||||||
|
self.request,
|
||||||
|
_("An unexpected error occurred: %(error)s") % {"error": str(e)},
|
||||||
|
)
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarGalleryView(ListView):
|
||||||
|
"""
|
||||||
|
View for displaying a gallery of recent AI-generated avatars
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "avatar_gallery.html"
|
||||||
|
context_object_name = "avatars"
|
||||||
|
paginate_by = 12 # Show 12 avatars per page
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Get the last 30 AI-generated avatars from all users, excluding invalid ones"""
|
||||||
|
return (
|
||||||
|
Photo.objects.filter(
|
||||||
|
ai_generated=True,
|
||||||
|
ai_prompt__isnull=False,
|
||||||
|
ai_invalid=False, # Exclude invalid images
|
||||||
|
)
|
||||||
|
.exclude(ai_prompt="")
|
||||||
|
.order_by("-add_date")[:30]
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Add additional context data"""
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Add user's own avatars for quick access (filtered for validity)
|
||||||
|
context["user_avatars"] = (
|
||||||
|
Photo.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
ai_generated=True,
|
||||||
|
ai_prompt__isnull=False,
|
||||||
|
ai_invalid=False, # Exclude invalid images
|
||||||
|
)
|
||||||
|
.exclude(ai_prompt="")
|
||||||
|
.order_by("-add_date")[:10]
|
||||||
|
)
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ReusePromptView(RedirectView):
|
||||||
|
"""
|
||||||
|
View to reuse a prompt from the gallery
|
||||||
|
"""
|
||||||
|
|
||||||
|
permanent = False
|
||||||
|
|
||||||
|
def get_redirect_url(self, *args, **kwargs):
|
||||||
|
"""Redirect to generate avatar page with pre-filled prompt"""
|
||||||
|
try:
|
||||||
|
photo = Photo.objects.get(
|
||||||
|
pk=kwargs["photo_id"], ai_generated=True, ai_prompt__isnull=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the prompt in session for the generate form
|
||||||
|
self.request.session["reuse_prompt"] = photo.ai_prompt
|
||||||
|
|
||||||
|
# Redirect to generate avatar page
|
||||||
|
return reverse_lazy("generate_avatar")
|
||||||
|
|
||||||
|
except Photo.DoesNotExist:
|
||||||
|
messages.error(self.request, _("Avatar not found."))
|
||||||
|
return reverse_lazy("avatar_gallery")
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name="dispatch")
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def task_status_api(request, task_id):
|
||||||
|
"""
|
||||||
|
API endpoint to get task status for AJAX requests
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
task = GenerationTask.objects.get(pk=task_id, user=request.user)
|
||||||
|
|
||||||
|
# Get queue information
|
||||||
|
pending_tasks = GenerationTask.objects.filter(status="pending").order_by(
|
||||||
|
"add_date"
|
||||||
|
)
|
||||||
|
processing_tasks = GenerationTask.objects.filter(status="processing")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"status": task.status,
|
||||||
|
"progress": task.progress,
|
||||||
|
"queue_position": task.queue_position,
|
||||||
|
"queue_length": pending_tasks.count(),
|
||||||
|
"processing_count": processing_tasks.count(),
|
||||||
|
"error_message": task.error_message,
|
||||||
|
"generated_photo_id": task.generated_photo.pk
|
||||||
|
if task.generated_photo
|
||||||
|
else None,
|
||||||
|
"status_display": task.get_status_display(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
except GenerationTask.DoesNotExist:
|
||||||
|
return JsonResponse({"error": "Task not found"}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in task status API: {e}")
|
||||||
|
return JsonResponse({"error": "Internal server error"}, status=500)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"social_django",
|
"social_django",
|
||||||
|
"django_celery_results",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
182
ivatar/tasks.py
Normal file
182
ivatar/tasks.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Celery tasks for avatar generation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from celery import shared_task
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from ivatar.ai_service import generate_avatar_image, AIServiceError
|
||||||
|
from ivatar.ivataraccount.models import GenerationTask, Photo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, name="ivatar.tasks.generate_avatar_task")
|
||||||
|
def generate_avatar_task(self, task_id, user_id, prompt, model, quality, allow_nsfw):
|
||||||
|
"""
|
||||||
|
Background task to generate avatar images
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: GenerationTask ID
|
||||||
|
user_id: User ID
|
||||||
|
prompt: Avatar description
|
||||||
|
model: AI model to use
|
||||||
|
quality: Generation quality
|
||||||
|
allow_nsfw: Whether to allow NSFW content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Task result with photo_id or error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get the task object
|
||||||
|
task = GenerationTask.objects.get(pk=task_id)
|
||||||
|
|
||||||
|
# Update task status
|
||||||
|
task.status = "processing"
|
||||||
|
task.task_id = self.request.id
|
||||||
|
task.progress = 10
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
logger.info(f"Starting avatar generation task {task_id} for user {user_id}")
|
||||||
|
|
||||||
|
# Update progress
|
||||||
|
self.update_state(
|
||||||
|
state="PROGRESS",
|
||||||
|
meta={"progress": 20, "status": "Initializing generation..."},
|
||||||
|
)
|
||||||
|
task.progress = 20
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
# Generate the avatar image
|
||||||
|
self.update_state(
|
||||||
|
state="PROGRESS", meta={"progress": 30, "status": "Generating image..."}
|
||||||
|
)
|
||||||
|
task.progress = 30
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
generated_image = generate_avatar_image(
|
||||||
|
prompt=prompt,
|
||||||
|
model=model,
|
||||||
|
size=(512, 512),
|
||||||
|
quality=quality,
|
||||||
|
allow_nsfw=allow_nsfw,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update progress
|
||||||
|
self.update_state(
|
||||||
|
state="PROGRESS", meta={"progress": 70, "status": "Processing image..."}
|
||||||
|
)
|
||||||
|
task.progress = 70
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
# Convert PIL image to bytes
|
||||||
|
img_buffer = BytesIO()
|
||||||
|
generated_image.save(img_buffer, format="PNG")
|
||||||
|
img_data = img_buffer.getvalue()
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
user = User.objects.get(pk=user_id)
|
||||||
|
|
||||||
|
# Create Photo object
|
||||||
|
photo = Photo()
|
||||||
|
photo.user = user
|
||||||
|
photo.ip_address = "127.0.0.1" # Default IP for background tasks
|
||||||
|
photo.data = img_data
|
||||||
|
photo.format = "png"
|
||||||
|
|
||||||
|
# Store AI generation metadata
|
||||||
|
photo.ai_generated = True
|
||||||
|
photo.ai_prompt = prompt
|
||||||
|
photo.ai_model = model
|
||||||
|
photo.ai_quality = quality
|
||||||
|
|
||||||
|
photo.save()
|
||||||
|
|
||||||
|
# Update progress
|
||||||
|
self.update_state(
|
||||||
|
state="PROGRESS", meta={"progress": 90, "status": "Saving avatar..."}
|
||||||
|
)
|
||||||
|
task.progress = 90
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
# Update task with completed status
|
||||||
|
task.status = "completed"
|
||||||
|
task.progress = 100
|
||||||
|
task.generated_photo = photo
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Completed avatar generation task {task_id}, created photo {photo.pk}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "completed", "photo_id": photo.pk, "task_id": task_id}
|
||||||
|
|
||||||
|
except AIServiceError as e:
|
||||||
|
logger.error(f"AI service error in task {task_id}: {e}")
|
||||||
|
task.status = "failed"
|
||||||
|
task.error_message = str(e)
|
||||||
|
task.progress = 0
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
return {"status": "failed", "error": str(e), "task_id": task_id}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in task {task_id}: {e}")
|
||||||
|
task.status = "failed"
|
||||||
|
task.error_message = str(e)
|
||||||
|
task.progress = 0
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"task_id": task_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="ivatar.tasks.update_queue_positions")
|
||||||
|
def update_queue_positions():
|
||||||
|
"""
|
||||||
|
Update queue positions for pending tasks
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pending_tasks = GenerationTask.objects.filter(status="pending").order_by(
|
||||||
|
"add_date"
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, task in enumerate(pending_tasks):
|
||||||
|
task.queue_position = index + 1
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
logger.info(f"Updated queue positions for {len(pending_tasks)} pending tasks")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating queue positions: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="ivatar.tasks.cleanup_old_tasks")
|
||||||
|
def cleanup_old_tasks():
|
||||||
|
"""
|
||||||
|
Clean up old completed/failed tasks
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# Delete tasks older than 7 days
|
||||||
|
cutoff_date = timezone.now() - timedelta(days=7)
|
||||||
|
|
||||||
|
old_tasks = GenerationTask.objects.filter(
|
||||||
|
add_date__lt=cutoff_date, status__in=["completed", "failed", "cancelled"]
|
||||||
|
)
|
||||||
|
|
||||||
|
count = old_tasks.count()
|
||||||
|
old_tasks.delete()
|
||||||
|
|
||||||
|
logger.info(f"Cleaned up {count} old tasks")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning up old tasks: {e}")
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
<ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
|
<ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
<li><a href="{% url 'profile' %}"><i class="fa fa-fw fa-image" aria-hidden="true"></i> {% trans 'Profile' %}</a></li>
|
<li><a href="{% url 'profile' %}"><i class="fa fa-fw fa-image" aria-hidden="true"></i> {% trans 'Profile' %}</a></li>
|
||||||
|
<li><a href="{% url 'generate_avatar' %}"><i class="fa fa-fw fa-magic" aria-hidden="true"></i> {% trans 'Generate AI Avatar' %}</a></li>
|
||||||
|
<li><a href="{% url 'avatar_gallery' %}"><i class="fa fa-fw fa-th" aria-hidden="true"></i> {% trans 'Avatar Gallery' %}</a></li>
|
||||||
<li><a href="{% url 'user_preference' %}"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> {% trans 'Preferences' %}</a></li>
|
<li><a href="{% url 'user_preference' %}"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> {% trans 'Preferences' %}</a></li>
|
||||||
<li><a href="{% url 'import_photo' %}"><i class="fa fa-fw fa-envelope-square" aria-hidden="true"></i> {% trans 'Import photo via mail address' %}</a></li>
|
<li><a href="{% url 'import_photo' %}"><i class="fa fa-fw fa-envelope-square" aria-hidden="true"></i> {% trans 'Import photo via mail address' %}</a></li>
|
||||||
<li><a href="{% url 'upload_export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Import libravatar XML export' %}</a></li>
|
<li><a href="{% url 'upload_export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Import libravatar XML export' %}</a></li>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
|
<ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
|
||||||
<li><a href="{% url 'profile' %}"><i class="fa fa-fw fa-image" aria-hidden="true"></i> {% trans 'Profile' %}</a></li>
|
<li><a href="{% url 'profile' %}"><i class="fa fa-fw fa-image" aria-hidden="true"></i> {% trans 'Profile' %}</a></li>
|
||||||
|
<li><a href="{% url 'generate_avatar' %}"><i class="fa fa-fw fa-magic" aria-hidden="true"></i> {% trans 'Generate AI Avatar' %}</a></li>
|
||||||
|
<li><a href="{% url 'avatar_gallery' %}"><i class="fa fa-fw fa-th" aria-hidden="true"></i> {% trans 'Avatar Gallery' %}</a></li>
|
||||||
<li><a href="{% url 'user_preference' %}"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> {% trans 'Preferences' %}</a></li>
|
<li><a href="{% url 'user_preference' %}"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> {% trans 'Preferences' %}</a></li>
|
||||||
<li><a href="{% url 'import_photo' %}"><i class="fa fa-fw fa-envelope-square" aria-hidden="true"></i> {% trans 'Import photo via mail address' %}</a></li>
|
<li><a href="{% url 'import_photo' %}"><i class="fa fa-fw fa-envelope-square" aria-hidden="true"></i> {% trans 'Import photo via mail address' %}</a></li>
|
||||||
<li><a href="{% url 'upload_export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Import libravatar XML export' %}</a></li>
|
<li><a href="{% url 'upload_export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Import libravatar XML export' %}</a></li>
|
||||||
|
|||||||
Reference in New Issue
Block a user