Skip to content

Commit dbc4735

Browse files
authored
Merge pull request #958 from memeLab/new-ar-viewer
New AR viewer
2 parents 2fedb24 + 82fde13 commit dbc4735

83 files changed

Lines changed: 23900 additions & 2635 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
DJANGO_SECRET_KEY=local
1+
DJANGO_SECRET_KEY=local-dev-secret-key-not-for-production
22
DJANGO_DEBUG=True
33
USE_GRANIAN=False
44
USE_POSTGRES=False

Dockerfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ ENV UV_PYTHON_VERSION=3.14.2 \
88
UV_PYTHON_INSTALL_DIR=/python \
99
UV_PYTHON_PREFERENCE=only-managed
1010

11-
COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /uvx /bin/
11+
COPY --from=ghcr.io/astral-sh/uv:0.11.25 /uv /uvx /bin/
1212

1313
RUN apt-get update && \
1414
apt-get install -y --no-install-recommends \
@@ -18,6 +18,10 @@ RUN apt-get update && \
1818
curl \
1919
wget \
2020
git \
21+
libavformat-dev \
22+
libavcodec-dev \
23+
libavutil-dev \
24+
libswscale-dev \
2125
&& dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \
2226
&& wget "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${dpkgArch}" -O /usr/local/bin/tini \
2327
&& chmod +x /usr/local/bin/tini && tini --version \

