media-optimization-engine 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. media_engine/__init__.py +7 -0
  2. media_engine/adapters/__init__.py +0 -0
  3. media_engine/adapters/flutter/README.md +10 -0
  4. media_engine/adapters/flutter/__init__.py +0 -0
  5. media_engine/adapters/flutter/models.dart +88 -0
  6. media_engine/adapters/marzipano/__init__.py +2 -0
  7. media_engine/adapters/marzipano/manifest.py +53 -0
  8. media_engine/adapters/pannellum/__init__.py +2 -0
  9. media_engine/adapters/pannellum/manifest.py +23 -0
  10. media_engine/adapters/threejs/__init__.py +2 -0
  11. media_engine/adapters/threejs/manifest.py +10 -0
  12. media_engine/admin.py +36 -0
  13. media_engine/apps.py +11 -0
  14. media_engine/auth.py +33 -0
  15. media_engine/autoregister.py +108 -0
  16. media_engine/control_plane.py +274 -0
  17. media_engine/doctor.py +177 -0
  18. media_engine/focal_detectors.py +28 -0
  19. media_engine/health.py +78 -0
  20. media_engine/image_ops.py +142 -0
  21. media_engine/integration.py +34 -0
  22. media_engine/locks.py +20 -0
  23. media_engine/management/__init__.py +0 -0
  24. media_engine/management/commands/__init__.py +0 -0
  25. media_engine/management/commands/audit_media_engine.py +54 -0
  26. media_engine/management/commands/audit_panorama_media.py +20 -0
  27. media_engine/management/commands/backfill_panorama_media.py +32 -0
  28. media_engine/management/commands/backfill_registered_media.py +21 -0
  29. media_engine/management/commands/backfill_responsive_images.py +27 -0
  30. media_engine/management/commands/cleanup_image_derivatives.py +30 -0
  31. media_engine/management/commands/media_engine_doctor.py +15 -0
  32. media_engine/management/commands/media_engine_node_report.py +24 -0
  33. media_engine/management/commands/rollback_media_engine_config.py +14 -0
  34. media_engine/management/commands/sync_media_engine_hub.py +20 -0
  35. media_engine/manifest.py +89 -0
  36. media_engine/metrics.py +34 -0
  37. media_engine/migrations/0001_initial.py +87 -0
  38. media_engine/migrations/0002_panorama_multires.py +44 -0
  39. media_engine/migrations/0003_panorama_cube_tiles.py +31 -0
  40. media_engine/migrations/__init__.py +0 -0
  41. media_engine/models.py +145 -0
  42. media_engine/node.py +29 -0
  43. media_engine/processors/__init__.py +0 -0
  44. media_engine/processors/base.py +21 -0
  45. media_engine/processors/image/__init__.py +0 -0
  46. media_engine/processors/image/standard.py +16 -0
  47. media_engine/processors/panorama/__init__.py +5 -0
  48. media_engine/processors/panorama/cube_tiles.py +121 -0
  49. media_engine/processors/panorama/cubemap.py +40 -0
  50. media_engine/processors/panorama/detect.py +23 -0
  51. media_engine/processors/panorama/equirectangular.py +149 -0
  52. media_engine/processors/panorama/manifest.py +105 -0
  53. media_engine/processors/panorama/tiles.py +88 -0
  54. media_engine/processors/registry.py +17 -0
  55. media_engine/profiles.py +77 -0
  56. media_engine/queueing.py +34 -0
  57. media_engine/registry.py +49 -0
  58. media_engine/runtime_config.py +37 -0
  59. media_engine/serializers.py +29 -0
  60. media_engine/services.py +187 -0
  61. media_engine/signals.py +5 -0
  62. media_engine/spec/openapi.yaml +191 -0
  63. media_engine/static/media_engine/adaptive-media.js +77 -0
  64. media_engine/static/media_engine/media-cache-sw.js +17 -0
  65. media_engine/storage_paths.py +13 -0
  66. media_engine/system_views.py +102 -0
  67. media_engine/tasks.py +44 -0
  68. media_engine/templatetags/__init__.py +0 -0
  69. media_engine/templatetags/responsive_media.py +262 -0
  70. media_engine/tests/__init__.py +0 -0
  71. media_engine/tests/test_distributed_node.py +72 -0
  72. media_engine/tests/test_image_ops.py +14 -0
  73. media_engine/tests/test_panorama.py +31 -0
  74. media_engine/tests/test_paths.py +9 -0
  75. media_engine/tests/test_profiles.py +8 -0
  76. media_engine/tests/test_responsive_media.py +153 -0
  77. media_engine/urls.py +14 -0
  78. media_engine/validators.py +42 -0
  79. media_engine/views.py +111 -0
  80. media_optimization_engine-1.4.0.dist-info/METADATA +253 -0
  81. media_optimization_engine-1.4.0.dist-info/RECORD +85 -0
  82. media_optimization_engine-1.4.0.dist-info/WHEEL +5 -0
  83. media_optimization_engine-1.4.0.dist-info/entry_points.txt +2 -0
  84. media_optimization_engine-1.4.0.dist-info/licenses/LICENSE +21 -0
  85. media_optimization_engine-1.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,153 @@
