diff --git a/deltatech_image_optimize/README.rst b/deltatech_image_optimize/README.rst new file mode 100644 index 000000000..de77f5d02 --- /dev/null +++ b/deltatech_image_optimize/README.rst @@ -0,0 +1,138 @@ +=============== +Image Optimizer +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:52431cbd7b6fd0fc59d3bda1dabac2b5135bb31f428e09674eea8f8fe8b3af2c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/github-dhongu%2Fdeltatech-lightgray.png?logo=github + :target: https://github.com/dhongu/deltatech/tree/18.0/deltatech_image_optimize + :alt: dhongu/deltatech + +|badge1| |badge2| + +Image Optimizer +=============== + +Recompresses oversized **original** image attachments (``image_1920`` +and ``image_variant_1920`` by default) to reclaim filestore space. + +For each targeted image the module: + +- downscales it to a maximum side of ``max_dim`` pixels (default 1920); +- re-encodes photos without transparency as progressive **JPEG** at the + configured quality (default 85); +- keeps images with transparency as optimized **PNG** (alpha preserved); +- skips animated GIFs (never flattens the animation); +- keeps the result only when it is actually smaller. + +The optimized image is written back **through the owning record**, so +Odoo regenerates the resized variants (``image_1024/512/256/128``) from +the new, smaller original. + +Processed attachments are flagged (``deltatech_image_optimized``) and +skipped on the next run. Because Odoo creates a fresh attachment +whenever an image field is updated, newly uploaded or changed images are +picked up automatically. + +Configuration +------------- + +System Parameters (Settings → Technical → System Parameters): + ++--------------------------------------------+-------------------------------+----------------------+ +| Key | Default | Meaning | ++============================================+===============================+======================+ +| ``deltatech_image_optimize.quality`` | 85 | JPEG quality (1..95) | ++--------------------------------------------+-------------------------------+----------------------+ +| ``deltatech_image_optimize.max_dim`` | 1920 | max side in pixels | ++--------------------------------------------+-------------------------------+----------------------+ +| ``deltatech_image_optimize.min_size`` | 102400 | only images larger | +| | | than this (bytes) | ++--------------------------------------------+-------------------------------+----------------------+ +| ``deltatech_image_optimize.batch`` | 1000 | images per cron run | ++--------------------------------------------+-------------------------------+----------------------+ +| ``deltatech_image_optimize.target_fields`` | image_1920,image_variant_1920 | fields to optimize | ++--------------------------------------------+-------------------------------+----------------------+ + +Scheduled action +---------------- + +``Image Optimizer: recompress oversized images`` runs daily. It is +**disabled by default** — review the configuration, test on staging, +then enable it. + +For a large one-time backlog you can loop the batch method from the +shell: + +.. code:: python + + while env["ir.attachment"]._dt_image_optimize_run(limit=2000)["scanned"]: + env.cr.commit() + +**Table of contents** + +.. contents:: + :local: + +Changelog +========= + +Changelog +========= + +18.0.1.0.0 (2025) +----------------- + +- Initial release. +- Add ``ir.attachment.deltatech_image_optimized`` marker field. +- Recompress original image attachments (``image_1920`` / + ``image_variant_1920``): downscale to ``max_dim``, JPEG (quality + tuned) for opaque images, optimized PNG for images with alpha, skip + animated GIFs. +- Write the optimized image back through the owning record so Odoo + regenerates the resized variants from the smaller original. +- Configurable via ``ir.config_parameter`` (quality, max_dim, min_size, + batch, target_fields). +- Daily scheduled action + ``Image Optimizer: recompress oversized images``, disabled by default. + +Bug Tracker +=========== + +Bugs are tracked on `Terrabit Issues `_. +In case of trouble, please check there if your issue has already been reported. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Terrabit +* Dorin Hongu + +Maintainers +----------- + +.. |maintainer-dhongu| image:: https://github.com/dhongu.png?size=40px + :target: https://github.com/dhongu + :alt: dhongu + +Current maintainer: + +|maintainer-dhongu| + +This module is part of the `dhongu/deltatech `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/deltatech_image_optimize/__init__.py b/deltatech_image_optimize/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/deltatech_image_optimize/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/deltatech_image_optimize/__manifest__.py b/deltatech_image_optimize/__manifest__.py new file mode 100644 index 000000000..a6146d76e --- /dev/null +++ b/deltatech_image_optimize/__manifest__.py @@ -0,0 +1,22 @@ +# © 2025 Terrabit +# Dorin Hongu + + + + + deltatech_image_optimize.quality + 85 + + + + + deltatech_image_optimize.max_dim + 1920 + + + + + deltatech_image_optimize.min_size + 102400 + + + + + deltatech_image_optimize.batch + 1000 + + + + + deltatech_image_optimize.target_fields + image_1920,image_variant_1920 + + + diff --git a/deltatech_image_optimize/data/ir_cron.xml b/deltatech_image_optimize/data/ir_cron.xml new file mode 100644 index 000000000..5c62d6149 --- /dev/null +++ b/deltatech_image_optimize/data/ir_cron.xml @@ -0,0 +1,17 @@ + + + + + Image Optimizer: recompress oversized images + + code + model._dt_image_optimize_cron() + 1 + days + + + + + + diff --git a/deltatech_image_optimize/models/__init__.py b/deltatech_image_optimize/models/__init__.py new file mode 100644 index 000000000..aaf38a167 --- /dev/null +++ b/deltatech_image_optimize/models/__init__.py @@ -0,0 +1 @@ +from . import ir_attachment diff --git a/deltatech_image_optimize/models/ir_attachment.py b/deltatech_image_optimize/models/ir_attachment.py new file mode 100644 index 000000000..f044a7bba --- /dev/null +++ b/deltatech_image_optimize/models/ir_attachment.py @@ -0,0 +1,164 @@ +import base64 +import io +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + +try: + from PIL import Image +except ImportError: # pragma: no cover + Image = None + +DEFAULT_TARGET_FIELDS = "image_1920,image_variant_1920" + + +class IrAttachment(models.Model): + _inherit = "ir.attachment" + + deltatech_image_optimized = fields.Datetime( + string="Image Optimized On", + copy=False, + index=True, + help="Set once the image optimizer has recompressed this image " + "attachment. Prevents reprocessing on the next run. Updated images " + "get a fresh attachment (empty flag) and are picked up automatically.", + ) + + # ------------------------------------------------------------------ + # Configuration + # ------------------------------------------------------------------ + @api.model + def _dt_image_optimize_params(self): + """Read the optimizer configuration from ir.config_parameter.""" + get = self.env["ir.config_parameter"].sudo().get_param + return { + "quality": max(1, min(95, int(get("deltatech_image_optimize.quality", 85)))), + "max_dim": int(get("deltatech_image_optimize.max_dim", 1920)), + "min_size": int(get("deltatech_image_optimize.min_size", 102400)), + "batch": int(get("deltatech_image_optimize.batch", 1000)), + "fields": [ + name.strip() + for name in get("deltatech_image_optimize.target_fields", DEFAULT_TARGET_FIELDS).split(",") + if name.strip() + ], + } + + # ------------------------------------------------------------------ + # Core recompression + # ------------------------------------------------------------------ + @staticmethod + def _dt_image_recompress(raw, quality, max_dim): + """Recompress raw image bytes. + + - photos without transparency -> JPEG (quality tuned, progressive) + - images with transparency -> optimized PNG (alpha preserved) + - animated GIFs -> skipped (never flattened) + + :return: smaller image bytes, or ``None`` when the image cannot be + optimized or the result would not be smaller. + """ + if not raw or Image is None: + return None + try: + img = Image.open(io.BytesIO(raw)) + img.load() + except Exception: # noqa: BLE001 - any unreadable image is skipped + return None + fmt = (img.format or "").upper() + if fmt == "GIF" and getattr(img, "is_animated", False): + return None + resample = getattr(Image, "Resampling", Image).LANCZOS + if max_dim and max(img.size) > max_dim: + img.thumbnail((max_dim, max_dim), resample) + has_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info) + buf = io.BytesIO() + if has_alpha: + img.convert("RGBA").save(buf, format="PNG", optimize=True) + else: + img.convert("RGB").save(buf, format="JPEG", quality=quality, optimize=True, progressive=True) + data = buf.getvalue() + return data if len(data) < len(raw) else None + + # ------------------------------------------------------------------ + # Batch runner + # ------------------------------------------------------------------ + @api.model + def _dt_image_optimize_run(self, limit=None): + """Optimize a batch of original image attachments. + + The smaller image is written back through the owning record + (``record.write({res_field: ...})``) so Odoo regenerates the resized + variants (image_1024/512/256/128) from the new, smaller original. + + :return: dict with ``scanned``, ``optimized`` and ``freed`` (bytes). + """ + if Image is None: + _logger.warning("Pillow (PIL) is not available; image optimizer skipped.") + return {"scanned": 0, "optimized": 0, "freed": 0} + + params = self._dt_image_optimize_params() + limit = limit or params["batch"] + domain = [ + ("res_field", "in", params["fields"]), + ("deltatech_image_optimized", "=", False), + ] + if params["min_size"]: + domain.append(("file_size", ">", params["min_size"])) + + # sudo() bypasses the record rule that hides field-stored attachments. + attachments = self.sudo().search(domain, order="file_size desc", limit=limit) + + now = fields.Datetime.now() + optimized = 0 + freed = 0 + for att in attachments: + raw = att.raw + data = self._dt_image_recompress(raw, params["quality"], params["max_dim"]) + if not data: + att.deltatech_image_optimized = now + continue + record = self.env[att.res_model].sudo().browse(att.res_id) + if not record.exists() or att.res_field not in record._fields: + att.deltatech_image_optimized = now + continue + try: + record.write({att.res_field: base64.b64encode(data)}) + except Exception as exc: # noqa: BLE001 + _logger.warning( + "Image optimize failed for %s(%s).%s: %s", + att.res_model, + att.res_id, + att.res_field, + exc, + ) + att.deltatech_image_optimized = now + continue + # Writing the image field recreates the attachment: flag the new one + # so it is not reprocessed on the next run. + new_att = self.sudo().search( + [ + ("res_model", "=", att.res_model), + ("res_id", "=", att.res_id), + ("res_field", "=", att.res_field), + ], + limit=1, + ) + if new_att: + new_att.deltatech_image_optimized = now + freed += len(raw) - len(data) + optimized += 1 + + _logger.info( + "Image optimizer: scanned=%s optimized=%s freed=%.1f MB", + len(attachments), + optimized, + freed / 1048576.0, + ) + return {"scanned": len(attachments), "optimized": optimized, "freed": freed} + + @api.model + def _dt_image_optimize_cron(self): + """Entry point for the scheduled action.""" + self._dt_image_optimize_run() diff --git a/deltatech_image_optimize/pyproject.toml b/deltatech_image_optimize/pyproject.toml new file mode 100644 index 000000000..96e5ce2cc --- /dev/null +++ b/deltatech_image_optimize/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = [ + "whool", +] +build-backend = "whool.buildapi" + +[project] +name = "odoo-addon-deltatech-image-optimize" diff --git a/deltatech_image_optimize/readme/DESCRIPTION.md b/deltatech_image_optimize/readme/DESCRIPTION.md new file mode 100644 index 000000000..72be45b25 --- /dev/null +++ b/deltatech_image_optimize/readme/DESCRIPTION.md @@ -0,0 +1,46 @@ +# Image Optimizer + +Recompresses oversized **original** image attachments (``image_1920`` and +``image_variant_1920`` by default) to reclaim filestore space. + +For each targeted image the module: + +- downscales it to a maximum side of ``max_dim`` pixels (default 1920); +- re-encodes photos without transparency as progressive **JPEG** at the + configured quality (default 85); +- keeps images with transparency as optimized **PNG** (alpha preserved); +- skips animated GIFs (never flattens the animation); +- keeps the result only when it is actually smaller. + +The optimized image is written back **through the owning record**, so Odoo +regenerates the resized variants (``image_1024/512/256/128``) from the new, +smaller original. + +Processed attachments are flagged (``deltatech_image_optimized``) and skipped on +the next run. Because Odoo creates a fresh attachment whenever an image field is +updated, newly uploaded or changed images are picked up automatically. + +## Configuration + +System Parameters (Settings → Technical → System Parameters): + +| Key | Default | Meaning | +| --- | --- | --- | +| ``deltatech_image_optimize.quality`` | 85 | JPEG quality (1..95) | +| ``deltatech_image_optimize.max_dim`` | 1920 | max side in pixels | +| ``deltatech_image_optimize.min_size`` | 102400 | only images larger than this (bytes) | +| ``deltatech_image_optimize.batch`` | 1000 | images per cron run | +| ``deltatech_image_optimize.target_fields`` | image_1920,image_variant_1920 | fields to optimize | + +## Scheduled action + +``Image Optimizer: recompress oversized images`` runs daily. It is +**disabled by default** — review the configuration, test on staging, then +enable it. + +For a large one-time backlog you can loop the batch method from the shell: + +```python +while env["ir.attachment"]._dt_image_optimize_run(limit=2000)["scanned"]: + env.cr.commit() +``` diff --git a/deltatech_image_optimize/readme/HISTORY.md b/deltatech_image_optimize/readme/HISTORY.md new file mode 100644 index 000000000..c8d1fbcd9 --- /dev/null +++ b/deltatech_image_optimize/readme/HISTORY.md @@ -0,0 +1,15 @@ +# Changelog + +## 18.0.1.0.0 (2025) + +- Initial release. +- Add ``ir.attachment.deltatech_image_optimized`` marker field. +- Recompress original image attachments (``image_1920`` / ``image_variant_1920``): + downscale to ``max_dim``, JPEG (quality tuned) for opaque images, optimized + PNG for images with alpha, skip animated GIFs. +- Write the optimized image back through the owning record so Odoo regenerates + the resized variants from the smaller original. +- Configurable via ``ir.config_parameter`` (quality, max_dim, min_size, batch, + target_fields). +- Daily scheduled action ``Image Optimizer: recompress oversized images``, + disabled by default. diff --git a/deltatech_image_optimize/static/description/index.html b/deltatech_image_optimize/static/description/index.html new file mode 100644 index 000000000..7d3ed18f8 --- /dev/null +++ b/deltatech_image_optimize/static/description/index.html @@ -0,0 +1,495 @@ + + + + + +Image Optimizer + + + +
+

