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,102 @@
1
+ import json
2
+ from importlib import resources
3
+ from pathlib import Path
4
+
5
+ from django.conf import settings
6
+ from django.http import HttpResponse, JsonResponse
7
+ from django.views.decorators.http import require_GET
8
+
9
+ from .control_plane import control_plane_status
10
+ from .health import build_health_report
11
+ from .node import get_node_identity
12
+ from .runtime_config import effective_profiles
13
+
14
+
15
+ def _version():
16
+ return getattr(settings, 'MEDIA_ENGINE_VERSION', '1.4.0')
17
+
18
+
19
+ @require_GET
20
+ def well_known(request):
21
+ identity = get_node_identity()
22
+ return JsonResponse({
23
+ 'service': 'Media Optimization Engine',
24
+ 'version': _version(),
25
+ 'api_version': 'v1',
26
+ 'node': identity.as_dict(),
27
+ 'autonomy': {
28
+ 'runtime_requires_control_plane': False,
29
+ 'last_known_good_config': True,
30
+ 'panorama_multires': True,
31
+ 'panorama_cubemap': True,
32
+ 'viewer_adapters': ['generic', 'pannellum', 'marzipano', 'threejs', 'flutter'],
33
+ 'local_processing': True,
34
+ 'local_storage_supported': True,
35
+ 'object_storage_supported': True,
36
+ },
37
+ 'discovery': {
38
+ 'base_api': '/api/v1/',
39
+ 'capabilities': '/api/v1/system/capabilities/',
40
+ 'health': '/api/v1/system/health/',
41
+ 'openapi': '/api/v1/system/openapi.yaml',
42
+ 'version': '/api/v1/system/version/',
43
+ },
44
+ 'authentication': {
45
+ 'type': 'bearer_api_key',
46
+ 'header': 'Authorization: Bearer <key>',
47
+ 'required': bool(getattr(settings, 'MEDIA_ENGINE_REQUIRE_API_KEY', False)),
48
+ },
49
+ })
50
+
51
+
52
+ @require_GET
53
+ def capabilities(request):
54
+ return JsonResponse({
55
+ 'service': 'Media Optimization Engine',
56
+ 'version': _version(),
57
+ 'node': get_node_identity().as_dict(),
58
+ 'formats': ['avif', 'webp', 'jpeg'],
59
+ 'profiles': effective_profiles(),
60
+ 'features': {
61
+ 'content_addressed_storage': True,
62
+ 'deduplication': bool(getattr(settings, 'MEDIA_ENGINE_DEDUPLICATE', True)),
63
+ 'async_generation': True,
64
+ 'smart_quality_budget': True,
65
+ 'focal_point': True,
66
+ 'automatic_focal_point': bool(getattr(settings, 'MEDIA_ENGINE_AUTO_FOCAL', False)),
67
+ 'blurhash': True,
68
+ 'dominant_color': True,
69
+ 'cdn_ready': True,
70
+ 's3_r2_minio_ready': True,
71
+ 'offline_from_control_plane': True,
72
+ 'last_known_good_config': True,
73
+ 'panorama_multires': True,
74
+ 'panorama_cubemap': True,
75
+ 'viewer_adapters': ['generic', 'pannellum', 'marzipano', 'threejs', 'flutter'],
76
+ },
77
+ 'control_plane': control_plane_status(),
78
+ })
79
+
80
+
81
+ @require_GET
82
+ def version(request):
83
+ return JsonResponse({
84
+ 'service': 'Media Optimization Engine',
85
+ 'version': _version(),
86
+ 'pipeline_version': getattr(settings, 'MEDIA_ENGINE_PIPELINE_VERSION', 1),
87
+ 'node': get_node_identity().as_dict(),
88
+ })
89
+
90
+
91
+ @require_GET
92
+ def health(request):
93
+ deep = request.GET.get('deep', '').lower() in {'1', 'true', 'yes'}
94
+ report = build_health_report(deep=deep)
95
+ status = 503 if report['state'] == 'unhealthy' else 200
96
+ return JsonResponse(report, status=status)
97
+
98
+
99
+ @require_GET
100
+ def openapi_yaml(request):
101
+ spec = resources.files('media_engine').joinpath('spec/openapi.yaml').read_text(encoding='utf-8')
102
+ return HttpResponse(spec, content_type='application/yaml; charset=utf-8')
media_engine/tasks.py ADDED
@@ -0,0 +1,44 @@
1
+ from celery import shared_task
2
+ from django.conf import settings
3
+ from django.core.files.storage import default_storage
4
+ from django.utils import timezone
5
+ from datetime import timedelta
6
+ from .models import MediaAsset, MediaVariant
7
+ from .services import generate_all_variants
8
+
9
+
10
+ @shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={'max_retries': 3})
11
+ def generate_asset_variants(self, asset_id, profile='default', force=False):
12
+ asset = MediaAsset.objects.get(pk=asset_id)
13
+ generate_all_variants(asset, profile_name=profile, force=force)
14
+ return str(asset.id)
15
+
16
+
17
+ @shared_task
18
+ def cleanup_orphan_derivatives_task(days=30):
19
+ threshold = timezone.now() - timedelta(days=days)
20
+ current_version = getattr(settings, 'MEDIA_ENGINE_PIPELINE_VERSION', 1)
21
+ qs = MediaVariant.objects.filter(processor_version__lt=current_version, updated_at__lt=threshold)
22
+ removed = 0
23
+ for variant in qs.iterator():
24
+ if variant.file and default_storage.exists(variant.file.name):
25
+ default_storage.delete(variant.file.name)
26
+ variant.delete()
27
+ removed += 1
28
+ return removed
29
+
30
+
31
+ @shared_task
32
+ def worker_heartbeat_task():
33
+ import time
34
+ from django.core.cache import cache
35
+ from .health import HEARTBEAT_KEY
36
+ stamp = time.time()
37
+ cache.set(HEARTBEAT_KEY, stamp, timeout=300)
38
+ return stamp
39
+
40
+
41
+ @shared_task
42
+ def sync_control_plane_config_task():
43
+ from .control_plane import sync_from_control_plane
44
+ return sync_from_control_plane(force=False)
File without changes
@@ -0,0 +1,262 @@
1
+ from django import template
2
+ from django.contrib.contenttypes.models import ContentType
3
+ from django.utils.html import escape
4
+ from django.utils.safestring import mark_safe
5
+
6
+ from media_engine.manifest import build_manifest
7
+ from media_engine.models import MediaBinding
8
+
9
+ register = template.Library()
10
+
11
+
12
+ def _srcset(items):
13
+ return ', '.join(f"{escape(item['url'])} {item['width']}w" for item in items)
14
+
15
+
16
+ def _attrs(**values):
17
+ parts = []
18
+ for name, value in values.items():
19
+ if value in (None, ''):
20
+ continue
21
+ html_name = 'class' if name == 'css_class' else name.rstrip('_').replace('_', '-')
22
+ parts.append(f'{html_name}="{escape(value)}"')
23
+ return (' ' + ' '.join(parts)) if parts else ''
24
+
25
+
26
+ def _merge_style(dominant_color, style):
27
+ parts = []
28
+ if dominant_color:
29
+ parts.append(f'background:{dominant_color}')
30
+ if style:
31
+ parts.append(str(style).strip().rstrip(';'))
32
+ return ';'.join(part for part in parts if part)
33
+
34
+
35
+ def _binding_for(instance, field_name):
36
+ if instance is None or not getattr(instance, 'pk', None):
37
+ return None
38
+ content_type = ContentType.objects.get_for_model(instance, for_concrete_model=False)
39
+ return (
40
+ MediaBinding.objects.filter(
41
+ content_type=content_type,
42
+ object_id=str(instance.pk),
43
+ field_name=field_name,
44
+ )
45
+ .select_related('asset')
46
+ .first()
47
+ )
48
+
49
+
50
+ def _field_url(instance, field_name):
51
+ field_file = getattr(instance, field_name, None) if instance is not None else None
52
+ if not field_file:
53
+ return ''
54
+ try:
55
+ return field_file.url
56
+ except (AttributeError, ValueError):
57
+ return ''
58
+
59
+
60
+ def _fallback_img(
61
+ url,
62
+ *,
63
+ alt='',
64
+ css_class='',
65
+ width='',
66
+ height='',
67
+ loading='lazy',
68
+ decoding='async',
69
+ fetchpriority='auto',
70
+ style='',
71
+ element_id='',
72
+ ):
73
+ if not url:
74
+ return ''
75
+ attrs = _attrs(
76
+ src=url,
77
+ alt=alt,
78
+ css_class=css_class,
79
+ width=width,
80
+ height=height,
81
+ loading=loading,
82
+ decoding=decoding,
83
+ fetchpriority=fetchpriority,
84
+ style=style,
85
+ id=element_id,
86
+ )
87
+ return mark_safe(f'<img{attrs}>')
88
+
89
+
90
+ def _pick_variant(manifest, preferred_width=960, preferred_format='webp'):
91
+ variants = manifest.get('variants', {})
92
+ try:
93
+ target_width = max(1, int(preferred_width))
94
+ except (TypeError, ValueError):
95
+ target_width = 960
96
+
97
+ formats = []
98
+ for fmt in (preferred_format, 'webp', 'avif'):
99
+ if fmt and fmt not in formats:
100
+ formats.append(fmt)
101
+
102
+ for fmt in formats:
103
+ candidates = list(variants.get(fmt) or [])
104
+ if not candidates:
105
+ continue
106
+ candidates.sort(key=lambda item: int(item.get('width') or 0))
107
+ for item in candidates:
108
+ if int(item.get('width') or 0) >= target_width:
109
+ return item
110
+ return candidates[-1]
111
+
112
+ return manifest.get('fallback') or None
113
+
114
+
115
+ @register.simple_tag
116
+ def responsive_image(
117
+ asset,
118
+ profile='default',
119
+ role='content',
120
+ alt='',
121
+ css_class='',
122
+ sizes='100vw',
123
+ picture_class='',
124
+ picture_style='',
125
+ width='',
126
+ height='',
127
+ loading='',
128
+ decoding='async',
129
+ fetchpriority='',
130
+ style='',
131
+ element_id='',
132
+ adaptive=False,
133
+ ):
134
+ """Render responsive sources while keeping presentation attributes on the img."""
135
+ manifest = build_manifest(asset, profile=profile)
136
+ variants = manifest.get('variants', {})
137
+ fallback = manifest.get('fallback')
138
+ if not fallback:
139
+ original = manifest.get('original_url')
140
+ if not original:
141
+ return ''
142
+ fallback = {'url': original, 'width': manifest['width'], 'height': manifest['height']}
143
+
144
+ eager = role == 'hero'
145
+ resolved_loading = loading or ('eager' if eager else 'lazy')
146
+ priority = fetchpriority or ('high' if eager else 'auto')
147
+
148
+ source_parts = []
149
+ if variants.get('avif'):
150
+ source_parts.append(
151
+ f'<source type="image/avif" srcset="{_srcset(variants["avif"])}" sizes="{escape(sizes)}">'
152
+ )
153
+ if variants.get('webp'):
154
+ source_parts.append(
155
+ f'<source type="image/webp" srcset="{_srcset(variants["webp"])}" sizes="{escape(sizes)}">'
156
+ )
157
+
158
+ resolved_width = width or fallback.get('width') or manifest['width']
159
+ resolved_height = height or fallback.get('height') or manifest['height']
160
+ merged_style = _merge_style(manifest.get('dominant_color'), style)
161
+
162
+ img_attrs = _attrs(
163
+ src=fallback['url'],
164
+ alt=alt,
165
+ css_class=css_class,
166
+ width=str(resolved_width),
167
+ height=str(resolved_height),
168
+ loading=resolved_loading,
169
+ decoding=decoding or 'async',
170
+ fetchpriority=priority,
171
+ style=merged_style,
172
+ id=element_id,
173
+ )
174
+ picture_attrs = _attrs(
175
+ css_class=picture_class,
176
+ style=picture_style,
177
+ data_moe_adaptive='1' if adaptive else '',
178
+ )
179
+ return mark_safe(
180
+ f'<picture{picture_attrs}>' + ''.join(source_parts) + f'<img{img_attrs}></picture>'
181
+ )
182
+
183
+
184
+ @register.simple_tag
185
+ def media_image(
186
+ instance,
187
+ field_name,
188
+ profile='',
189
+ role='',
190
+ alt='',
191
+ css_class='',
192
+ sizes='100vw',
193
+ picture_class='',
194
+ picture_style='',
195
+ width='',
196
+ height='',
197
+ loading='',
198
+ decoding='async',
199
+ fetchpriority='',
200
+ style='',
201
+ element_id='',
202
+ adaptive=False,
203
+ ):
204
+ """Render a model ImageField or FileField through its MediaBinding."""
205
+ binding = _binding_for(instance, field_name)
206
+ resolved_role = role or (binding.role if binding else 'content')
207
+ if binding:
208
+ return responsive_image(
209
+ binding.asset,
210
+ profile=profile or binding.profile,
211
+ role=resolved_role,
212
+ alt=alt,
213
+ css_class=css_class,
214
+ sizes=sizes,
215
+ picture_class=picture_class,
216
+ picture_style=picture_style,
217
+ width=width,
218
+ height=height,
219
+ loading=loading,
220
+ decoding=decoding,
221
+ fetchpriority=fetchpriority,
222
+ style=style,
223
+ element_id=element_id,
224
+ adaptive=adaptive,
225
+ )
226
+
227
+ return _fallback_img(
228
+ _field_url(instance, field_name),
229
+ alt=alt,
230
+ css_class=css_class,
231
+ width=width,
232
+ height=height,
233
+ loading=loading or ('eager' if resolved_role == 'hero' else 'lazy'),
234
+ decoding=decoding or 'async',
235
+ fetchpriority=fetchpriority or ('high' if resolved_role == 'hero' else 'auto'),
236
+ style=style,
237
+ element_id=element_id,
238
+ )
239
+
240
+
241
+ @register.simple_tag
242
+ def media_url(
243
+ instance,
244
+ field_name,
245
+ preferred_width=960,
246
+ preferred_format='webp',
247
+ profile='',
248
+ ):
249
+ """Return one optimized derivative URL for CSS, metadata, email, or APIs."""
250
+ binding = _binding_for(instance, field_name)
251
+ if not binding:
252
+ return _field_url(instance, field_name)
253
+
254
+ manifest = build_manifest(binding.asset, profile=profile or binding.profile)
255
+ variant = _pick_variant(
256
+ manifest,
257
+ preferred_width=preferred_width,
258
+ preferred_format=preferred_format,
259
+ )
260
+ if variant and variant.get('url'):
261
+ return variant['url']
262
+ return manifest.get('original_url') or _field_url(instance, field_name)
File without changes
@@ -0,0 +1,72 @@
1
+ import json
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ from django.core.cache import cache
6
+ from django.test import SimpleTestCase, override_settings
7
+ from rest_framework.test import APIRequestFactory
8
+
9
+ from media_engine.auth import MediaEngineApiKeyPermission
10
+ from media_engine.control_plane import CONFIG_CACHE_KEY, validate_remote_config
11
+ from media_engine.node import get_node_identity
12
+ from media_engine.runtime_config import effective_profiles
13
+
14
+
15
+ class DistributedNodeTests(SimpleTestCase):
16
+ @override_settings(
17
+ MEDIA_ENGINE_NODE_ID='node-a',
18
+ MEDIA_ENGINE_PROJECT_SLUG='project-a',
19
+ MEDIA_ENGINE_TENANT_SLUG='tenant-a',
20
+ MEDIA_ENGINE_DEPLOYMENT_ID='prod-1',
21
+ )
22
+ def test_node_identity_is_local(self):
23
+ identity = get_node_identity()
24
+ self.assertEqual(identity.node_id, 'node-a')
25
+ self.assertEqual(identity.project_slug, 'project-a')
26
+ self.assertEqual(identity.tenant_slug, 'tenant-a')
27
+
28
+ @override_settings(MEDIA_ENGINE_REQUIRE_API_KEY=True, MEDIA_ENGINE_API_KEYS='secret-a,secret-b')
29
+ def test_api_key_permission(self):
30
+ factory = APIRequestFactory()
31
+ permission = MediaEngineApiKeyPermission()
32
+ good = factory.get('/api/v1/images/', HTTP_AUTHORIZATION='Bearer secret-b')
33
+ bad = factory.get('/api/v1/images/', HTTP_AUTHORIZATION='Bearer wrong')
34
+ self.assertTrue(permission.has_permission(good, None))
35
+ self.assertFalse(permission.has_permission(bad, None))
36
+
37
+ def test_control_plane_rejects_secret_settings(self):
38
+ validated = validate_remote_config({
39
+ 'version': 2,
40
+ 'profiles': {'hero': {'quality': {'avif': 55}}},
41
+ 'feature_flags': {'auto_focal': True, 'shell_command': True},
42
+ 'AWS_SECRET_ACCESS_KEY': 'must-not-pass',
43
+ 'REDIS_URL': 'must-not-pass',
44
+ })
45
+ self.assertNotIn('AWS_SECRET_ACCESS_KEY', validated)
46
+ self.assertNotIn('REDIS_URL', validated)
47
+ self.assertNotIn('shell_command', validated['feature_flags'])
48
+ self.assertTrue(validated['feature_flags']['auto_focal'])
49
+
50
+ def test_last_known_good_profile_override_is_local_file(self):
51
+ with tempfile.TemporaryDirectory() as tmp:
52
+ Path(tmp, 'current.json').write_text(json.dumps({
53
+ 'version': 3,
54
+ 'profiles': {'hero': {'quality': {'avif': 41}}},
55
+ 'feature_flags': {},
56
+ }))
57
+ cache.delete(CONFIG_CACHE_KEY)
58
+ with override_settings(
59
+ MEDIA_ENGINE_CONFIG_DIR=tmp,
60
+ MEDIA_ENGINE_PROFILES={
61
+ 'hero': {
62
+ 'widths': [640, 1280],
63
+ 'formats': ['avif', 'webp'],
64
+ 'quality': {'avif': 58, 'webp': 78},
65
+ }
66
+ },
67
+ ):
68
+ profiles = effective_profiles()
69
+ self.assertEqual(profiles['hero']['quality']['avif'], 41)
70
+ self.assertEqual(profiles['hero']['quality']['webp'], 78)
71
+ self.assertEqual(profiles['hero']['widths'], [640, 1280])
72
+ cache.delete(CONFIG_CACHE_KEY)
@@ -0,0 +1,14 @@
1
+ from django.test import SimpleTestCase
2
+ from PIL import Image
3
+ from media_engine.image_ops import resized, dominant_color
4
+
5
+
6
+ class ImageOpsTests(SimpleTestCase):
7
+ def test_resize_preserves_aspect_ratio(self):
8
+ image = Image.new('RGB', (1000, 500), 'red')
9
+ out = resized(image, 500)
10
+ self.assertEqual(out.size, (500, 250))
11
+
12
+ def test_dominant_color(self):
13
+ image = Image.new('RGB', (10, 10), (255, 0, 0))
14
+ self.assertEqual(dominant_color(image), '#ff0000')
@@ -0,0 +1,31 @@
1
+ from django.test import SimpleTestCase
2
+ from PIL import Image
3
+
4
+ from media_engine.processors.panorama.detect import detect_equirectangular
5
+ from media_engine.processors.panorama.tiles import pyramid_widths, build_plan
6
+ from media_engine.processors.panorama.cubemap import equirectangular_to_cubemap
7
+
8
+
9
+ class PanoramaMathTests(SimpleTestCase):
10
+ def test_detects_typical_equirectangular_ratio(self):
11
+ result = detect_equirectangular(8192, 4096)
12
+ self.assertTrue(result.is_panorama)
13
+ self.assertEqual(result.projection, 'equirectangular')
14
+
15
+ def test_rejects_normal_landscape(self):
16
+ result = detect_equirectangular(1600, 900)
17
+ self.assertFalse(result.is_panorama)
18
+
19
+ def test_pyramid_widths(self):
20
+ self.assertEqual(pyramid_widths(8192, 1024), [1024, 2048, 4096, 8192])
21
+
22
+ def test_tile_plan(self):
23
+ plan = build_plan(2048, 512, 1)
24
+ self.assertEqual((plan.cols, plan.rows), (4, 2))
25
+ self.assertEqual((plan.width, plan.height), (2048, 1024))
26
+
27
+ def test_cubemap_has_six_faces(self):
28
+ image = Image.new('RGB', (512, 256), (120, 80, 40))
29
+ faces = equirectangular_to_cubemap(image, 64)
30
+ self.assertEqual(set(faces), {'front', 'back', 'left', 'right', 'top', 'bottom'})
31
+ self.assertTrue(all(face.size == (64, 64) for face in faces.values()))
@@ -0,0 +1,9 @@
1
+ from django.test import SimpleTestCase
2
+ from media_engine.storage_paths import original_storage_name, derivative_storage_name
3
+
4
+
5
+ class StoragePathTests(SimpleTestCase):
6
+ def test_paths_are_content_addressed(self):
7
+ sha = 'ab' * 32
8
+ self.assertIn('/ab/ab/', original_storage_name(sha, 'jpg'))
9
+ self.assertIn('/v1/hero/640.avif', derivative_storage_name(sha, 1, 'hero', 640, 'avif'))
@@ -0,0 +1,8 @@
1
+ from django.test import SimpleTestCase, override_settings
2
+ from media_engine.profiles import requested_widths
3
+
4
+
5
+ class ProfileTests(SimpleTestCase):
6
+ def test_does_not_upscale(self):
7
+ profile = {'widths': [320, 640, 1280]}
8
+ self.assertEqual(requested_widths(profile, 800), [320, 640])