diff --git a/.env b/.env index 34e432afd..4c71493a8 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -DJANGO_SECRET_KEY=local +DJANGO_SECRET_KEY=local-dev-secret-key-not-for-production DJANGO_DEBUG=True USE_GRANIAN=False USE_POSTGRES=False diff --git a/Dockerfile b/Dockerfile index b6658f35f..1f4f9f96e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ ENV UV_PYTHON_VERSION=3.14.2 \ UV_PYTHON_INSTALL_DIR=/python \ UV_PYTHON_PREFERENCE=only-managed -COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /uvx /bin/ +COPY --from=ghcr.io/astral-sh/uv:0.11.25 /uv /uvx /bin/ RUN apt-get update && \ apt-get install -y --no-install-recommends \ @@ -18,6 +18,10 @@ RUN apt-get update && \ curl \ wget \ git \ + libavformat-dev \ + libavcodec-dev \ + libavutil-dev \ + libswscale-dev \ && dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \ && wget "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${dpkgArch}" -O /usr/local/bin/tini \ && chmod +x /usr/local/bin/tini && tini --version \ diff --git a/pyproject.toml b/pyproject.toml index e56b2c30f..a19d179ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Jandig" -version = "2.0.11" +version = "3.0.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" @@ -22,6 +22,7 @@ dependencies = [ "granian[reload]>=2.5.0", "jinja2~=3.1", "markupsafe~=3.0", + "av>=14.0.0", "pillow~=12.2", "psycopg[binary,pool]>=3.2.9", "pymarker~=1.2", @@ -38,12 +39,14 @@ dev = [ "factory-boy~=3.3", "numpy>=2.3.1", "playwright~=1.49", + "pyopenssl>=26.3.0", "pytest-cov>=6.1.1", "pytest-django~=4.9", "pytest-playwright~=0.4", "pytest-xdist~=3.6", "pytest~=9.0", "ruff>=0.11.4", + "werkzeug>=3.1.8", ] @@ -52,7 +55,7 @@ DJANGO_SETTINGS_MODULE = "config.test_settings" python_files = ["tests.py", "test_*.py", "*_tests.py"] testpaths = ["src/core", "src/users", "src/blog", "src/ui_tests"] pythonpath = "src" -addopts="-n4" +addopts="-n16" [tool.coverage.run] relative_files=true @@ -72,3 +75,5 @@ target-version = "py313" profile="jinja" extension="jinja2" +[tool.uv] +exclude-newer="1 week" diff --git a/src/config/jinja2.py b/src/config/jinja2.py index 557fb48c7..bd82c13f1 100644 --- a/src/config/jinja2.py +++ b/src/config/jinja2.py @@ -4,6 +4,8 @@ from django.utils import translation from jinja2 import Environment +from core.utils import filesizeformat + def environment(**options): options["extensions"] = ["jinja2.ext.i18n"] @@ -13,6 +15,7 @@ def environment(**options): { "static": staticfiles_storage.url, "url": reverse, + "enumerate": enumerate, "CUR_LANGUAGE": translation.get_language(), "languages": [ translation.get_language_info(code) @@ -21,5 +24,7 @@ def environment(**options): } ) + env.filters["filesizeformat"] = filesizeformat + env.install_gettext_translations(translation, newstyle=True) return env diff --git a/src/config/settings.py b/src/config/settings.py index e255447f8..5fddec815 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -52,7 +52,7 @@ SENTRY_TRACES_SAMPLE_RATE = env("SENTRY_TRACES_SAMPLE_RATE", default=0.1) SENTRY_PROFILES_SAMPLE_RATE = env("SENTRY_PROFILES_SAMPLE_RATE", default=0.1) SENTRY_ENVIRONMENT = env("SENTRY_ENVIRONMENT", default="") -SENTRY_RELEASE = env("SENTRY_RELEASE", default="2.0.11") +SENTRY_RELEASE = env("SENTRY_RELEASE", default="3.0.0") def traces_sampler(sampling_context): diff --git a/src/core/admin.py b/src/core/admin.py index f3694f9ac..98e072db5 100644 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -1,3 +1,4 @@ +import json from io import BytesIO from django.contrib import admin @@ -6,11 +7,13 @@ from django.db.models import Count from django.utils.html import format_html from PIL import Image -from pymarker import generate_patt_from_image, remove_borders_from_image +from pymarker import remove_borders_from_image +from core.marker_utils import generate_marker_variants +from core.media_dimensions import extract_dimensions from core.models import Artwork, Exhibit, Marker, Object, Sound -from core.utils import generate_uuid_name, get_admin_url -from core.views.api_views import MarkerGeneratorAPIView +from core.spritesheet_converter import gif_to_spritesheet +from core.utils import filesizeformat, get_admin_url HTML_LINK = '{}' @@ -72,10 +75,8 @@ def exhibits_count(self, obj): exhibits_count.admin_order_field = "_exhibits_count" def filesize(self, obj): - """File size in MB""" - if obj.file_size > 0: - return f"{obj.file_size / 1024 / 1024:.2f} MB" - return obj.file_size + """File size in human-readable format""" + return filesizeformat(obj.file_size) filesize.short_description = "File Size" filesize.admin_order_field = "file_size" @@ -92,7 +93,8 @@ def regenerate_marker_white_border(modeladmin, request, queryset): Regenerate markers with white inner border. This action will regenerate the marker images with a white inner border. """ - regenerate_marker(queryset, inner_border=True) + for marker in queryset: + generate_marker_variants(marker, inner_border=True) @admin.action(description="Regenerate Marker Without Inner Border") @@ -100,51 +102,24 @@ def regenerate_marker_no_inner_border(modeladmin, request, queryset): """Regenerate markers without inner border. This action will regenerate the marker images without a white inner border. """ - regenerate_marker(queryset, inner_border=False) + for marker in queryset: + generate_marker_variants(marker, inner_border=False) @admin.action(description="Remove Border") def remove_border(modeladmin, request, queryset): - """Remove border from markers. - This action will regenerate the marker images without any borders. + """Remove border from markers and regenerate all variants. + This action will strip borders from the current source, save as original, + then regenerate all variants. """ for marker in queryset: with Image.open(marker.source) as image: pil_image = remove_borders_from_image(image) blob = BytesIO() - pil_image.save(blob, "JPEG") - uuid = generate_uuid_name() - filename = f"{uuid}.jpg" - marker.file_size = marker.source.size - marker.source.save(filename, File(blob), save=True) - patt_str = generate_patt_from_image(pil_image) - marker.patt.save( - f"{uuid}.patt", - ContentFile(patt_str.encode("utf-8")), - save=True, - ) - marker.save() - - -def regenerate_marker(queryset, inner_border=False): - for marker in queryset: - with Image.open(marker.source) as image: - pil_image = MarkerGeneratorAPIView.generate_marker( - image, inner_border=inner_border - ) - blob = BytesIO() - pil_image.save(blob, "JPEG") - uuid = generate_uuid_name() - filename = f"{uuid}.jpg" - marker.file_size = marker.source.size + pil_image.save(blob, "PNG") + filename = f"markers/{marker.pk}/original.png" marker.source.save(filename, File(blob), save=True) - patt_str = generate_patt_from_image(image) - marker.patt.save( - f"{uuid}.patt", - ContentFile(patt_str.encode("utf-8")), - save=True, - ) - marker.save() + generate_marker_variants(marker, inner_border=False) @admin.register(Marker) @@ -159,17 +134,123 @@ def image_preview(self, obj): return format_html(obj.as_html_thumbnail(), "") +@admin.action(description="Generate spritesheets for selected GIF objects") +def generate_spritesheets(modeladmin, request, queryset): + """Generate (or regenerate) PNG spritesheets and metadata JSON for GIF objects. + + Running this action twice replaces the existing spritesheet and metadata + files, keeping only the original GIF source unchanged. + """ + for obj in queryset.filter(file_extension="gif"): + if not obj.source: + continue + + try: + storage = obj.source.storage + + with obj.source.open("rb") as f: + png_bytes, metadata = gif_to_spritesheet(f) + + # Save spritesheet PNG to objects//spritesheet.png + spritesheet_path = f"objects/{obj.pk}/spritesheet.png" + _save_to_storage(storage, spritesheet_path, png_bytes) + obj.spritesheet_file.name = spritesheet_path + + # Save metadata JSON to objects//metadata.json + metadata_path = f"objects/{obj.pk}/metadata.json" + _save_to_storage( + storage, metadata_path, json.dumps(metadata).encode("utf-8") + ) + obj.spritesheet_metadata.name = metadata_path + + obj.save() + except Exception as e: + modeladmin.message_user( + request, + f"Failed to generate spritesheet for Object {obj.pk}: {e}", + level="error", + ) + + +def _save_to_storage(storage, path, content_bytes): + """Save content to storage, deleting existing file first for idempotency.""" + try: + if storage.exists(path): + storage.delete(path) + except Exception: + pass + storage.save(path, ContentFile(content_bytes)) + + +class SpritesheetFilter(admin.SimpleListFilter): + title = "spritesheet status" + parameter_name = "has_spritesheet" + + def lookups(self, request, model_admin): + return [ + ("yes", "Has spritesheet"), + ("no", "Missing spritesheet (GIF only)"), + ] + + def queryset(self, request, queryset): + if self.value() == "yes": + return queryset.exclude(spritesheet_file="").exclude(spritesheet_file=None) + if self.value() == "no": + from django.db.models import Q + + return queryset.filter(file_extension="gif").filter( + Q(spritesheet_file="") | Q(spritesheet_file=None) + ) + return queryset + + @admin.register(Object) class ObjectAdmin(BaseMarkerObjectAdmin): list_display = BaseMarkerObjectAdmin.list_display + [ "file_extension", + "has_spritesheet", ] search_fields = ["title", "id"] - list_filter = ["file_extension"] + list_filter = ["file_extension", SpritesheetFilter] + actions = [generate_spritesheets, "populate_dimensions"] def image_preview(self, obj): return format_html(obj.as_html_thumbnail(), "") + def has_spritesheet(self, obj): + return bool(obj.spritesheet_file and obj.spritesheet_metadata) + + @admin.action(description="Populate width/height dimensions for selected objects") + def populate_dimensions(self, request, queryset): + updated = 0 + for obj in queryset: + try: + thumbnail = obj.thumbnail if obj.is_3d else None + with obj.source.open("rb") as f: + thumb_file = None + if thumbnail: + try: + thumb_file = thumbnail.open("rb") + except Exception: + thumb_file = None + + dims = extract_dimensions(f, obj.file_extension, thumb_file) + + if thumb_file: + thumb_file.close() + + if dims: + obj.width, obj.height = dims + obj.save(update_fields=["width", "height"]) + updated += 1 + except Exception as e: + self.message_user( + request, + f"Failed for Object {obj.pk}: {e}", + level="error", + ) + self.message_user(request, f"Updated dimensions for {updated} object(s).") + @admin.register(Artwork) class ArtworkAdmin(admin.ModelAdmin): diff --git a/src/core/forms.py b/src/core/forms.py index 4dd631f9e..ba711e5b9 100644 --- a/src/core/forms.py +++ b/src/core/forms.py @@ -1,16 +1,12 @@ -from io import BytesIO - from django import forms -from django.core.files.base import ContentFile, File from django.forms.widgets import NumberInput from django.template import loader from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from PIL import Image -from pymarker.core import generate_patt_from_image +from core.marker_utils import generate_marker_variants +from core.media_dimensions import extract_dimensions from core.models import Artwork, Marker, ObjectExtensions -from core.views.api_views import MarkerGeneratorAPIView from .models import Exhibit, ExhibitTypes, Object, Sound @@ -48,7 +44,7 @@ def __init__(self, thumbnail=None, attrs=None): self.thumbnail = thumbnail def render(self, name, value, attrs=None, renderer=None): - attrs.update({"accept": ".gif, .mp4, .webm, .glb"}) + attrs.update({"accept": ".gif, .png, .mp4, .webm, .glb"}) context = self.get_context(name, value, attrs) if self.thumbnail: context["widget"]["thumbnail"] = self.thumbnail @@ -85,11 +81,13 @@ class Meta: def clean_source(self): file = self.cleaned_data.get("source") - allowed_extensions = ["gif", "mp4", "webm", "glb"] + allowed_extensions = ["gif", "png", "mp4", "webm", "glb"] extension = getattr(file, "name", "").split(".")[-1].lower() if extension not in allowed_extensions: raise forms.ValidationError( - _("Only GIF images, MP4, WebM videos, and GLB files are allowed.") + _( + "Only GIF images, PNG images, MP4, WebM videos, and GLB files are allowed." + ) ) # Object already exists, we need to check if it's being used by another user if self.instance.pk: @@ -122,6 +120,13 @@ def save(self, *args, **kwargs): self.instance.file_name_original = self.instance.source.name.split("/")[-1] self.instance.file_extension = self.instance.source.name.split(".")[-1].lower() + thumbnail = self.cleaned_data.get("thumbnail") or self.instance.thumbnail + dims = extract_dimensions( + self.instance.source, self.instance.file_extension, thumbnail + ) + if dims: + self.instance.width, self.instance.height = dims + return super(UploadObjectForm, self).save(*args, **kwargs) @@ -144,25 +149,13 @@ class Meta: def save(self, *args, **kwargs): commit = kwargs.get("commit", True) - - with Image.open(self.instance.source) as image: - pil_image = MarkerGeneratorAPIView.generate_marker( - image, inner_border=self.cleaned_data.get("inner_border", False) - ) - blob = BytesIO() - pil_image.save(blob, "JPEG") - filename = self.instance.source.name - self.instance.file_size = self.instance.source.size - self.instance.source.save(filename, File(blob), save=commit) - patt_str = generate_patt_from_image(image) - - self.instance.patt.save( - f"{filename}.patt", - ContentFile(patt_str.encode("utf-8")), - save=commit, + instance = super(UploadMarkerForm, self).save(*args, **kwargs) + if commit: + generate_marker_variants( + instance, + inner_border=self.cleaned_data.get("inner_border", False), ) - - return super(UploadMarkerForm, self).save(*args, **kwargs) + return instance class ArtworkForm(forms.ModelForm): diff --git a/src/core/jinja2/core/ar.jinja2 b/src/core/jinja2/core/ar.jinja2 new file mode 100644 index 000000000..b9445f855 --- /dev/null +++ b/src/core/jinja2/core/ar.jinja2 @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + {% for id, artwork in enumerate(artworks) %} + + {% if artwork.augmented.file_extension == 'gif' %} + + {% elif artwork.augmented.file_extension == 'mp4' %} + + {% elif artwork.augmented.file_extension == 'png' %} + + {% endif %} + + {% endfor %} + + + diff --git a/src/core/jinja2/core/base.jinja2 b/src/core/jinja2/core/base.jinja2 index 8718ddea0..e8cd3001e 100644 --- a/src/core/jinja2/core/base.jinja2 +++ b/src/core/jinja2/core/base.jinja2 @@ -13,14 +13,6 @@ - - - {# #} - - - - - Jandig ARte diff --git a/src/core/jinja2/core/edit-marker.jinja2 b/src/core/jinja2/core/edit-marker.jinja2 deleted file mode 100644 index 912eedd30..000000000 --- a/src/core/jinja2/core/edit-marker.jinja2 +++ /dev/null @@ -1,47 +0,0 @@ -{% extends '/core/home.jinja2' %} -{% block content %} - - {# FIXME: maybe this can be improved #} - - {{ _("Edit object") }} - - - - {{ csrf_input }} - {{ _("Edit Marker's title") }} - {{ form.visible_fields()[2] }} - {{ form.visible_fields()[2] .errors }} - {{ _("You can't edit the marker patt and the png for now, but hopefully soon enough it will be possible!") }} - - {{ form.visible_fields()[1] }} - {{ form.visible_fields()[1] .errors }} - - - - - - - - -{% endblock %} diff --git a/src/core/jinja2/core/exhibit.jinja2 b/src/core/jinja2/core/exhibit.jinja2 deleted file mode 100644 index ebdfd4119..000000000 --- a/src/core/jinja2/core/exhibit.jinja2 +++ /dev/null @@ -1,25 +0,0 @@ -{% extends '/core/base.jinja2' %} -{% block content %} - - - - {% for artwork in artworks %} - - - - - {% endfor %} - -{% endblock %} diff --git a/src/core/jinja2/core/generator.html b/src/core/jinja2/core/generator.html deleted file mode 100644 index 2762c75b3..000000000 --- a/src/core/jinja2/core/generator.html +++ /dev/null @@ -1,104 +0,0 @@ - -Jandig Marker Generator - - - - - - - - - - - - - - - - - - - - - - - info_outline - - - - Welcome! - - Details on how to pick the inner image can be found here - - - OK - - - - - - - - - - - - - - Upload your image - - - Browse to the file you want to put inside the marker. - - - - The marker generator does not support images with a transparent background. Take care to select an image that has a non-transparent background. - - - - - - You will need both of these - - - - Download .patt Marker - - - Download the encoded file. - - - - - Download Image Marker - - - Download marker image. - - - - - You can also save it for print in a .pdf file! - - - - One per Page - - - - - Two per page - - - - - Six per Page - - - - - - - - -
- {{ form.visible_fields()[1] }} - {{ form.visible_fields()[1] .errors }} -