Image Optimizer

+ + +

Beta dhongu/deltatech

+
+

Image Optimizer

+

Recompresses oversized original image attachments (image_1920 +and image_variant_1920 by default) to reclaim filestore space.

+

For each targeted image the module:

+
    +
  • downscales it to a maximum side of max_dim pixels (default 1920);
  • +
  • re-encodes photos without transparency as progressive JPEG at the +configured quality (default 85);
  • +
  • keeps images with transparency as optimized PNG (alpha preserved);
  • +
  • skips animated GIFs (never flattens the animation);
  • +
  • keeps the result only when it is actually smaller.
  • +
+

The optimized image is written back through the owning record, so +Odoo regenerates the resized variants (image_1024/512/256/128) from +the new, smaller original.

+

Processed attachments are flagged (deltatech_image_optimized) and +skipped on the next run. Because Odoo creates a fresh attachment +whenever an image field is updated, newly uploaded or changed images are +picked up automatically.

+
+

Configuration

+

System Parameters (Settings → Technical → System Parameters):

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyDefaultMeaning
deltatech_image_optimize.quality85JPEG quality (1..95)
deltatech_image_optimize.max_dim1920max side in pixels
deltatech_image_optimize.min_size102400only images larger +than this (bytes)
deltatech_image_optimize.batch1000images per cron run
deltatech_image_optimize.target_fieldsimage_1920,image_variant_1920fields to optimize
+
+
+

