1+ import json
12from io import BytesIO
23
34from django .contrib import admin
67from django .db .models import Count
78from django .utils .html import format_html
89from 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
1114from 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
1518HTML_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" )
99101def 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" )
107110def 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 )
163208class 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 )
175256class ArtworkAdmin (admin .ModelAdmin ):
0 commit comments