729 lines
28 KiB
Python
729 lines
28 KiB
Python
import base64
|
|
import io
|
|
import json
|
|
import logging
|
|
import requests
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
from PIL import Image
|
|
HAS_PILLOW = True
|
|
except ImportError:
|
|
HAS_PILLOW = False
|
|
_logger.warning("Pillow not installed. Image optimization features will be disabled.")
|
|
|
|
|
|
class OtkSeoContentImage(models.Model):
|
|
_name = 'otk.seo.content.image'
|
|
_description = 'SEO Content Generated Image'
|
|
_order = 'sequence, id'
|
|
|
|
content_id = fields.Many2one('otk.seo.content', 'Content',
|
|
required=True, ondelete='cascade', index=True,
|
|
help='The SEO content record this image belongs to.')
|
|
|
|
sequence = fields.Integer('Sequence', default=10,
|
|
help='Order in which images appear. Lower numbers appear first.')
|
|
name = fields.Char('Image Name',
|
|
help='A descriptive name for this image. Auto-generated from the prompt if not provided.')
|
|
|
|
# === Generation Settings ===
|
|
prompt = fields.Text('Generation Prompt', required=True,
|
|
help='The text description sent to the AI to generate this image. Be specific about style, subject, composition, and mood.')
|
|
negative_prompt = fields.Text('Negative Prompt',
|
|
default='text, watermark, low quality, blurry, distorted',
|
|
help='Elements to avoid in the generated image. The AI will try not to include these aspects.')
|
|
|
|
style = fields.Selection([
|
|
('photorealistic', 'Photorealistic'),
|
|
('illustration', 'Illustration'),
|
|
('digital_art', 'Digital Art'),
|
|
('watercolor', 'Watercolor'),
|
|
('sketch', 'Sketch'),
|
|
('3d_render', '3D Render'),
|
|
], string='Style', default='photorealistic',
|
|
help='Visual style for the generated image. Photorealistic creates photo-like images; other styles create artistic interpretations.')
|
|
|
|
aspect_ratio = fields.Selection([
|
|
('1024x1024', 'Square (1:1)'),
|
|
('1792x1024', 'Landscape (16:9)'),
|
|
('1024x1792', 'Portrait (9:16)'),
|
|
], string='Size', default='1792x1024',
|
|
help='Image dimensions. Landscape (16:9) is ideal for blog headers; Square (1:1) works well for social media; Portrait (9:16) for mobile-first content.')
|
|
|
|
quality = fields.Selection([
|
|
('standard', 'Standard'),
|
|
('hd', 'HD'),
|
|
], string='Quality', default='standard',
|
|
help='Image quality level. HD produces sharper images with more detail but uses more tokens.')
|
|
|
|
# === Task Tracking ===
|
|
task_id = fields.Char('API Task ID', index=True,
|
|
help='Internal identifier for tracking this image generation task in the O\'Toolkit API.')
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('pending', 'Pending'),
|
|
('processing', 'Processing'),
|
|
('done', 'Done'),
|
|
('error', 'Error'),
|
|
], string='Status', default='draft', index=True,
|
|
help='Current status of image generation. Draft: not submitted; Pending: waiting to be sent; Processing: AI is generating; Done: image ready; Error: generation failed.')
|
|
|
|
# === Result ===
|
|
image = fields.Binary('Image', attachment=True,
|
|
help='The generated image file. Stored as an Odoo attachment.')
|
|
image_filename = fields.Char('Filename',
|
|
help='The filename for the image when downloaded.')
|
|
revised_prompt = fields.Text('Revised Prompt',
|
|
help='The prompt as revised by the AI for better generation. The AI may modify your prompt to produce better results.')
|
|
|
|
# === Optimization ===
|
|
image_original = fields.Binary('Original Image', attachment=True,
|
|
help='The original unoptimized image. Kept as backup before optimization.')
|
|
image_webp = fields.Binary('WebP Image', attachment=True,
|
|
help='WebP version of the image for modern browsers. Smaller file size with same quality.')
|
|
|
|
original_size = fields.Integer('Original Size (bytes)',
|
|
help='File size of the original generated image before any optimization.')
|
|
optimized_size = fields.Integer('Optimized Size (bytes)',
|
|
help='File size after optimization. Lower is better for web performance.')
|
|
webp_size = fields.Integer('WebP Size (bytes)',
|
|
help='File size of the WebP version.')
|
|
size_reduction = fields.Float('Size Reduction %', compute='_compute_size_reduction',
|
|
help='Percentage reduction in file size from optimization.')
|
|
|
|
width = fields.Integer('Width (px)',
|
|
help='Image width in pixels.')
|
|
height = fields.Integer('Height (px)',
|
|
help='Image height in pixels.')
|
|
|
|
is_optimized = fields.Boolean('Optimized', default=False,
|
|
help='Indicates if this image has been optimized for web.')
|
|
optimization_quality = fields.Integer('Optimization Quality', default=85,
|
|
help='JPEG/WebP quality level (1-100). Lower means smaller files but less quality. 85 is a good balance.')
|
|
|
|
# === Usage ===
|
|
alt_text = fields.Char('Alt Text',
|
|
help='SEO-friendly alt text describing the image. Important for accessibility and search engine optimization.')
|
|
is_cover = fields.Boolean('Use as Cover Image',
|
|
help='If enabled, this image will be used as the cover/header image for the blog post.')
|
|
is_og_image = fields.Boolean('Use as OpenGraph Image',
|
|
help='If enabled, this image will be used for social media sharing previews (Facebook, Twitter, LinkedIn).')
|
|
inserted_in_content = fields.Boolean('Inserted in Content',
|
|
help='Indicates whether this image has been embedded within the content body.')
|
|
|
|
# === Tracking ===
|
|
token_cost = fields.Integer('Tokens Used',
|
|
help='Number of API tokens consumed to generate this image. HD quality images cost more tokens.')
|
|
error_message = fields.Text('Error Message',
|
|
help='If generation failed, this contains the error details. Check this to troubleshoot issues.')
|
|
|
|
# === Retry Logic ===
|
|
retry_count = fields.Integer('Retry Count', default=0,
|
|
help='Number of retry attempts made for this image generation.')
|
|
max_retries = fields.Integer('Max Retries', default=3,
|
|
help='Maximum number of retry attempts before permanently failing.')
|
|
next_retry_at = fields.Datetime('Next Retry At',
|
|
help='Scheduled time for next retry attempt.')
|
|
processing_since = fields.Datetime('Processing Since',
|
|
help='Timestamp of when processing started, for stuck task detection.')
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
"""Auto-generate name if not provided."""
|
|
for vals in vals_list:
|
|
if not vals.get('name') and vals.get('prompt'):
|
|
vals['name'] = vals['prompt'][:50] + '...' if len(vals['prompt']) > 50 else vals['prompt']
|
|
return super().create(vals_list)
|
|
|
|
def _get_api_config(self):
|
|
"""Get API configuration."""
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
base_url = ICP.get_param('otoolkit.api.endpoint', '')
|
|
api_key = ICP.get_param('otoolkit_api_key', '')
|
|
|
|
if not api_key:
|
|
raise UserError(_("Please configure your O'Toolkit API key in Settings."))
|
|
if not base_url:
|
|
raise UserError(_("O'Toolkit API endpoint is not configured."))
|
|
|
|
return base_url, api_key
|
|
|
|
def action_generate(self):
|
|
"""Submit image for generation."""
|
|
self.ensure_one()
|
|
|
|
if not self.prompt:
|
|
raise UserError(_("Please provide a generation prompt."))
|
|
|
|
base_url, api_key = self._get_api_config()
|
|
|
|
headers = {
|
|
'Content-Type': 'application/json',
|
|
'Odoo-Api-Key': api_key,
|
|
}
|
|
|
|
payload = {
|
|
'prompt': self.prompt,
|
|
'style': self.style,
|
|
'size': self.aspect_ratio,
|
|
'quality': self.quality,
|
|
'odoo_user_id': self.env.uid,
|
|
}
|
|
|
|
if self.negative_prompt:
|
|
payload['negative_prompt'] = self.negative_prompt
|
|
|
|
try:
|
|
response = requests.post(
|
|
f"{base_url.rstrip('/')}/api/seo-content/image/",
|
|
headers=headers,
|
|
json=payload,
|
|
timeout=30
|
|
)
|
|
|
|
if response.status_code == 400:
|
|
try:
|
|
error_data = response.json()
|
|
error_msg = error_data.get('error') or error_data.get('detail') or 'Unknown error'
|
|
except (ValueError, json.JSONDecodeError):
|
|
error_msg = response.text[:500] if response.text else 'Unknown error'
|
|
raise UserError(_("API Error: %s") % error_msg)
|
|
|
|
if response.status_code >= 400:
|
|
raise UserError(_("API Error (%s): %s") % (response.status_code, response.text[:500]))
|
|
|
|
response.raise_for_status()
|
|
|
|
try:
|
|
result = response.json()
|
|
except (ValueError, json.JSONDecodeError):
|
|
raise UserError(_("Invalid response from API: %s") % response.text[:200])
|
|
|
|
if result.get('success') and result.get('task_id'):
|
|
self.write({
|
|
'task_id': result['task_id'],
|
|
'state': 'processing',
|
|
'error_message': False,
|
|
'processing_since': fields.Datetime.now(),
|
|
})
|
|
else:
|
|
raise UserError(_("Failed to submit image generation: %s") % result.get('error', 'Unknown error'))
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
_logger.error(f"Image generation request error: {e}")
|
|
self.write({
|
|
'state': 'error',
|
|
'error_message': str(e),
|
|
})
|
|
raise UserError(_("Failed to submit image generation: %s") % str(e))
|
|
|
|
def action_retry(self):
|
|
"""Retry failed image generation."""
|
|
self.ensure_one()
|
|
self.write({
|
|
'state': 'pending',
|
|
'error_message': False,
|
|
'task_id': False,
|
|
})
|
|
return self.action_generate()
|
|
|
|
def action_regenerate(self):
|
|
"""Regenerate image with current settings (allows modifying prompt/style first)."""
|
|
self.ensure_one()
|
|
|
|
if self.state not in ('done', 'error'):
|
|
raise UserError(_("Can only regenerate completed or failed images."))
|
|
|
|
# Store current image as backup (original) if we have an image and no backup yet
|
|
if self.image and not self.image_original:
|
|
self.image_original = self.image
|
|
|
|
# Reset state and clear generation results
|
|
self.write({
|
|
'state': 'pending',
|
|
'task_id': False,
|
|
'error_message': False,
|
|
'image': False,
|
|
'image_webp': False,
|
|
'revised_prompt': False,
|
|
'is_optimized': False,
|
|
'optimized_size': 0,
|
|
'webp_size': 0,
|
|
'token_cost': 0,
|
|
})
|
|
|
|
# Submit for generation
|
|
return self.action_generate()
|
|
|
|
def action_poll_status(self):
|
|
"""Check the status of image generation."""
|
|
self.ensure_one()
|
|
|
|
if not self.task_id:
|
|
raise UserError(_("No task ID found. Please generate the image first."))
|
|
|
|
base_url, api_key = self._get_api_config()
|
|
|
|
headers = {
|
|
'Content-Type': 'application/json',
|
|
'Odoo-Api-Key': api_key,
|
|
}
|
|
|
|
# Safely convert task_id to int
|
|
try:
|
|
task_id_int = int(self.task_id)
|
|
except (ValueError, TypeError):
|
|
raise UserError(_("Invalid task ID format: %s") % self.task_id)
|
|
|
|
try:
|
|
response = requests.post(
|
|
f"{base_url.rstrip('/')}/api/tasks/",
|
|
headers=headers,
|
|
json={'task_ids': [task_id_int]},
|
|
timeout=30
|
|
)
|
|
|
|
response.raise_for_status()
|
|
|
|
try:
|
|
data = response.json()
|
|
except (ValueError, json.JSONDecodeError):
|
|
_logger.error("Invalid JSON response from tasks API: %s", response.text[:200])
|
|
return
|
|
|
|
tasks = data.get('tasks', []) if isinstance(data, dict) else data
|
|
|
|
if tasks and len(tasks) > 0:
|
|
task = tasks[0]
|
|
status = task.get('status')
|
|
|
|
if status == 'completed':
|
|
result = task.get('result', {})
|
|
self._process_completed_image(result)
|
|
elif status == 'failed':
|
|
error_msg = task.get('result', {}).get('error', 'Generation failed')
|
|
error_type = task.get('result', {}).get('type', '')
|
|
# Retry on transient errors
|
|
if error_type in ('rate_limit', 'timeout', 'connection_error'):
|
|
self._schedule_retry(error_msg)
|
|
else:
|
|
self.write({
|
|
'state': 'error',
|
|
'error_message': error_msg,
|
|
})
|
|
# Notify user
|
|
if self.content_id and self.content_id.create_uid and self.content_id.create_uid.partner_id:
|
|
self.env['bus.bus']._sendone(
|
|
self.content_id.create_uid.partner_id,
|
|
'simple_notification',
|
|
{
|
|
'title': _("Image Generation Failed"),
|
|
'message': _("An image for '%s' could not be generated.") % (
|
|
self.content_id.generated_title or self.content_id.name),
|
|
'type': 'warning',
|
|
'sticky': False,
|
|
}
|
|
)
|
|
self._check_content_images_complete()
|
|
# else: still processing, do nothing
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
_logger.error("Image status poll error: %s", e)
|
|
|
|
def _process_completed_image(self, result):
|
|
"""Process a completed image generation result."""
|
|
self.ensure_one()
|
|
|
|
image_base64 = result.get('image_base64')
|
|
if not image_base64:
|
|
self.write({
|
|
'state': 'error',
|
|
'error_message': 'No image data received',
|
|
})
|
|
return
|
|
|
|
# Parse size from aspect_ratio
|
|
size_parts = self.aspect_ratio.split('x')
|
|
width = int(size_parts[0]) if len(size_parts) == 2 else 1024
|
|
height = int(size_parts[1]) if len(size_parts) == 2 else 1024
|
|
|
|
# Generate alt text if not set
|
|
alt_text = self.alt_text
|
|
if not alt_text and self.content_id:
|
|
alt_text = f"{self.content_id.generated_title or self.content_id.name} - Image"
|
|
|
|
self.write({
|
|
'image': image_base64,
|
|
'image_filename': f"seo_image_{self.id}.png",
|
|
'revised_prompt': result.get('revised_prompt', ''),
|
|
'width': width,
|
|
'height': height,
|
|
'token_cost': result.get('cost', 0),
|
|
'state': 'done',
|
|
'error_message': False,
|
|
'alt_text': alt_text,
|
|
})
|
|
|
|
# Notify user of individual image completion
|
|
if self.content_id and self.content_id.create_uid and self.content_id.create_uid.partner_id:
|
|
total = len(self.content_id.image_ids)
|
|
done = len(self.content_id.image_ids.filtered(lambda i: i.state == 'done'))
|
|
self.env['bus.bus']._sendone(
|
|
self.content_id.create_uid.partner_id,
|
|
'simple_notification',
|
|
{
|
|
'title': _("Image Generated"),
|
|
'message': _("Image %d/%d for '%s' is ready.") % (
|
|
done, total, self.content_id.generated_title or self.content_id.name),
|
|
'type': 'info',
|
|
'sticky': False,
|
|
}
|
|
)
|
|
|
|
# Update content state if all images are done
|
|
self._check_content_images_complete()
|
|
|
|
def _schedule_retry(self, error_msg):
|
|
"""Schedule a retry with exponential backoff, or permanently fail."""
|
|
self.ensure_one()
|
|
backoff_minutes = [1, 5, 15]
|
|
|
|
if self.retry_count >= self.max_retries:
|
|
self.write({
|
|
'state': 'error',
|
|
'error_message': _("Permanently failed after %d retries. Last error: %s") % (
|
|
self.retry_count, error_msg),
|
|
'processing_since': False,
|
|
})
|
|
_logger.warning("Image %s permanently failed after %d retries", self.id, self.retry_count)
|
|
# Notify user of permanent failure
|
|
if self.content_id and self.content_id.create_uid and self.content_id.create_uid.partner_id:
|
|
self.env['bus.bus']._sendone(
|
|
self.content_id.create_uid.partner_id,
|
|
'simple_notification',
|
|
{
|
|
'title': _("Image Generation Failed"),
|
|
'message': _("An image for '%s' failed after %d retries.") % (
|
|
self.content_id.generated_title or self.content_id.name,
|
|
self.retry_count),
|
|
'type': 'warning',
|
|
'sticky': True,
|
|
}
|
|
)
|
|
self._check_content_images_complete()
|
|
return
|
|
|
|
from datetime import timedelta
|
|
delay = backoff_minutes[min(self.retry_count, len(backoff_minutes) - 1)]
|
|
next_retry = fields.Datetime.now() + timedelta(minutes=delay)
|
|
|
|
self.write({
|
|
'state': 'pending',
|
|
'retry_count': self.retry_count + 1,
|
|
'next_retry_at': next_retry,
|
|
'error_message': _("Retry %d/%d scheduled. Error: %s") % (
|
|
self.retry_count + 1, self.max_retries, error_msg),
|
|
'processing_since': False,
|
|
'task_id': False,
|
|
})
|
|
_logger.info("Image %s retry %d/%d scheduled at %s",
|
|
self.id, self.retry_count, self.max_retries, next_retry)
|
|
|
|
def _check_content_images_complete(self):
|
|
"""Check if all images for the content are complete.
|
|
|
|
Transitions content to 'review' when no images are pending/processing,
|
|
regardless of whether they are 'done' or 'error'.
|
|
"""
|
|
if not self.content_id:
|
|
return
|
|
|
|
content = self.content_id
|
|
pending_images = content.image_ids.filtered(
|
|
lambda i: i.state in ('pending', 'processing')
|
|
)
|
|
|
|
if not pending_images and content.state == 'images_pending':
|
|
content.write({'state': 'review'})
|
|
# Send bus notification
|
|
if content.create_uid and content.create_uid.partner_id:
|
|
self.env['bus.bus']._sendone(
|
|
content.create_uid.partner_id,
|
|
'simple_notification',
|
|
{
|
|
'title': _("Images Ready!"),
|
|
'message': _("All images for '%s' are ready.") % (
|
|
content.generated_title or content.name),
|
|
'type': 'success',
|
|
'sticky': False,
|
|
}
|
|
)
|
|
|
|
@api.model
|
|
def cron_submit_pending_images(self):
|
|
"""Cron job to submit pending images for generation.
|
|
|
|
Also picks up images with pending retries.
|
|
"""
|
|
from datetime import timedelta
|
|
now = fields.Datetime.now()
|
|
|
|
# Include images with pending retries (next_retry_at <= now)
|
|
pending = self.search([
|
|
'|',
|
|
'&', ('state', '=', 'pending'), ('next_retry_at', '=', False),
|
|
'&', ('state', '=', 'pending'), ('next_retry_at', '<=', now),
|
|
], limit=10)
|
|
|
|
for image in pending:
|
|
try:
|
|
image.write({'next_retry_at': False})
|
|
image.action_generate()
|
|
except Exception as e:
|
|
_logger.error(f"Failed to submit image {image.id}: {e}")
|
|
image.write({
|
|
'state': 'error',
|
|
'error_message': str(e),
|
|
})
|
|
|
|
@api.model
|
|
def cron_poll_image_status(self):
|
|
"""Cron job to poll status of processing images.
|
|
|
|
Also handles stuck images (processing > 30 min) and retries on failure.
|
|
"""
|
|
from datetime import timedelta
|
|
now = fields.Datetime.now()
|
|
|
|
# --- Detect stuck images (processing for > 30 minutes) ---
|
|
stuck_cutoff = now - timedelta(minutes=30)
|
|
stuck_images = self.search([
|
|
('state', '=', 'processing'),
|
|
('processing_since', '!=', False),
|
|
('processing_since', '<', stuck_cutoff),
|
|
], limit=10)
|
|
for img in stuck_images:
|
|
_logger.warning("Image %s stuck in processing since %s, scheduling retry",
|
|
img.id, img.processing_since)
|
|
img._schedule_retry(_("Task stuck for over 30 minutes"))
|
|
|
|
# --- Poll processing tasks ---
|
|
processing = self.search([
|
|
('state', '=', 'processing'),
|
|
('task_id', '!=', False),
|
|
], limit=20)
|
|
|
|
if not processing:
|
|
return
|
|
|
|
# Batch poll all tasks - safely convert task_ids to int
|
|
task_ids = []
|
|
task_id_map = {} # Maps int task_id back to image
|
|
for img in processing:
|
|
if img.task_id:
|
|
try:
|
|
task_id_int = int(img.task_id)
|
|
task_ids.append(task_id_int)
|
|
task_id_map[str(task_id_int)] = img
|
|
except (ValueError, TypeError):
|
|
_logger.warning("Invalid task_id format for image %s: %s", img.id, img.task_id)
|
|
img.write({
|
|
'state': 'error',
|
|
'error_message': f'Invalid task ID format: {img.task_id}',
|
|
})
|
|
|
|
if not task_ids:
|
|
return
|
|
|
|
try:
|
|
base_url, api_key = processing[0]._get_api_config()
|
|
|
|
headers = {
|
|
'Content-Type': 'application/json',
|
|
'Odoo-Api-Key': api_key,
|
|
}
|
|
|
|
response = requests.post(
|
|
f"{base_url.rstrip('/')}/api/tasks/",
|
|
headers=headers,
|
|
json={'task_ids': task_ids},
|
|
timeout=30
|
|
)
|
|
|
|
response.raise_for_status()
|
|
|
|
try:
|
|
data = response.json()
|
|
except (ValueError, json.JSONDecodeError):
|
|
_logger.error("Invalid JSON response from tasks API in cron: %s", response.text[:200])
|
|
return
|
|
|
|
tasks = data.get('tasks', []) if isinstance(data, dict) else data
|
|
|
|
# Create mapping of task_id to result
|
|
task_result_map = {str(t.get('id')): t for t in tasks if t.get('id')}
|
|
|
|
for task_id_str, image in task_id_map.items():
|
|
task = task_result_map.get(task_id_str)
|
|
if not task:
|
|
continue
|
|
|
|
status = task.get('status')
|
|
|
|
if status == 'completed':
|
|
result = task.get('result', {})
|
|
image._process_completed_image(result)
|
|
elif status == 'failed':
|
|
error_msg = task.get('result', {}).get('error', 'Generation failed')
|
|
image._schedule_retry(error_msg)
|
|
|
|
except Exception as e:
|
|
_logger.error("Image status poll cron error: %s", e)
|
|
|
|
@api.depends('original_size', 'optimized_size')
|
|
def _compute_size_reduction(self):
|
|
for record in self:
|
|
if record.original_size and record.optimized_size:
|
|
record.size_reduction = round(
|
|
(1 - record.optimized_size / record.original_size) * 100, 1
|
|
)
|
|
else:
|
|
record.size_reduction = 0
|
|
|
|
def action_optimize(self):
|
|
"""Optimize image for web - compress and create WebP version."""
|
|
self.ensure_one()
|
|
|
|
if not HAS_PILLOW:
|
|
raise UserError(_("Image optimization requires Pillow library. Please install it with: pip install Pillow"))
|
|
|
|
if not self.image:
|
|
raise UserError(_("No image to optimize."))
|
|
|
|
try:
|
|
# Decode original image
|
|
image_data = base64.b64decode(self.image)
|
|
original_size = len(image_data)
|
|
|
|
# Store original if not already stored
|
|
if not self.image_original:
|
|
self.image_original = self.image
|
|
|
|
# Open with Pillow
|
|
img = Image.open(io.BytesIO(image_data))
|
|
|
|
# Get actual dimensions
|
|
width, height = img.size
|
|
|
|
# Convert to RGB if necessary (for JPEG/WebP compatibility)
|
|
if img.mode in ('RGBA', 'P'):
|
|
# Create white background for transparency
|
|
background = Image.new('RGB', img.size, (255, 255, 255))
|
|
if img.mode == 'P':
|
|
img = img.convert('RGBA')
|
|
background.paste(img, mask=img.split()[3] if len(img.split()) > 3 else None)
|
|
img = background
|
|
elif img.mode != 'RGB':
|
|
img = img.convert('RGB')
|
|
|
|
quality = self.optimization_quality or 85
|
|
|
|
# Optimize as JPEG
|
|
jpeg_buffer = io.BytesIO()
|
|
img.save(jpeg_buffer, format='JPEG', quality=quality, optimize=True)
|
|
optimized_jpeg = jpeg_buffer.getvalue()
|
|
optimized_size = len(optimized_jpeg)
|
|
|
|
# Try to create WebP version (requires libwebp)
|
|
webp_data = None
|
|
webp_size = 0
|
|
try:
|
|
webp_buffer = io.BytesIO()
|
|
img.save(webp_buffer, format='WEBP', quality=quality)
|
|
webp_data = webp_buffer.getvalue()
|
|
webp_size = len(webp_data)
|
|
except Exception as webp_error:
|
|
_logger.warning(f"WebP conversion not available: {webp_error}")
|
|
|
|
# Update record
|
|
update_vals = {
|
|
'image': base64.b64encode(optimized_jpeg),
|
|
'image_filename': f"seo_image_{self.id}.jpg",
|
|
'original_size': original_size,
|
|
'optimized_size': optimized_size,
|
|
'webp_size': webp_size,
|
|
'width': width,
|
|
'height': height,
|
|
'is_optimized': True,
|
|
}
|
|
if webp_data:
|
|
update_vals['image_webp'] = base64.b64encode(webp_data)
|
|
|
|
self.write(update_vals)
|
|
|
|
_logger.info(
|
|
f"Image {self.id} optimized: {original_size} -> {optimized_size} bytes "
|
|
f"({round((1 - optimized_size/original_size) * 100, 1)}% reduction)"
|
|
+ (f", WebP: {webp_size} bytes" if webp_size else "")
|
|
)
|
|
|
|
message = _('Reduced from %s KB to %s KB (%.1f%% smaller)') % (
|
|
round(original_size / 1024, 1),
|
|
round(optimized_size / 1024, 1),
|
|
(1 - optimized_size / original_size) * 100,
|
|
)
|
|
if webp_size:
|
|
message += _('. WebP: %s KB') % round(webp_size / 1024, 1)
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Image optimization failed: {e}")
|
|
raise UserError(_("Image optimization failed: %s") % str(e))
|
|
|
|
def action_restore_original(self):
|
|
"""Restore the original unoptimized image."""
|
|
self.ensure_one()
|
|
|
|
if not self.image_original:
|
|
raise UserError(_("No original image stored."))
|
|
|
|
self.write({
|
|
'image': self.image_original,
|
|
'image_filename': f"seo_image_{self.id}.png",
|
|
'is_optimized': False,
|
|
'optimized_size': 0,
|
|
'image_webp': False,
|
|
'webp_size': 0,
|
|
})
|
|
|
|
def action_auto_optimize_all(self):
|
|
"""Optimize all non-optimized images."""
|
|
images = self.search([
|
|
('state', '=', 'done'),
|
|
('is_optimized', '=', False),
|
|
('image', '!=', False),
|
|
])
|
|
|
|
optimized_count = 0
|
|
for image in images:
|
|
try:
|
|
image.action_optimize()
|
|
optimized_count += 1
|
|
except Exception as e:
|
|
_logger.error(f"Failed to optimize image {image.id}: {e}")
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _('Batch Optimization Complete'),
|
|
'message': _('%d images optimized.') % optimized_count,
|
|
'type': 'success',
|
|
'sticky': False,
|
|
'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'},
|
|
}
|
|
}
|