Scheduled action

+

Image Optimizer: recompress oversized images runs daily. It is +disabled by default — review the configuration, test on staging, +then enable it.

+

For a large one-time backlog you can loop the batch method from the +shell:

+
+while env["ir.attachment"]._dt_image_optimize_run(limit=2000)["scanned"]:
+    env.cr.commit()
+
+

Table of contents

+
+
+
+

Changelog

+
+
+

Changelog

+
+

18.0.1.0.0 (2025)

+
    +
  • Initial release.
  • +
  • Add ir.attachment.deltatech_image_optimized marker field.
  • +
  • Recompress original image attachments (image_1920 / +image_variant_1920): downscale to max_dim, JPEG (quality +tuned) for opaque images, optimized PNG for images with alpha, skip +animated GIFs.
  • +
  • Write the optimized image back through the owning record so Odoo +regenerates the resized variants from the smaller original.
  • +
  • Configurable via ir.config_parameter (quality, max_dim, min_size, +batch, target_fields).
  • +
  • Daily scheduled action +Image Optimizer: recompress oversized images, disabled by default.
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on Terrabit Issues. +In case of trouble, please check there if your issue has already been reported.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Terrabit
  • +
  • Dorin Hongu
  • +
+
+
+

Maintainers

+

Current maintainer:

