Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
4c9987d
Add new JS libs for AR
pablodiegoss Jun 28, 2026
b4e25e5
Create new base files for AR Viewer with OpenCV
pablodiegoss Jun 28, 2026
078e0aa
Create AR tags to manage exhibit content
pablodiegoss Jun 28, 2026
1af7910
Create basic marker tracking
pablodiegoss Jun 28, 2026
53d32df
Refactor three overlay and html elements
pablodiegoss Jun 28, 2026
8cd615d
Improve ID handling on html
pablodiegoss Jun 28, 2026
8321a9b
Update video resolution
pablodiegoss Jun 28, 2026
64e4572
Create a cache or marker rotations
pablodiegoss Jun 28, 2026
0d84a17
Remove unused code from old marker and patt creation and rotationLock.js
pablodiegoss Jun 28, 2026
05c27c9
Update dependencies with exclude-newer: 1 week
pablodiegoss Jun 28, 2026
6c1e38e
Create new Marker fields for image variantes
pablodiegoss Jun 30, 2026
5067e36
Update marker upload form to generate all variants
pablodiegoss Jun 30, 2026
68f6486
Fix broken tests and modals for markers
pablodiegoss Jun 30, 2026
258f660
Use modified datetime to break caches
pablodiegoss Jun 30, 2026
62d1ffd
Re-enable marker editing
pablodiegoss Jun 30, 2026
2abcf03
Update factories to generate variants for markers
pablodiegoss Jun 30, 2026
0008c79
Re-enable tests for editing markers
pablodiegoss Jun 30, 2026
e100aaf
Update marker rotation every 20 frames to improve performance
pablodiegoss Jul 1, 2026
adb0365
Fix spritesheet and metadata relations
pablodiegoss Jul 1, 2026
5f98cb3
Update AR to support spritesheet, video and images
pablodiegoss Jul 1, 2026
1b938b1
Add script to convert GIFs to spritesheets
pablodiegoss Jul 1, 2026
28d2ae5
Add video to upload page hint
pablodiegoss Jul 1, 2026
155a472
Create new spritesheet fields
pablodiegoss Jul 1, 2026
4bdccb5
Create git to spritesheet endpoint
pablodiegoss Jul 1, 2026
266e28a
Add GIF to spritesheet convertion on upload and edit object pages
pablodiegoss Jul 1, 2026
76ee57f
Update convert GIF to spritesheet endpoint handling for non-gifs
pablodiegoss Jul 1, 2026
ec91dfd
Remove migration to generate spritesheets for existing GIFs, use admi…
pablodiegoss Jul 1, 2026
355c734
Remove unused method and error on upload pages
pablodiegoss Jul 1, 2026
189ad64
Relocate objects inside storage for better organization
pablodiegoss Jul 1, 2026
da4e580
Create tests to ensure stale files are removed on edit Object
pablodiegoss Jul 1, 2026
2bc0991
Refactor data migration to avoid overcropping on original files
pablodiegoss Jul 1, 2026
9fe8918
Update django admin to account for new fields
pablodiegoss Jul 1, 2026
df77c8b
Remove PATT file field from markers
pablodiegoss Jul 1, 2026
b3ee301
Remove references to patt files
pablodiegoss Jul 1, 2026
edf76b3
Fix exhibits badge for mr+ar exhibits
pablodiegoss Jul 1, 2026
ff7d84f
Remove old AR toolkit files
pablodiegoss Jul 1, 2026
711bddd
Reorganize manifest and sw files
pablodiegoss Jul 1, 2026
d361065
Allow users to upload PNG as objects
pablodiegoss Jul 1, 2026
1a177e2
Add dependencies to allow runserver_plus with SSL for https testing o…
pablodiegoss Jul 1, 2026
cbed5d4
Collect width and height from objects
pablodiegoss Jul 1, 2026
11e3cd2
Format numbers on modal pages
pablodiegoss Jul 1, 2026
371ff63
Fix used_in button not showing exhibits
pablodiegoss Jul 1, 2026
0ece4c5
Fix filesize formating on tests and avoid duplicated function
pablodiegoss Jul 1, 2026
bace2d0
Fix factory to generate spritesheet and metadata
pablodiegoss Jul 1, 2026
ca34067
Improve test for manifest and sw
pablodiegoss Jul 1, 2026
e9a5611
Fix tests for related content
pablodiegoss Jul 1, 2026
f61f17d
Fix factory usage on create artworks and exhibits
pablodiegoss Jul 1, 2026
82fde13
Update project version
pablodiegoss Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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 \
Expand Down
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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",
Expand All @@ -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",
]


Expand All @@ -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
Expand All @@ -72,3 +75,5 @@ target-version = "py313"
profile="jinja"
extension="jinja2"

[tool.uv]
exclude-newer="1 week"
5 changes: 5 additions & 0 deletions src/config/jinja2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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)
Expand All @@ -21,5 +24,7 @@ def environment(**options):
}
)

env.filters["filesizeformat"] = filesizeformat

env.install_gettext_translations(translation, newstyle=True)
return env
2 changes: 1 addition & 1 deletion src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
169 changes: 125 additions & 44 deletions src/core/admin.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from io import BytesIO

from django.contrib import admin
Expand All @@ -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 = '<a href="{}">{}</a>'

Expand Down Expand Up @@ -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"
Expand All @@ -92,59 +93,33 @@ 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")
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)
Expand All @@ -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/<pk>/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/<pk>/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):
Expand Down
Loading
Loading