1
+ from types import SimpleNamespace
2
+ from unittest.mock import patch
3
+
4
+ from django.test import SimpleTestCase
5
+
6
+ from media_engine.templatetags.responsive_media import media_image, media_url, responsive_image
7
+
8
+
9
+ class ResponsiveImageTagTests(SimpleTestCase):
10
+ def manifest(self):
11
+ return {
12
+ 'width': 1600,
13
+ 'height': 900,
14
+ 'dominant_color': '#123456',
15
+ 'original_url': '/media/original.png',
16
+ 'fallback': {'url': '/media/fallback.webp', 'width': 1280, 'height': 720},
17
+ 'variants': {
18
+ 'avif': [
19
+ {'url': '/media/480.avif', 'width': 480, 'height': 270},
20
+ {'url': '/media/960.avif', 'width': 960, 'height': 540},
21
+ ],
22
+ 'webp': [
23
+ {'url': '/media/480.webp', 'width': 480, 'height': 270},
24
+ {'url': '/media/960.webp', 'width': 960, 'height': 540},
25
+ {'url': '/media/1280.webp', 'width': 1280, 'height': 720},
26
+ ],
27
+ },
28
+ }
29
+
30
+ @patch('media_engine.templatetags.responsive_media.build_manifest')
31
+ def test_keeps_image_presentation_attributes_on_nested_img(self, build_manifest):
32
+ build_manifest.return_value = self.manifest()
33
+
34
+ html = str(
35
+ responsive_image(
36
+ object(),
37
+ css_class='site-logo rounded',
38
+ picture_class='media-picture site-logo-wrap',
39
+ style='object-fit:contain;width:100%',
40
+ element_id='brand-logo',
41
+ sizes='(max-width: 600px) 50vw, 240px',
42
+ )
43
+ )
44
+
45
+ self.assertIn('<picture class="media-picture site-logo-wrap">', html)
46
+ self.assertIn('class="site-logo rounded"', html)
47
+ self.assertIn('id="brand-logo"', html)
48
+ self.assertIn('style="background:#123456;object-fit:contain;width:100%"', html)
49
+ self.assertIn('srcset="/media/480.avif 480w, /media/960.avif 960w"', html)
50
+ self.assertIn('srcset="/media/480.webp 480w, /media/960.webp 960w, /media/1280.webp 1280w"', html)
51
+ self.assertIn('src="/media/fallback.webp"', html)
52
+
53
+ @patch('media_engine.templatetags.responsive_media.build_manifest')
54
+ def test_adaptive_mode_marks_picture_for_runtime_sizing(self, build_manifest):
55
+ build_manifest.return_value = self.manifest()
56
+
57
+ html = str(responsive_image(object(), adaptive=True))
58
+
59
+ self.assertIn('data-moe-adaptive="1"', html)
60
+
61
+ @patch('media_engine.templatetags.responsive_media.build_manifest')
62
+ def test_custom_loading_and_dimensions_override_defaults(self, build_manifest):
63
+ build_manifest.return_value = self.manifest()
64
+
65
+ html = str(
66
+ responsive_image(
67
+ object(),
68
+ role='hero',
69
+ width='320',
70
+ height='180',
71
+ loading='lazy',
72
+ decoding='sync',
73
+ fetchpriority='low',
74
+ )
75
+ )
76
+
77
+ self.assertIn('width="320"', html)
78
+ self.assertIn('height="180"', html)
79
+ self.assertIn('loading="lazy"', html)
80
+ self.assertIn('decoding="sync"', html)
81
+ self.assertIn('fetchpriority="low"', html)
82
+
83
+ @patch('media_engine.templatetags.responsive_media.build_manifest')
84
+ def test_existing_call_signature_remains_compatible(self, build_manifest):
85
+ build_manifest.return_value = self.manifest()
86
+
87
+ html = str(responsive_image(object(), profile='card', role='content', alt='Card'))
88
+
89
+ self.assertTrue(html.startswith('<picture>'))
90
+ self.assertIn('alt="Card"', html)
91
+ self.assertIn('loading="lazy"', html)
92
+ self.assertIn('width="1280"', html)
93
+ self.assertIn('height="720"', html)
94
+
95
+ @patch('media_engine.templatetags.responsive_media._binding_for')
96
+ @patch('media_engine.templatetags.responsive_media.build_manifest')
97
+ def test_media_image_resolves_model_field_binding(self, build_manifest, binding_for):
98
+ build_manifest.return_value = self.manifest()
99
+ binding_for.return_value = SimpleNamespace(
100
+ asset=object(),
101
+ profile='hero',
102
+ role='hero',
103
+ )
104
+
105
+ html = str(
106
+ media_image(
107
+ SimpleNamespace(pk=7),
108
+ 'cover_image',
109
+ alt='Article cover',
110
+ css_class='article-hero',
111
+ sizes='(max-width: 768px) 100vw, 1200px',
112
+ )
113
+ )
114
+
115
+ self.assertIn('<picture', html)
116
+ self.assertIn('class="article-hero"', html)
117
+ self.assertIn('loading="eager"', html)
118
+ self.assertIn('fetchpriority="high"', html)
119
+ self.assertIn('/media/960.avif 960w', html)
120
+
121
+ @patch('media_engine.templatetags.responsive_media._field_url')
122
+ @patch('media_engine.templatetags.responsive_media._binding_for')
123
+ def test_media_image_falls_back_to_field_url_without_binding(self, binding_for, field_url):
124
+ binding_for.return_value = None
125
+ field_url.return_value = '/uploads/photo.jpg'
126
+
127
+ html = str(
128
+ media_image(
129
+ SimpleNamespace(pk=9),
130
+ 'photo',
131
+ alt='Profile',
132
+ css_class='avatar',
133
+ )
134
+ )
135
+
136
+ self.assertTrue(html.startswith('<img'))
137
+ self.assertIn('src="/uploads/photo.jpg"', html)
138
+ self.assertIn('loading="lazy"', html)
139
+
140
+ @patch('media_engine.templatetags.responsive_media._binding_for')
141
+ @patch('media_engine.templatetags.responsive_media.build_manifest')
142
+ def test_media_url_selects_smallest_variant_at_or_above_target(self, build_manifest, binding_for):
143
+ build_manifest.return_value = self.manifest()
144
+ binding_for.return_value = SimpleNamespace(asset=object(), profile='default')
145
+
146
+ url = media_url(
147
+ SimpleNamespace(pk=3),
148
+ 'logo',
149
+ preferred_width=700,
150
+ preferred_format='webp',
151
+ )
152
+
153
+ self.assertEqual(url, '/media/960.webp')
media_engine/urls.py ADDED
@@ -0,0 +1,14 @@
1
+ from django.urls import path
2
+ from rest_framework.routers import DefaultRouter
3
+ from .views import MediaViewSet
4
+ from . import system_views
5
+
6
+ router = DefaultRouter()
7
+ router.register('images', MediaViewSet, basename='media-image')
8
+
9
+ urlpatterns = [
10
+ path('system/capabilities/', system_views.capabilities, name='media-engine-capabilities'),
11
+ path('system/health/', system_views.health, name='media-engine-health'),
12
+ path('system/version/', system_views.version, name='media-engine-version'),
13
+ path('system/openapi.yaml', system_views.openapi_yaml, name='media-engine-openapi'),
14
+ ] + router.urls
@@ -0,0 +1,42 @@
1
+ import io
2
+ from django.conf import settings
3
+ from django.core.exceptions import ValidationError
4
+ from PIL import Image
5
+ try:
6
+ import pillow_avif # noqa: F401
7
+ except Exception:
8
+ pillow_avif = None
9
+
10
+ ALLOWED_FORMATS = {'JPEG', 'PNG', 'WEBP', 'AVIF'}
11
+ MIME_BY_FORMAT = {
12
+ 'JPEG': 'image/jpeg',
13
+ 'PNG': 'image/png',
14
+ 'WEBP': 'image/webp',
15
+ 'AVIF': 'image/avif',
16
+ }
17
+
18
+
19
+ def inspect_upload(uploaded_file):
20
+ max_bytes = getattr(settings, 'MEDIA_ENGINE_MAX_UPLOAD_BYTES', 25 * 1024 * 1024)
21
+ if uploaded_file.size > max_bytes:
22
+ raise ValidationError(f'Image exceeds maximum upload size of {max_bytes} bytes.')
23
+
24
+ uploaded_file.seek(0)
25
+ raw = uploaded_file.read()
26
+ uploaded_file.seek(0)
27
+ try:
28
+ image = Image.open(io.BytesIO(raw))
29
+ image.verify()
30
+ image = Image.open(io.BytesIO(raw))
31
+ fmt = (image.format or '').upper()
32
+ if fmt not in ALLOWED_FORMATS:
33
+ raise ValidationError(f'Unsupported image format: {fmt or "unknown"}')
34
+ width, height = image.size
35
+ max_pixels = getattr(settings, 'MEDIA_ENGINE_MAX_PIXELS', 50_000_000)
36
+ if width * height > max_pixels:
37
+ raise ValidationError(f'Image exceeds maximum pixel count of {max_pixels}.')
38
+ except ValidationError:
39
+ raise
40
+ except Exception as exc:
41
+ raise ValidationError('Invalid or corrupted image.') from exc
42
+ return raw, fmt, MIME_BY_FORMAT[fmt], width, height
media_engine/views.py ADDED
@@ -0,0 +1,111 @@
1
+ from django.conf import settings
2
+ from django.http import HttpResponseRedirect
3
+ from django.shortcuts import get_object_or_404
4
+ from rest_framework import status
5
+ from rest_framework.decorators import action
6
+ from rest_framework.response import Response
7
+ from rest_framework.viewsets import ViewSet
8
+ from .manifest import build_manifest
9
+ from .models import MediaAsset, MediaVariant
10
+ from .profiles import get_profile
11
+ from .serializers import MediaUploadSerializer, MediaAssetSerializer
12
+ from .services import ingest_uploaded_file, generate_variant
13
+ from .queueing import enqueue_asset
14
+ from .auth import MediaEngineApiKeyPermission
15
+ from .node import scoped_owner_ref
16
+
17
+
18
+ class MediaViewSet(ViewSet):
19
+ permission_classes = [MediaEngineApiKeyPermission]
20
+
21
+ def create(self, request):
22
+ serializer = MediaUploadSerializer(data=request.data)
23
+ serializer.is_valid(raise_exception=True)
24
+ data = serializer.validated_data
25
+ asset, created = ingest_uploaded_file(
26
+ data['file'],
27
+ owner_ref=scoped_owner_ref(data.get('owner_ref', '')),
28
+ focal_x=data.get('focal_x'),
29
+ focal_y=data.get('focal_y'),
30
+ profile=data.get('profile', 'default'),
31
+ media_kind=data.get('media_kind', ''),
32
+ projection=data.get('projection', ''),
33
+ enqueue=True,
34
+ )
35
+ output = MediaAssetSerializer(asset, context={'profile': data.get('profile', 'default')}).data
36
+ return Response(output, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
37
+
38
+ def retrieve(self, request, pk=None):
39
+ asset = get_object_or_404(MediaAsset, pk=pk)
40
+ profile = request.query_params.get('profile', 'default')
41
+ return Response(MediaAssetSerializer(asset, context={'profile': profile}).data)
42
+
43
+ def partial_update(self, request, pk=None):
44
+ asset = get_object_or_404(MediaAsset, pk=pk)
45
+ changed = False
46
+ for field in ('focal_x', 'focal_y'):
47
+ if field in request.data:
48
+ value = float(request.data[field])
49
+ if not 0 <= value <= 1:
50
+ return Response({field: 'Must be between 0 and 1.'}, status=400)
51
+ setattr(asset, field, value)
52
+ changed = True
53
+ if changed:
54
+ asset.save(update_fields=['focal_x', 'focal_y', 'updated_at'])
55
+ return Response(MediaAssetSerializer(asset).data)
56
+
57
+ @action(detail=True, methods=['post'])
58
+ def regenerate(self, request, pk=None):
59
+ asset = get_object_or_404(MediaAsset, pk=pk)
60
+ profile = request.data.get('profile', 'default')
61
+ enqueue_asset(asset.id, profile=profile, force=True)
62
+ return Response({'id': str(asset.id), 'status': 'queued', 'profile': profile}, status=202)
63
+
64
+ @action(detail=True, methods=['get'])
65
+ def manifest(self, request, pk=None):
66
+ asset = get_object_or_404(MediaAsset, pk=pk)
67
+ profile = request.query_params.get('profile', 'default')
68
+ return Response(build_manifest(asset, profile=profile))
69
+
70
+ @action(detail=True, methods=['get'])
71
+ def panorama(self, request, pk=None):
72
+ asset = get_object_or_404(MediaAsset, pk=pk)
73
+ profile = request.query_params.get('profile', 'panorama.multires')
74
+ manifest = build_manifest(asset, profile=profile)
75
+ adapter = request.query_params.get('adapter', 'generic').lower()
76
+ if adapter == 'pannellum':
77
+ from .adapters.pannellum import to_pannellum
78
+ manifest = to_pannellum(manifest)
79
+ elif adapter == 'marzipano':
80
+ from .adapters.marzipano import to_marzipano
81
+ manifest = to_marzipano(manifest)
82
+ elif adapter == 'threejs':
83
+ from .adapters.threejs import to_threejs
84
+ manifest = to_threejs(manifest)
85
+ elif adapter != 'generic':
86
+ return Response({'adapter': 'Unsupported adapter.'}, status=400)
87
+ return Response(manifest)
88
+
89
+ @action(detail=True, methods=['get'])
90
+ def render(self, request, pk=None):
91
+ asset = get_object_or_404(MediaAsset, pk=pk)
92
+ profile_name = request.query_params.get('profile', 'default')
93
+ profile = get_profile(profile_name)
94
+ fmt = request.query_params.get('format', 'avif').lower()
95
+ width = int(request.query_params.get('width', 640))
96
+ if fmt not in set(profile.get('formats', [])) | {profile.get('fallback_format', 'jpeg')}:
97
+ return Response({'format': 'Unsupported format.'}, status=400)
98
+ width = min(width, asset.width)
99
+ variant = MediaVariant.objects.filter(
100
+ asset=asset,
101
+ profile=profile_name,
102
+ width=width,
103
+ format=fmt,
104
+ processor_version=getattr(settings, 'MEDIA_ENGINE_PIPELINE_VERSION', 1),
105
+ status=MediaVariant.Status.READY,
106
+ ).first()
107
+ if variant is None:
108
+ variant = generate_variant(asset, profile_name=profile_name, width=width, fmt=fmt)
109
+ if variant is None:
110
+ return Response({'status': 'processing', 'retry_after': 1}, status=202)
111
+ return HttpResponseRedirect(variant.file.url)
@@ -0,0 +1,253 @@
1
+ Metadata-Version: 2.4
2
+ Name: media-optimization-engine
3
+ Version: 1.4.0
4
+ Summary: Media Optimization Engineer for Django/Python: automatic model-field optimization, responsive picture/srcset rendering, AVIF/WebP derivatives, 360 media, autonomous nodes, and CDN-ready storage.
5
+ Author-email: Achille Kabasele <pepexykabasele@gmail.com>
6
+ Maintainer-email: Achille Kabasele <pepexykabasele@gmail.com>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/Kabasele754/media-optimization-engine
9
+ Project-URL: Repository, https://github.com/Kabasele754/media-optimization-engine
10
+ Project-URL: Issues, https://github.com/Kabasele754/media-optimization-engine/issues
11
+ Project-URL: Documentation, https://github.com/Kabasele754/media-optimization-engine/tree/master/docs
12
+ Keywords: django,media,image-optimization,avif,webp,panorama,360,marzipano,cdn,cloudflare-r2
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 4.2
16
+ Classifier: Framework :: Django :: 5.2
17
+ Classifier: Framework :: Django :: 6.0
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
25
+ Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
26
+ Requires-Python: >=3.11
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: Django<6.1,>=4.2.30
30
+ Requires-Dist: djangorestframework<4,>=3.14
31
+ Requires-Dist: Pillow<13,>=10.3
32
+ Requires-Dist: pillow-avif-plugin<2,>=1.5
33
+ Requires-Dist: numpy<3,>=1.26
34
+ Requires-Dist: blurhash<2,>=1.1
35
+ Provides-Extra: celery
36
+ Requires-Dist: celery<6,>=5.4; extra == "celery"
37
+ Requires-Dist: redis<7,>=5; extra == "celery"
38
+ Provides-Extra: postgres
39
+ Requires-Dist: psycopg[binary]<4,>=3.2; extra == "postgres"
40
+ Provides-Extra: s3
41
+ Requires-Dist: boto3<2,>=1.35; extra == "s3"
42
+ Requires-Dist: django-storages[s3]<2,>=1.14; extra == "s3"
43
+ Provides-Extra: r2
44
+ Requires-Dist: boto3<2,>=1.35; extra == "r2"
45
+ Requires-Dist: django-storages[s3]<2,>=1.14; extra == "r2"
46
+ Provides-Extra: observability
47
+ Requires-Dist: prometheus-client<1,>=0.21; extra == "observability"
48
+ Provides-Extra: all
49
+ Requires-Dist: celery<6,>=5.4; extra == "all"
50
+ Requires-Dist: redis<7,>=5; extra == "all"
51
+ Requires-Dist: psycopg[binary]<4,>=3.2; extra == "all"
52
+ Requires-Dist: boto3<2,>=1.35; extra == "all"
53
+ Requires-Dist: django-storages[s3]<2,>=1.14; extra == "all"
54
+ Requires-Dist: prometheus-client<1,>=0.21; extra == "all"
55
+ Dynamic: license-file
56
+
57
+ # Media Optimization Engineer 1.4
58
+
59
+ Media Optimization Engineer (MOE) is a reusable Django/Python package for image optimization, responsive delivery, and 360-degree media processing.
60
+
61
+ The public package is intentionally application-agnostic. It does not depend on any consuming project's themes, tenants, brands, business models, or private infrastructure.
62
+
63
+ ## Core capabilities
64
+
65
+ ### Standard images
66
+
67
+ - automatic ImageField / FileField integration;
68
+ - immutable originals;
69
+ - AVIF, WebP and JPEG derivatives;
70
+ - responsive width sets;
71
+ - semantic profiles such as avatar, card, hero, and default;
72
+ - focal-point aware crops;
73
+ - dominant color, BlurHash and lightweight placeholders;
74
+ - SHA-256 deduplication;
75
+ - local, S3-compatible and Cloudflare R2 storage;
76
+ - Redis/Celery processing when enabled;
77
+ - CDN-ready derivative URLs;
78
+ - Django template tags for responsive rendering.
79
+
80
+ ### 360 panoramas
81
+
82
+ - equirectangular panorama support;
83
+ - progressive preview;
84
+ - multiresolution pyramids;
85
+ - tiled AVIF/WebP delivery;
86
+ - generic manifests;
87
+ - Pannellum, Marzipano and Three.js adapters;
88
+ - optional cubemap generation.
89
+
90
+ ## Install
91
+
92
+ ~~~bash
93
+ pip install media-optimization-engine
94
+ ~~~
95
+
96
+ Optional infrastructure extras:
97
+
98
+ ~~~bash
99
+ pip install "media-optimization-engine[all]"
100
+ ~~~
101
+
102
+ ## Django setup
103
+
104
+ ~~~python
105
+ # settings.py
106
+ INSTALLED_APPS = [
107
+ # ...
108
+ "rest_framework",
109
+ "media_engine",
110
+ ]
111
+
112
+ MEDIA_ENGINE_TASK_MODE = "auto"
113
+
114
+ MEDIA_ENGINE_AUTO_FIELDS = {
115
+ "articles.Article": {
116
+ "cover_image": {
117
+ "profile": "hero",
118
+ "role": "hero",
119
+ },
120
+ },
121
+ "accounts.Profile": {
122
+ "photo": {
123
+ "profile": "avatar",
124
+ "role": "content",
125
+ },
126
+ },
127
+ "organizations.Organization": {
128
+ "logo": {
129
+ "profile": "default",
130
+ "role": "content",
131
+ },
132
+ },
133
+ }
134
+ ~~~
135
+
136
+ ~~~python
137
+ # urls.py
138
+ from django.urls import include, path
139
+
140
+ urlpatterns = [
141
+ path("api/v1/", include("media_engine.urls")),
142
+ ]
143
+ ~~~
144
+
145
+ Then:
146
+
147
+ ~~~bash
148
+ python manage.py migrate
149
+ python manage.py media_engine_doctor
150
+ ~~~
151
+
152
+ ## Django template rendering
153
+
154
+ For normal model fields, use the high-level media_image tag:
155
+
156
+ ~~~django
157
+ {% load responsive_media %}
158
+
159
+ {% media_image article "cover_image"
160
+ alt=article.title
161
+ css_class="article-hero"
162
+ sizes="(max-width: 768px) 100vw, 1200px"
163
+ %}
164
+ ~~~
165
+
166
+ MOE resolves the field's MediaBinding, generates a responsive picture with available AVIF/WebP sources, preserves the image's CSS classes, and falls back to the original field URL only when no processed binding is available.
167
+
168
+ ### Logo or footer image
169
+
170
+ ~~~django
171
+ {% load responsive_media %}
172
+
173
+ {% media_image organization "logo"
174
+ alt=organization.name
175
+ css_class="site-logo"
176
+ sizes="240px"
177
+ %}
178
+ ~~~
179
+
180
+ ### Card grid
181
+
182
+ ~~~django
183
+ {% load responsive_media %}
184
+
185
+ {% for article in articles %}
186
+ <article class="article-card">
187
+ {% media_image article "cover_image"
188
+ alt=article.title
189
+ css_class="article-card__image"
190
+ sizes="(max-width: 640px) 92vw, (max-width: 1100px) 45vw, 360px"
191
+ %}
192
+ <h2>{{ article.title }}</h2>
193
+ </article>
194
+ {% endfor %}
195
+ ~~~
196
+
197
+ ### Direct binding rendering
198
+
199
+ Advanced integrations can render a known asset directly:
200
+
201
+ ~~~django
202
+ {% load responsive_media %}
203
+
204
+ {% responsive_image binding.asset
205
+ profile=binding.profile
206
+ role=binding.role
207
+ alt=object.title
208
+ css_class="media-object"
209
+ picture_class="media-object-picture"
210
+ sizes="100vw"
211
+ %}
212
+ ~~~
213
+
214
+ ### One optimized URL
215
+
216
+ For Open Graph metadata, CSS backgrounds, emails, or APIs where picture is not possible:
217
+
218
+ ~~~django
219
+ {% load responsive_media %}
220
+
221
+ {% media_url organization "logo" preferred_width=512 preferred_format="webp" as optimized_logo %}
222
+
223
+ <meta property="og:image" content="{{ optimized_logo }}">
224
+ ~~~
225
+
226
+ For normal page images, prefer media_image over media_url; the browser can make a better final choice from srcset and sizes.
227
+
228
+ ## Responsive selection
229
+
230
+ MOE provides width descriptors and sizes. The browser chooses the final resource using layout width, viewport, DPR, supported formats, and its own network heuristics.
231
+
232
+ JavaScript is not required for normal responsive images.
233
+
234
+ Runtime measurement is appropriate only for highly dynamic components whose rendered width cannot be described reliably with sizes, such as resizable editors, canvas-like builders, or asynchronously mounted panels.
235
+
236
+ ## Backfill
237
+
238
+ Historical media can be ingested with maintenance commands:
239
+
240
+ ~~~bash
241
+ python manage.py backfill_registered_media
242
+ python manage.py audit_media_engine --fail-on-incomplete
243
+ ~~~
244
+
245
+ New uploads registered in MEDIA_ENGINE_AUTO_FIELDS are handled automatically.
246
+
247
+ ## Runtime independence
248
+
249
+ Embedded Django mode and standalone-node mode are autonomous. Optional control-plane synchronization must never be required for upload, processing, rendering, or derivative delivery.
250
+
251
+ ## License
252
+
253
+ MIT License. Copyright (c) 2026 Achille Kabasele.
@@ -0,0 +1,85 @@
1
+ media_engine/__init__.py,sha256=HfAxEc4JmiSOgrNiFiJht-4V2AUW8vplhQrM6QgcvUI,227
2
+ media_engine/admin.py,sha256=X-vE76CV_Ow6ylObT-mm-wD7O-Tl1iw1QhwWtVVIFtU,1588
3
+ media_engine/apps.py,sha256=Dh4H86cECcBR5kLkqZnXcU7xWEPXQnCz4iH0KpmRe6g,319
4
+ media_engine/auth.py,sha256=nd3enjjvKNd788czIqmm7WTXqrPk01OHxb4KduYHGPc,1119
5
+ media_engine/autoregister.py,sha256=WNWDcT2sFUAjuMKavgB84txOQjE8XHWDaLTMJqD3aQc,3731
6
+ media_engine/control_plane.py,sha256=g2xCx7-wgP6KjNQbIe6petfQ4PvAMHcRkAWwIEqDdDI,10094
7
+ media_engine/doctor.py,sha256=o2sn5gW-Pf7wttukRQ3EM3D5Gol-5WZz9tF8_S1AbNk,6122
8
+ media_engine/focal_detectors.py,sha256=qRX6QWC7lYRpsbIjdIHtYAMCymmxDBKJhxazai6E4CA,997
9
+ media_engine/health.py,sha256=DjNxIe8_5gmPjzjhMka7MD_XMyMBAVIEB_edRwm4ttw,2716
10
+ media_engine/image_ops.py,sha256=GgVUVsKMlKd6gPsE9IhfTPC-p9FPVg8pXjuGm9NKPMo,4574
11
+ media_engine/integration.py,sha256=JVZLtet9lmNb0tQ5z2bBVhQJhM4tHkZaaEHiBbgS1YY,1519
12
+ media_engine/locks.py,sha256=kJfPamg26jXTC7O904DnBXMefWwOtEC82rVrAZd0Hig,506
13
+ media_engine/manifest.py,sha256=95LkdM7UEt9Za6oaj5fFO9i2RJYG8G-AJlj-F7mc3v0,3566
14
+ media_engine/metrics.py,sha256=CvYPUt3RTM0AdSk32i9jxp2JDGDDgOr0aPvGll2b_Mc,1648
15
+ media_engine/models.py,sha256=uAUc9lN8pzPiRyAs2TPstPJUzQ--hhJwh7CFpibkinU,6428
16
+ media_engine/node.py,sha256=5lIusUkZ-ALb9c4qGrnP4PsZG8jGM7wmsd09HSMfaxo,887
17
+ media_engine/profiles.py,sha256=4_akGg8J5IQ0Q57kqpbwgHKva-_-DjuJIfXCDrXI2IU,2478
18
+ media_engine/queueing.py,sha256=eqxLlnUIevN6_Pw3temJSsbhFxGYKPQMt9ufA1Wj0OQ,1336
19
+ media_engine/registry.py,sha256=lNqz8AzcpoJglc-yphcfgYiucfjLuU8vSUPqBcSX9I8,1371
20
+ media_engine/runtime_config.py,sha256=4ImMovJGe2vDQnRKDwSC1Ag2dkfjqIyrJmmeN-pNZ6A,1267
21
+ media_engine/serializers.py,sha256=saq7y8tR6gsWsavueX5nbMF_6KeVQLUdchs4Str0Yvw,1308
22
+ media_engine/services.py,sha256=aFtehPz9dyvjW15r2N4T2XqW4KWKQfXSOhOAk1_pD4A,8380
23
+ media_engine/signals.py,sha256=GSUedn5Oe5wT9nVF5vOt-YvBpgtqYkRAFDMjaq5ZoOo,248
24
+ media_engine/storage_paths.py,sha256=wGjWWOH6uPk5kviERlUlBidmyAFJA37g1CIB5Tt0hqM,500
25
+ media_engine/system_views.py,sha256=ik1VGOgprvLA5HMmmkZFff5ONMKKNqaLuVyRoq_acEY,3508
26
+ media_engine/tasks.py,sha256=DY4d2qzTLJRBGg86mDseS8_ty4XSCspj9YnpdMFdgYE,1514
27
+ media_engine/urls.py,sha256=_XVaI5DYdFjJg0UYceRBSndIy5yrms2-GOe0n_gSES0,601
28
+ media_engine/validators.py,sha256=G1IKFh34lHGTftfhJNEXui1orspBVglJEgEHI2K0Iec,1432
29
+ media_engine/views.py,sha256=22T7WMmCCMfbnYwqd9HnbUICEG6FCFI7xQj6uVpNI7w,5110
30
+ media_engine/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
+ media_engine/adapters/flutter/README.md,sha256=j0r7Mecx6OMBM1RXh-7uR6PJjqvpkUoI8BDnG6b_EM4,495
32
+ media_engine/adapters/flutter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
+ media_engine/adapters/flutter/models.dart,sha256=wvnfERfMgtrZAKBzYJPo8ntDrVmn9geC35KVGU-9CnM,2349
34
+ media_engine/adapters/marzipano/__init__.py,sha256=FZiH8ei3WWwnFZXunyFM5uNwznI4HTddTwz1XzWho_E,62
35
+ media_engine/adapters/marzipano/manifest.py,sha256=eXLTAKM_qG0C44AliXGDCIgWvLsBYYhzlOqeVUE6W6Q,1917
36
+ media_engine/adapters/pannellum/__init__.py,sha256=GR2Mhcsj0rf5uLSXTpPTentrUxdrxNA4arafn2a-_qY,62
37
+ media_engine/adapters/pannellum/manifest.py,sha256=RY9DnwvQAPKiEhmRTeXftq5xzL1HvF_qrFsX-aZx00E,753
38
+ media_engine/adapters/threejs/__init__.py,sha256=1DUIYZrcFRHKqBtXTUO2oGhqsh7C-EJI6Uy-3QzzyQk,58
39
+ media_engine/adapters/threejs/manifest.py,sha256=qhYZYKHCl_xyBOJITKqhtgx-CuyQYCV9utWbGQDXN1c,303
40
+ media_engine/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
41
+ media_engine/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
42
+ media_engine/management/commands/audit_media_engine.py,sha256=91VNMOahisVMp5tTHNzZCzuFSLl8nxGGEAW_jc-Lm5g,2620
43
+ media_engine/management/commands/audit_panorama_media.py,sha256=k-j0ic-RlEO9LVYd5ZX_ThTFNlxI_2llfl5P_DPPktw,998
44
+ media_engine/management/commands/backfill_panorama_media.py,sha256=tw5aOPqZYBEGOqjFzvVTZpSRmsUxYSwDXZ4YODNWEYI,1512
45
+ media_engine/management/commands/backfill_registered_media.py,sha256=jTWb9tWQJL4PfJm80z3C6i8JQ7_9LDlwdR5Q0dc6MfE,840
46
+ media_engine/management/commands/backfill_responsive_images.py,sha256=vfkvDIIQ8-8hEBY6umFKQRTRXDli2BZCfOj_F7q9djA,1157
47
+ media_engine/management/commands/cleanup_image_derivatives.py,sha256=5e3IHQVboI40C8_DwyyhP7xjjhx42NbUbjrSGGWEwZQ,1300
48
+ media_engine/management/commands/media_engine_doctor.py,sha256=TTSan-nOVBhZ6yC7c4lH85fxUwsz5FrnJfKGrxDfniU,510
49
+ media_engine/management/commands/media_engine_node_report.py,sha256=kOOhTRAsyhpY5Rv5ZQo6RIEbmz12R7aSoYhy1M1mzzc,1163
50
+ media_engine/management/commands/rollback_media_engine_config.py,sha256=0T0I7lvALdhHMNk5RP_3UrjSpaRL-fE5Nyy4r6BL9kM,515
51
+ media_engine/management/commands/sync_media_engine_hub.py,sha256=5g91YJsx8feh86yZ5OY7yFdCZbfUzHszxWjsJFYIeAE,787
52
+ media_engine/migrations/0001_initial.py,sha256=lY20ercltw3f_DJmltDIrpnHVUM4X0amvoznNpXj63Y,5226
53
+ media_engine/migrations/0002_panorama_multires.py,sha256=dpKzGuHKJ6CtqJO-xgH56MIrKtnhJ-Jlvk731GmYsKo,3117
54
+ media_engine/migrations/0003_panorama_cube_tiles.py,sha256=lJLQx3k0myJoHcucojtBZWGL6srUcRYQVv7XzMPTZZI,1030
55
+ media_engine/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
56
+ media_engine/processors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
+ media_engine/processors/base.py,sha256=tXWzY2m-gYq9qCx9zUrlmJaK6qrmGhgB4bKcilk5cK4,421
58
+ media_engine/processors/registry.py,sha256=Uf-5UvRSm3gyIq6tbU3EKYuwBR2Gk3TUxLpNETc9RN4,546
59
+ media_engine/processors/image/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
60
+ media_engine/processors/image/standard.py,sha256=t_uAX8ftaTmR35XBFCi87sG7LACC8Qi5hKeAvOfXL3A,602
61
+ media_engine/processors/panorama/__init__.py,sha256=x8U9y8fzCXPZVrXew_swVXqdF6ITgnNc0RRkvKNJsiY,216
62
+ media_engine/processors/panorama/cube_tiles.py,sha256=ITKi1ZfiQmlI5RfqDAXF3X44VWcz7rcF45baQ4lLg0g,3969
63
+ media_engine/processors/panorama/cubemap.py,sha256=dwWftLKCWOYh5u4wP3guCReAbDotJy5FO8XQsN-TdCc,1423
64
+ media_engine/processors/panorama/detect.py,sha256=nG4mT4syeAUVo441tt95xHLIwrmX-ys7kq8XJEqZ6pQ,898
65
+ media_engine/processors/panorama/equirectangular.py,sha256=CL9HTRb6KwIqFOGO2CLqz7CIWK4x547DTFgnzRCKNdU,7267
66
+ media_engine/processors/panorama/manifest.py,sha256=F7jz9bQnJ8mGJyT90zm7CVFbtnGuBgWgq7eM7EZdBwY,4037
67
+ media_engine/processors/panorama/tiles.py,sha256=IW1WFan8e0Yfmwskmxdwax1A3yEyVF0R45JXn2QrusQ,2956
68
+ media_engine/spec/openapi.yaml,sha256=CD7zoD9ltUFKzITqm9jrNCrBYzlr1EdDB6YiozCDILs,5738
69
+ media_engine/static/media_engine/adaptive-media.js,sha256=ggoeknJBevT3A8T57HRzPunCPyAD8e_P2Gow8_WcY3g,2129
70
+ media_engine/static/media_engine/media-cache-sw.js,sha256=pBMJfCspN0SA1MPU0YNTySYYf3m9BTmANjjhh_CL45U,549
71
+ media_engine/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
72
+ media_engine/templatetags/responsive_media.py,sha256=meEdGn5IhGIPvWuktM5RSoQwQxwKUl57CJN-A21bKuE,7341
73
+ media_engine/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
74
+ media_engine/tests/test_distributed_node.py,sha256=Ev8TSuZk-te5aGIS9rTq1z8GDzZVCJk0iagFFMro14s,3142
75
+ media_engine/tests/test_image_ops.py,sha256=CG0VRh85I_y7Mv1vBs4Mu61szc0FoOgBLWhc5U-ejCk,494
76
+ media_engine/tests/test_panorama.py,sha256=XHz7hmja3LBX37oALkCl-X1Eg1sofdHcvRoQZ-ljVNY,1332
77
+ media_engine/tests/test_paths.py,sha256=GKdVZQfGEnffxTD4AIWMHZ0qtE-K5AVm1P9nur7QOig,404
78
+ media_engine/tests/test_profiles.py,sha256=oOhdvyRbZGW-TI-TI0BMIoPNSAALhmBnKlzBMQ8SOh4,300
79
+ media_engine/tests/test_responsive_media.py,sha256=zv50HVVIZA6cjLFvrimE9wgKrxwz-YCmhfixNijSVeA,5968
80
+ media_optimization_engine-1.4.0.dist-info/licenses/LICENSE,sha256=1opBmAobedP1rC2gF7n_zFbvJ7tXT6McLaJzLBEmJEE,1073
81
+ media_optimization_engine-1.4.0.dist-info/METADATA,sha256=4RX_SSqTnY7d2ci8Lslubo8ByY7D8VbE3QgqX6ZrC1E,7535
82
+ media_optimization_engine-1.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
83
+ media_optimization_engine-1.4.0.dist-info/entry_points.txt,sha256=yqnRhbItLlCUBvkLAH-Waq4Rlz2RXiH5dXzi3VEymjE,65
84
+ media_optimization_engine-1.4.0.dist-info/top_level.txt,sha256=T31aKQHDr9u7GPiEULX4KCNwjssi5x2jsCqZ0EVCscA,13
85
+ media_optimization_engine-1.4.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ media-engine-doctor = media_engine.doctor:main