+

dhongu

+

This module is part of the dhongu/deltatech project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/deltatech_image_optimize/tests/__init__.py b/deltatech_image_optimize/tests/__init__.py new file mode 100644 index 000000000..a7d374fde --- /dev/null +++ b/deltatech_image_optimize/tests/__init__.py @@ -0,0 +1 @@ +from . import test_image_optimize diff --git a/deltatech_image_optimize/tests/test_image_optimize.py b/deltatech_image_optimize/tests/test_image_optimize.py new file mode 100644 index 000000000..9da2fcc26 --- /dev/null +++ b/deltatech_image_optimize/tests/test_image_optimize.py @@ -0,0 +1,77 @@ +import base64 +import io +import os + +from odoo.tests import TransactionCase, tagged + +try: + from PIL import Image +except ImportError: + Image = None + + +@tagged("post_install", "-at_install") +class TestImageOptimize(TransactionCase): + def _make_big_jpeg(self, size=2200): + """Build a noisy JPEG that is large and above the 1920 limit.""" + raw = os.urandom(size * size * 3) + img = Image.frombytes("RGB", (size, size), raw) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=95) + return base64.b64encode(buf.getvalue()) + + def _image_attachment(self, partner): + return ( + self.env["ir.attachment"] + .sudo() + .search( + [ + ("res_model", "=", "res.partner"), + ("res_id", "=", partner.id), + ("res_field", "=", "image_1920"), + ], + limit=1, + ) + ) + + def test_optimize_shrinks_and_flags(self): + if Image is None: + self.skipTest("Pillow not available") + + ICP = self.env["ir.config_parameter"].sudo() + ICP.set_param("deltatech_image_optimize.min_size", "1") + ICP.set_param("deltatech_image_optimize.quality", "70") + ICP.set_param("deltatech_image_optimize.target_fields", "image_1920") + + partner = self.env["res.partner"].create({"name": "Image Optimize Test", "image_1920": self._make_big_jpeg()}) + att = self._image_attachment(partner) + self.assertTrue(att, "partner should have a stored image_1920 attachment") + original_size = att.file_size + self.assertFalse(att.deltatech_image_optimized) + + stats = self.env["ir.attachment"]._dt_image_optimize_run(limit=50) + self.assertGreaterEqual(stats["optimized"], 1) + + new_att = self._image_attachment(partner) + self.assertTrue(new_att) + self.assertLess(new_att.file_size, original_size) + self.assertTrue(new_att.deltatech_image_optimized) + + # The stored image must still be a valid image no larger than 1920 px. + img = Image.open(io.BytesIO(new_att.raw)) + self.assertLessEqual(max(img.size), 1920) + + def test_second_run_is_idempotent(self): + if Image is None: + self.skipTest("Pillow not available") + + ICP = self.env["ir.config_parameter"].sudo() + ICP.set_param("deltatech_image_optimize.min_size", "1") + ICP.set_param("deltatech_image_optimize.quality", "70") + ICP.set_param("deltatech_image_optimize.target_fields", "image_1920") + + self.env["res.partner"].create({"name": "Image Optimize Test 2", "image_1920": self._make_big_jpeg()}) + self.env["ir.attachment"]._dt_image_optimize_run(limit=50) + # Everything processed is flagged, so a second run finds nothing new. + stats = self.env["ir.attachment"]._dt_image_optimize_run(limit=50) + self.assertEqual(stats["optimized"], 0)