pyproject.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "Jandig"
3-
version = "2.0.11"
3+
version = "3.0.0"
44
description = "Add your description here"
55
readme = "README.md"
66
requires-python = ">=3.13"
@@ -22,6 +22,7 @@ dependencies = [
2222
"granian[reload]>=2.5.0",
2323
"jinja2~=3.1",
2424
"markupsafe~=3.0",
25+
"av>=14.0.0",
2526
"pillow~=12.2",
2627
"psycopg[binary,pool]>=3.2.9",
2728
"pymarker~=1.2",
@@ -38,12 +39,14 @@ dev = [
3839
"factory-boy~=3.3",
3940
"numpy>=2.3.1",
4041
"playwright~=1.49",
42+
"pyopenssl>=26.3.0",
4143
"pytest-cov>=6.1.1",
4244
"pytest-django~=4.9",
4345
"pytest-playwright~=0.4",
4446
"pytest-xdist~=3.6",
4547
"pytest~=9.0",
4648
"ruff>=0.11.4",
49+
"werkzeug>=3.1.8",
4750
]
4851

4952

@@ -52,7 +55,7 @@ DJANGO_SETTINGS_MODULE = "config.test_settings"
5255
python_files = ["tests.py", "test_*.py", "*_tests.py"]
5356
testpaths = ["src/core", "src/users", "src/blog", "src/ui_tests"]
5457
pythonpath = "src"
55-
addopts="-n4"
58+
addopts="-n16"
5659

5760
[tool.coverage.run]
5861
relative_files=true
@@ -72,3 +75,5 @@ target-version = "py313"
7275
profile="jinja"
7376
extension="jinja2"
7477

78+
[tool.uv]
79+
exclude-newer="1 week"

src/config/jinja2.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from django.utils import translation
55
from jinja2 import Environment
66

7+
from core.utils import filesizeformat
8+
79

810
def environment(**options):
911
options["extensions"] = ["jinja2.ext.i18n"]
@@ -13,6 +15,7 @@ def environment(**options):
1315
{
1416
"static": staticfiles_storage.url,
1517
"url": reverse,
18+
"enumerate": enumerate,
1619
"CUR_LANGUAGE": translation.get_language(),
1720
"languages": [
1821
translation.get_language_info(code)
@@ -21,5 +24,7 @@ def environment(**options):
2124
}
2225
)
2326

27+
env.filters["filesizeformat"] = filesizeformat
28+
2429
env.install_gettext_translations(translation, newstyle=True)
2530
return env

src/config/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
SENTRY_TRACES_SAMPLE_RATE = env("SENTRY_TRACES_SAMPLE_RATE", default=0.1)
5353
SENTRY_PROFILES_SAMPLE_RATE = env("SENTRY_PROFILES_SAMPLE_RATE", default=0.1)
5454
SENTRY_ENVIRONMENT = env("SENTRY_ENVIRONMENT", default="")
55-
SENTRY_RELEASE = env("SENTRY_RELEASE", default="2.0.11")
55+
SENTRY_RELEASE = env("SENTRY_RELEASE", default="3.0.0")
5656

5757

5858
def traces_sampler(sampling_context):

src/core/admin.py

Lines changed: 125 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from io import BytesIO
23

34
from django.contrib import admin
@@ -6,11 +7,13 @@
67
from django.db.models import Count
78
from django.utils.html import format_html
89
from PIL import Image
9-
from pymarker import generate_patt_from_image, remove_borders_from_image
10+
from pymarker import remove_borders_from_image
1011

12+
from core.marker_utils import generate_marker_variants
13+
from core.media_dimensions import extract_dimensions
1114
from core.models import Artwork, Exhibit, Marker, Object, Sound
12-
from core.utils import generate_uuid_name, get_admin_url
13-
from core.views.api_views import MarkerGeneratorAPIView
15+
from core.spritesheet_converter import gif_to_spritesheet
16+
from core.utils import filesizeformat, get_admin_url
1417

1518
HTML_LINK = '<a href="{}">{}</a>'
1619

@@ -72,10 +75,8 @@ def exhibits_count(self, obj):
7275
exhibits_count.admin_order_field = "_exhibits_count"
7376

7477
def filesize(self, obj):
75-
"""File size in MB"""
76-
if obj.file_size > 0:
77-
return f"{obj.file_size / 1024 / 1024:.2f} MB"
78-
return obj.file_size
78+
"""File size in human-readable format"""
79+
return filesizeformat(obj.file_size)
7980

8081
filesize.short_description = "File Size"
8182
filesize.admin_order_field = "file_size"
@@ -92,59 +93,33 @@ def regenerate_marker_white_border(modeladmin, request, queryset):
9293
Regenerate markers with white inner border.
9394
This action will regenerate the marker images with a white inner border.
9495
"""
95-
regenerate_marker(queryset, inner_border=True)
96+
for marker in queryset:
97+
generate_marker_variants(marker, inner_border=True)
9698

9799

98100
@admin.action(description="Regenerate Marker Without Inner Border")
99101
def regenerate_marker_no_inner_border(modeladmin, request, queryset):
100102
"""Regenerate markers without inner border.
101103
This action will regenerate the marker images without a white inner border.
102104
"""
103-
regenerate_marker(queryset, inner_border=False)
105+
for marker in queryset:
106+
generate_marker_variants(marker, inner_border=False)
104107

105108

106109
@admin.action(description="Remove Border")
107110
def remove_border(modeladmin, request, queryset):
108-
"""Remove border from markers.
109-
This action will regenerate the marker images without any borders.
111+
"""Remove border from markers and regenerate all variants.
112+
This action will strip borders from the current source, save as original,
113+
then regenerate all variants.
110114
"""
111115
for marker in queryset:
112116
with Image.open(marker.source) as image:
113117
pil_image = remove_borders_from_image(image)
114118
blob = BytesIO()
115-
pil_image.save(blob, "JPEG")
116-
uuid = generate_uuid_name()
117-
filename = f"{uuid}.jpg"
118-
marker.file_size = marker.source.size
119-
marker.source.save(filename, File(blob), save=True)
120-
patt_str = generate_patt_from_image(pil_image)
121-
marker.patt.save(
122-
f"{uuid}.patt",
123-
ContentFile(patt_str.encode("utf-8")),
124-
save=True,
125-
)
126-
marker.save()
127-
128-
129-
def regenerate_marker(queryset, inner_border=False):
130-
for marker in queryset:
131-
with Image.open(marker.source) as image:
132-
pil_image = MarkerGeneratorAPIView.generate_marker(
133-
image, inner_border=inner_border
134-
)
135-
blob = BytesIO()
136-
pil_image.save(blob, "JPEG")
137-
uuid = generate_uuid_name()
138-
filename = f"{uuid}.jpg"
139-
marker.file_size = marker.source.size
119+
pil_image.save(blob, "PNG")
120+
filename = f"markers/{marker.pk}/original.png"
140121
marker.source.save(filename, File(blob), save=True)
141-
patt_str = generate_patt_from_image(image)
142-
marker.patt.save(
143-
f"{uuid}.patt",
144-
ContentFile(patt_str.encode("utf-8")),
145-
save=True,
146-
)
147-
marker.save()
122+
generate_marker_variants(marker, inner_border=False)
148123

149124

150125
@admin.register(Marker)
@@ -159,17 +134,123 @@ def image_preview(self, obj):
159134
return format_html(obj.as_html_thumbnail(), "")
160135

161136

137+
@admin.action(description="Generate spritesheets for selected GIF objects")
138+
def generate_spritesheets(modeladmin, request, queryset):
139+
"""Generate (or regenerate) PNG spritesheets and metadata JSON for GIF objects.
140+
141+
Running this action twice replaces the existing spritesheet and metadata
142+
files, keeping only the original GIF source unchanged.
143+
"""
144+
for obj in queryset.filter(file_extension="gif"):
145+
if not obj.source:
146+
continue
147+
148+
try:
149+
storage = obj.source.storage
150+
151+
with obj.source.open("rb") as f:
152+
png_bytes, metadata = gif_to_spritesheet(f)
153+
154+
# Save spritesheet PNG to objects/<pk>/spritesheet.png
155+
spritesheet_path = f"objects/{obj.pk}/spritesheet.png"
156+
_save_to_storage(storage, spritesheet_path, png_bytes)
157+
obj.spritesheet_file.name = spritesheet_path
158+
159+
# Save metadata JSON to objects/<pk>/metadata.json
160+
metadata_path = f"objects/{obj.pk}/metadata.json"
161+
_save_to_storage(
162+
storage, metadata_path, json.dumps(metadata).encode("utf-8")
163+
)
164+
obj.spritesheet_metadata.name = metadata_path
165+
166+
obj.save()
167+
except Exception as e:
168+
modeladmin.message_user(
169+
request,
170+
f"Failed to generate spritesheet for Object {obj.pk}: {e}",
171+
level="error",
172+
)
173+
174+
175+
def _save_to_storage(storage, path, content_bytes):
176+
"""Save content to storage, deleting existing file first for idempotency."""
177+
try:
178+
if storage.exists(path):
179+
storage.delete(path)
180+
except Exception:
181+
pass
182+
storage.save(path, ContentFile(content_bytes))
183+
184+
185+
class SpritesheetFilter(admin.SimpleListFilter):
186+
title = "spritesheet status"
187+
parameter_name = "has_spritesheet"
188+
189+
def lookups(self, request, model_admin):
190+
return [
191+
("yes", "Has spritesheet"),
192+
("no", "Missing spritesheet (GIF only)"),
193+
]
194+
195+
def queryset(self, request, queryset):
196+
if self.value() == "yes":
197+
return queryset.exclude(spritesheet_file="").exclude(spritesheet_file=None)
198+
if self.value() == "no":
199+
from django.db.models import Q
200+
201+
return queryset.filter(file_extension="gif").filter(
202+
Q(spritesheet_file="") | Q(spritesheet_file=None)
203+
)
204+
return queryset
205+
206+
162207
@admin.register(Object)
163208
class ObjectAdmin(BaseMarkerObjectAdmin):
164209
list_display = BaseMarkerObjectAdmin.list_display + [
165210
"file_extension",
211+
"has_spritesheet",
166212
]
167213
search_fields = ["title", "id"]
168-
list_filter = ["file_extension"]
214+
list_filter = ["file_extension", SpritesheetFilter]
215+
actions = [generate_spritesheets, "populate_dimensions"]
169216

170217
def image_preview(self, obj):
171218
return format_html(obj.as_html_thumbnail(), "")
172219

220+
def has_spritesheet(self, obj):
221+
return bool(obj.spritesheet_file and obj.spritesheet_metadata)
222+
223+
@admin.action(description="Populate width/height dimensions for selected objects")
224+
def populate_dimensions(self, request, queryset):
225+
updated = 0
226+
for obj in queryset:
227+
try:
228+
thumbnail = obj.thumbnail if obj.is_3d else None
229+
with obj.source.open("rb") as f:
230+
thumb_file = None
231+
if thumbnail:
232+
try:
233+
thumb_file = thumbnail.open("rb")
234+
except Exception:
235+
thumb_file = None
236+
237+
dims = extract_dimensions(f, obj.file_extension, thumb_file)
238+
239+
if thumb_file:
240+
thumb_file.close()
241+
242+
if dims:
243+
obj.width, obj.height = dims
244+
obj.save(update_fields=["width", "height"])
245+
updated += 1
246+
except Exception as e:
247+
self.message_user(
248+
request,
249+
f"Failed for Object {obj.pk}: {e}",
250+
level="error",
251+
)
252+
self.message_user(request, f"Updated dimensions for {updated} object(s).")
253+
173254

174255
@admin.register(Artwork)
175256
class ArtworkAdmin(admin.ModelAdmin):

0 commit comments

Comments
 (0)