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,121 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+
6
+ import numpy as np
7
+ from PIL import Image
8
+ from django.core.files.base import ContentFile
9
+ from django.core.files.storage import default_storage
10
+
11
+ from ...image_ops import encode_with_budget
12
+ from ...models import PanoramaTile
13
+ from .cubemap import equirectangular_to_cubemap
14
+
15
+ # Marzipano's CubeGeometry canonical face keys.
16
+ FACE_NAME_TO_KEY = {
17
+ 'front': 'f',
18
+ 'back': 'b',
19
+ 'left': 'l',
20
+ 'right': 'r',
21
+ 'top': 'u',
22
+ 'bottom': 'd',
23
+ }
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class CubeLevelPlan:
28
+ level: int
29
+ size: int
30
+ tile_size: int
31
+
32
+ @property
33
+ def cols(self) -> int:
34
+ return math.ceil(self.size / self.tile_size)
35
+
36
+ @property
37
+ def rows(self) -> int:
38
+ return math.ceil(self.size / self.tile_size)
39
+
40
+
41
+ def _largest_power_of_two_lte(value: int, *, minimum: int = 512, maximum: int = 4096) -> int:
42
+ value = min(max(int(value), minimum), maximum)
43
+ power = 1
44
+ while power * 2 <= value:
45
+ power *= 2
46
+ return max(power, minimum)
47
+
48
+
49
+ def cube_level_sizes(original_width: int, *, min_size: int = 512, max_size: int = 4096) -> list[int]:
50
+ # A common equirectangular panorama has width ~= 4 * cubemap face width.
51
+ estimated = max(min_size, int(original_width / 4))
52
+ top = _largest_power_of_two_lte(estimated, minimum=min_size, maximum=max_size)
53
+ sizes: list[int] = []
54
+ current = min_size
55
+ while current <= top:
56
+ sizes.append(current)
57
+ current *= 2
58
+ return sizes or [min_size]
59
+
60
+
61
+ def generate_cube_level(
62
+ asset,
63
+ source_image: Image.Image,
64
+ *,
65
+ profile: str,
66
+ version: int,
67
+ plan: CubeLevelPlan,
68
+ fmt: str = 'webp',
69
+ quality: int = 72,
70
+ force: bool = False,
71
+ ) -> int:
72
+ faces = equirectangular_to_cubemap(source_image, plan.size)
73
+ generated = 0
74
+ for face_name, face_image in faces.items():
75
+ face = FACE_NAME_TO_KEY[face_name]
76
+ for row in range(plan.rows):
77
+ for col in range(plan.cols):
78
+ left = col * plan.tile_size
79
+ top = row * plan.tile_size
80
+ right = min(left + plan.tile_size, plan.size)
81
+ bottom = min(top + plan.tile_size, plan.size)
82
+ tile = face_image.crop((left, top, right, bottom))
83
+ obj, _ = PanoramaTile.objects.get_or_create(
84
+ asset=asset,
85
+ profile=profile,
86
+ processor_version=version,
87
+ level=plan.level,
88
+ face=face,
89
+ col=col,
90
+ row=row,
91
+ format=fmt,
92
+ defaults={
93
+ 'width': tile.width,
94
+ 'height': tile.height,
95
+ 'level_width': plan.size,
96
+ 'level_height': plan.size,
97
+ },
98
+ )
99
+ if obj.status == PanoramaTile.Status.READY and not force:
100
+ continue
101
+ encoded = encode_with_budget(tile, fmt, quality, target_bytes=None)
102
+ path = (
103
+ f'media_engine/panoramas/{asset.original_sha256[:2]}/'
104
+ f'{asset.original_sha256}/v{version}/{profile}/'
105
+ f'l{plan.level}/{face}/{col}_{row}.{fmt}'
106
+ )
107
+ if default_storage.exists(path):
108
+ default_storage.delete(path)
109
+ stored = default_storage.save(path, ContentFile(encoded.content))
110
+ obj.file = stored
111
+ obj.width = tile.width
112
+ obj.height = tile.height
113
+ obj.level_width = plan.size
114
+ obj.level_height = plan.size
115
+ obj.file_size = len(encoded.content)
116
+ obj.quality = encoded.quality
117
+ obj.status = PanoramaTile.Status.READY
118
+ obj.last_error = ''
119
+ obj.save()
120
+ generated += 1
121
+ return generated
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import numpy as np
5
+ from PIL import Image
6
+
7
+
8
+ _FACE_VECTORS = {
9
+ 'front': lambda a, b: (a, b, np.ones_like(a)),
10
+ 'back': lambda a, b: (-a, b, -np.ones_like(a)),
11
+ 'left': lambda a, b: (-np.ones_like(a), b, a),
12
+ 'right': lambda a, b: (np.ones_like(a), b, -a),
13
+ 'top': lambda a, b: (a, np.ones_like(a), -b),
14
+ 'bottom': lambda a, b: (a, -np.ones_like(a), b),
15
+ }
16
+
17
+
18
+ def _sample_equirectangular(array: np.ndarray, x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray:
19
+ norm = np.sqrt(x * x + y * y + z * z)
20
+ x, y, z = x / norm, y / norm, z / norm
21
+ lon = np.arctan2(x, z)
22
+ lat = np.arcsin(np.clip(y, -1.0, 1.0))
23
+ h, w = array.shape[:2]
24
+ px = ((lon / (2.0 * math.pi)) + 0.5) * (w - 1)
25
+ py = (0.5 - lat / math.pi) * (h - 1)
26
+ xi = np.mod(np.rint(px).astype(np.int64), w)
27
+ yi = np.clip(np.rint(py).astype(np.int64), 0, h - 1)
28
+ return array[yi, xi]
29
+
30
+
31
+ def equirectangular_to_cubemap(image: Image.Image, face_size: int) -> dict[str, Image.Image]:
32
+ src = np.asarray(image.convert('RGB'))
33
+ axis = np.linspace(-1.0, 1.0, face_size, dtype=np.float32)
34
+ a, b = np.meshgrid(axis, -axis)
35
+ result = {}
36
+ for name, vector_fn in _FACE_VECTORS.items():
37
+ x, y, z = vector_fn(a, b)
38
+ sampled = _sample_equirectangular(src, x, y, z)
39
+ result[name] = Image.fromarray(sampled.astype(np.uint8), mode='RGB')
40
+ return result
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class PanoramaDetection:
8
+ is_panorama: bool
9
+ projection: str
10
+ confidence: str
11
+ reason: str
12
+
13
+
14
+ def detect_equirectangular(width: int, height: int, metadata: dict | None = None) -> PanoramaDetection:
15
+ metadata = metadata or {}
16
+ projection_type = str(metadata.get('ProjectionType') or metadata.get('GPano:ProjectionType') or '').lower()
17
+ if projection_type == 'equirectangular':
18
+ return PanoramaDetection(True, 'equirectangular', 'high', 'GPano/XMP projection metadata')
19
+ if height > 0:
20
+ ratio = width / height
21
+ if width >= 2048 and 1.98 <= ratio <= 2.02:
22
+ return PanoramaDetection(True, 'equirectangular', 'medium', '2:1 dimensions typical of equirectangular panoramas')
23
+ return PanoramaDetection(False, '', 'low', 'No reliable panorama signal')
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ from django.conf import settings
4
+ from django.core.files.base import ContentFile
5
+ from django.core.files.storage import default_storage
6
+ from django.utils import timezone
7
+
8
+ from ...image_ops import normalized_image, encode_with_budget
9
+ from ...models import MediaAsset, PanoramaTile
10
+ from ..base import ProcessorResult
11
+ from .tiles import pyramid_widths, build_plan, generate_level
12
+ from .cube_tiles import cube_level_sizes, CubeLevelPlan, generate_cube_level
13
+
14
+
15
+ class Panorama360Processor:
16
+ name = 'panorama_360'
17
+
18
+ def supports(self, asset, profile_name: str) -> bool:
19
+ return getattr(asset, 'media_kind', '') == 'panorama' or profile_name.startswith('panorama.')
20
+
21
+ def _write_preview(self, asset, image, *, version: int, profile_name: str):
22
+ preview_width = min(1024, asset.width)
23
+ preview_height = max(1, preview_width // 2)
24
+ preview = image.resize((preview_width, preview_height))
25
+ encoded_preview = encode_with_budget(preview, 'webp', 58, target_bytes=140000)
26
+ preview_path = (
27
+ f'media_engine/panoramas/{asset.original_sha256[:2]}/'
28
+ f'{asset.original_sha256}/v{version}/{profile_name}/preview.webp'
29
+ )
30
+ if default_storage.exists(preview_path):
31
+ default_storage.delete(preview_path)
32
+ asset.panorama_preview = default_storage.save(preview_path, ContentFile(encoded_preview.content))
33
+
34
+ def _process_equirect_tiles(self, asset, image, *, version: int, profile_name: str, force: bool):
35
+ tile_size = getattr(settings, 'MEDIA_ENGINE_PANORAMA_TILE_SIZE', 512)
36
+ min_width = getattr(settings, 'MEDIA_ENGINE_PANORAMA_MIN_LEVEL_WIDTH', 1024)
37
+ formats = tuple(getattr(settings, 'MEDIA_ENGINE_PANORAMA_FORMATS', ('avif', 'webp')))
38
+ quality_map = getattr(settings, 'MEDIA_ENGINE_PANORAMA_QUALITY', {'avif': 52, 'webp': 72})
39
+ widths = pyramid_widths(asset.width, min_width=min_width)
40
+ for level, width in enumerate(widths):
41
+ plan = build_plan(width, tile_size, level)
42
+ for fmt in formats:
43
+ generate_level(
44
+ asset,
45
+ image,
46
+ profile=profile_name,
47
+ version=version,
48
+ plan=plan,
49
+ fmt=fmt,
50
+ quality=quality_map.get(fmt, 70),
51
+ force=force,
52
+ )
53
+ PanoramaTile.objects.filter(
54
+ asset=asset,
55
+ profile=profile_name,
56
+ processor_version=version,
57
+ level=level,
58
+ face='',
59
+ ).update(level_width=plan.width, level_height=plan.height)
60
+ return {'layout': 'equirectangular-grid', 'levels': len(widths), 'formats': list(formats)}
61
+
62
+ def _process_marzipano_cube(self, asset, image, *, version: int, profile_name: str, force: bool):
63
+ tile_size = getattr(settings, 'MEDIA_ENGINE_PANORAMA_TILE_SIZE', 512)
64
+ min_size = getattr(settings, 'MEDIA_ENGINE_PANORAMA_CUBE_MIN_SIZE', 512)
65
+ max_size = getattr(settings, 'MEDIA_ENGINE_PANORAMA_CUBE_MAX_SIZE', 4096)
66
+ fmt = getattr(settings, 'MEDIA_ENGINE_PANORAMA_CUBE_FORMAT', 'webp').lower()
67
+ quality = int(getattr(settings, 'MEDIA_ENGINE_PANORAMA_CUBE_QUALITY', 72))
68
+ sizes = cube_level_sizes(asset.width, min_size=min_size, max_size=max_size)
69
+ for level, size in enumerate(sizes):
70
+ generate_cube_level(
71
+ asset,
72
+ image,
73
+ profile=profile_name,
74
+ version=version,
75
+ plan=CubeLevelPlan(level=level, size=size, tile_size=tile_size),
76
+ fmt=fmt,
77
+ quality=quality,
78
+ force=force,
79
+ )
80
+ return {
81
+ 'layout': 'cube-multires',
82
+ 'levels': len(sizes),
83
+ 'face_sizes': sizes,
84
+ 'format': fmt,
85
+ 'tile_size': tile_size,
86
+ }
87
+
88
+ def process(self, asset, profile_name: str, force: bool = False) -> ProcessorResult:
89
+ version = getattr(settings, 'MEDIA_ENGINE_PIPELINE_VERSION', 1)
90
+ asset.status = MediaAsset.Status.PROCESSING
91
+ asset.last_error = ''
92
+ asset.save(update_fields=['status', 'last_error', 'updated_at'])
93
+ try:
94
+ with default_storage.open(asset.original_file.name, 'rb') as source:
95
+ image = normalized_image(source).convert('RGB')
96
+ self._write_preview(asset, image, version=version, profile_name=profile_name)
97
+
98
+ PanoramaTile.objects.filter(
99
+ asset=asset,
100
+ profile=profile_name,
101
+ processor_version=version,
102
+ ).exclude(status=PanoramaTile.Status.READY).delete()
103
+
104
+ if profile_name in {'panorama.marzipano', 'panorama.cube'}:
105
+ pipeline = self._process_marzipano_cube(
106
+ asset, image, version=version, profile_name=profile_name, force=force
107
+ )
108
+ else:
109
+ pipeline = self._process_equirect_tiles(
110
+ asset, image, version=version, profile_name=profile_name, force=force
111
+ )
112
+
113
+ metadata = dict(asset.metadata_json or {})
114
+ metadata['panorama_pipeline'] = pipeline
115
+ if getattr(settings, 'MEDIA_ENGINE_PANORAMA_GENERATE_CUBEMAP', False) and profile_name not in {'panorama.marzipano', 'panorama.cube'}:
116
+ from .cubemap import equirectangular_to_cubemap
117
+ face_size = min(
118
+ getattr(settings, 'MEDIA_ENGINE_PANORAMA_CUBEMAP_FACE_SIZE', 2048),
119
+ max(256, asset.height // 2),
120
+ )
121
+ faces = equirectangular_to_cubemap(image, face_size)
122
+ face_urls = {}
123
+ for face_name, face_image in faces.items():
124
+ encoded_face = encode_with_budget(face_image, 'webp', 76, target_bytes=None)
125
+ face_path = (
126
+ f'media_engine/panoramas/{asset.original_sha256[:2]}/'
127
+ f'{asset.original_sha256}/v{version}/{profile_name}/cube/{face_name}.webp'
128
+ )
129
+ if default_storage.exists(face_path):
130
+ default_storage.delete(face_path)
131
+ stored_face = default_storage.save(face_path, ContentFile(encoded_face.content))
132
+ face_urls[face_name] = default_storage.url(stored_face)
133
+ metadata['cubemap'] = {'face_size': face_size, 'format': 'webp', 'faces': face_urls}
134
+
135
+ asset.metadata_json = metadata
136
+ asset.media_kind = 'panorama'
137
+ asset.projection = asset.projection or 'equirectangular'
138
+ asset.processor = self.name
139
+ asset.processor_version = version
140
+ asset.status = MediaAsset.Status.READY
141
+ asset.processed_at = timezone.now()
142
+ asset.last_error = ''
143
+ asset.save()
144
+ except Exception as exc:
145
+ asset.status = MediaAsset.Status.FAILED
146
+ asset.last_error = str(exc)
147
+ asset.save(update_fields=['status', 'last_error', 'updated_at'])
148
+ raise
149
+ return ProcessorResult(str(asset.pk), self.name, asset.status)
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from django.conf import settings
5
+
6
+
7
+ def panorama_manifest(asset, profile: str = 'panorama.multires') -> dict:
8
+ qs = asset.panorama_tiles.filter(profile=profile, status='READY').order_by('level', 'face', 'row', 'col')
9
+ is_cube = qs.exclude(face='').exists()
10
+
11
+ if is_cube:
12
+ groups = defaultdict(lambda: {
13
+ 'formats': defaultdict(list),
14
+ 'size': 0,
15
+ 'cols': 0,
16
+ 'rows': 0,
17
+ 'faces': set(),
18
+ })
19
+ for tile in qs:
20
+ level = groups[tile.level]
21
+ level['size'] = max(level['size'], tile.level_width, tile.level_height)
22
+ level['cols'] = max(level['cols'], tile.col + 1)
23
+ level['rows'] = max(level['rows'], tile.row + 1)
24
+ level['faces'].add(tile.face)
25
+ level['formats'][tile.format].append({
26
+ 'face': tile.face,
27
+ 'x': tile.col,
28
+ 'y': tile.row,
29
+ 'width': tile.width,
30
+ 'height': tile.height,
31
+ 'url': tile.file.url,
32
+ 'bytes': tile.file_size,
33
+ })
34
+ levels = []
35
+ for level_id in sorted(groups):
36
+ item = groups[level_id]
37
+ levels.append({
38
+ 'level': level_id,
39
+ 'size': item['size'],
40
+ 'width': item['size'],
41
+ 'height': item['size'],
42
+ 'cols': item['cols'],
43
+ 'rows': item['rows'],
44
+ 'tile_size': getattr(settings, 'MEDIA_ENGINE_PANORAMA_TILE_SIZE', 512),
45
+ 'faces': sorted(item['faces']),
46
+ 'formats': dict(item['formats']),
47
+ })
48
+ return {
49
+ 'asset_id': str(asset.pk),
50
+ 'media_kind': 'panorama',
51
+ 'projection': asset.projection or 'equirectangular',
52
+ 'layout': 'cube-multires',
53
+ 'width': asset.width,
54
+ 'height': asset.height,
55
+ 'profile': profile,
56
+ 'levels': levels,
57
+ 'preview': asset.panorama_preview.url if asset.panorama_preview else '',
58
+ 'dominant_color': asset.dominant_color,
59
+ 'blurhash': asset.blurhash,
60
+ 'processor_version': asset.processor_version,
61
+ 'cubemap': (asset.metadata_json or {}).get('cubemap'),
62
+ }
63
+
64
+ groups = defaultdict(lambda: {'formats': defaultdict(list), 'width': 0, 'height': 0, 'cols': 0, 'rows': 0})
65
+ for tile in qs:
66
+ level = groups[tile.level]
67
+ level['width'] = max(level['width'], tile.level_width)
68
+ level['height'] = max(level['height'], tile.level_height)
69
+ level['cols'] = max(level['cols'], tile.col + 1)
70
+ level['rows'] = max(level['rows'], tile.row + 1)
71
+ level['formats'][tile.format].append({
72
+ 'x': tile.col,
73
+ 'y': tile.row,
74
+ 'width': tile.width,
75
+ 'height': tile.height,
76
+ 'url': tile.file.url,
77
+ 'bytes': tile.file_size,
78
+ })
79
+ levels = []
80
+ for level_id in sorted(groups):
81
+ item = groups[level_id]
82
+ levels.append({
83
+ 'level': level_id,
84
+ 'width': item['width'],
85
+ 'height': item['height'],
86
+ 'cols': item['cols'],
87
+ 'rows': item['rows'],
88
+ 'tile_size': getattr(settings, 'MEDIA_ENGINE_PANORAMA_TILE_SIZE', 512),
89
+ 'formats': dict(item['formats']),
90
+ })
91
+ return {
92
+ 'asset_id': str(asset.pk),
93
+ 'media_kind': 'panorama',
94
+ 'projection': asset.projection or 'equirectangular',
95
+ 'layout': 'equirectangular-grid',
96
+ 'width': asset.width,
97
+ 'height': asset.height,
98
+ 'profile': profile,
99
+ 'levels': levels,
100
+ 'preview': asset.panorama_preview.url if asset.panorama_preview else '',
101
+ 'dominant_color': asset.dominant_color,
102
+ 'blurhash': asset.blurhash,
103
+ 'processor_version': asset.processor_version,
104
+ 'cubemap': (asset.metadata_json or {}).get('cubemap'),
105
+ }
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import math
5
+ from dataclasses import dataclass
6
+
7
+ from PIL import Image
8
+ from django.core.files.base import ContentFile
9
+ from django.core.files.storage import default_storage
10
+
11
+ from ...image_ops import encode_with_budget
12
+ from ...models import PanoramaTile
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class TilePlan:
17
+ level: int
18
+ width: int
19
+ height: int
20
+ cols: int
21
+ rows: int
22
+ tile_size: int
23
+
24
+
25
+ def pyramid_widths(original_width: int, min_width: int = 1024) -> list[int]:
26
+ values = []
27
+ width = max(1, original_width)
28
+ while width > min_width:
29
+ values.append(width)
30
+ width = width // 2
31
+ values.append(min(width, min_width) if original_width >= min_width else original_width)
32
+ return sorted(set(v for v in values if v > 0))
33
+
34
+
35
+ def build_plan(width: int, tile_size: int, level: int) -> TilePlan:
36
+ height = max(1, width // 2)
37
+ return TilePlan(
38
+ level=level,
39
+ width=width,
40
+ height=height,
41
+ cols=math.ceil(width / tile_size),
42
+ rows=math.ceil(height / tile_size),
43
+ tile_size=tile_size,
44
+ )
45
+
46
+
47
+ def generate_level(asset, image: Image.Image, *, profile: str, version: int, plan: TilePlan, fmt: str, quality: int, force: bool = False):
48
+ work = image.resize((plan.width, plan.height), Image.Resampling.LANCZOS)
49
+ generated = 0
50
+ for row in range(plan.rows):
51
+ for col in range(plan.cols):
52
+ left = col * plan.tile_size
53
+ top = row * plan.tile_size
54
+ right = min(left + plan.tile_size, plan.width)
55
+ bottom = min(top + plan.tile_size, plan.height)
56
+ tile = work.crop((left, top, right, bottom))
57
+ obj, _ = PanoramaTile.objects.get_or_create(
58
+ asset=asset,
59
+ profile=profile,
60
+ processor_version=version,
61
+ level=plan.level,
62
+ face='',
63
+ col=col,
64
+ row=row,
65
+ format=fmt,
66
+ defaults={'width': tile.width, 'height': tile.height},
67
+ )
68
+ if obj.status == PanoramaTile.Status.READY and not force:
69
+ continue
70
+ encoded = encode_with_budget(tile, fmt, quality, target_bytes=None)
71
+ path = (
72
+ f'media_engine/panoramas/{asset.original_sha256[:2]}/'
73
+ f'{asset.original_sha256}/v{version}/{profile}/'
74
+ f'l{plan.level}/{col}_{row}.{fmt}'
75
+ )
76
+ if default_storage.exists(path):
77
+ default_storage.delete(path)
78
+ stored = default_storage.save(path, ContentFile(encoded.content))
79
+ obj.file = stored
80
+ obj.width = tile.width
81
+ obj.height = tile.height
82
+ obj.file_size = len(encoded.content)
83
+ obj.quality = encoded.quality
84
+ obj.status = PanoramaTile.Status.READY
85
+ obj.last_error = ''
86
+ obj.save()
87
+ generated += 1
88
+ return generated
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from .image.standard import StandardImageProcessor
4
+ from .panorama.equirectangular import Panorama360Processor
5
+
6
+ _PROCESSORS = [Panorama360Processor(), StandardImageProcessor()]
7
+
8
+
9
+ def get_processor(asset, profile_name: str):
10
+ for processor in _PROCESSORS:
11
+ if processor.supports(asset, profile_name):
12
+ return processor
13
+ raise RuntimeError(f'No media processor supports asset={asset.pk} profile={profile_name}')
14
+
15
+
16
+ def registered_processors():
17
+ return tuple(p.name for p in _PROCESSORS)
@@ -0,0 +1,77 @@
1
+ from django.conf import settings
2
+
3
+ DEFAULT_PROFILES = {
4
+ 'default': {
5
+ 'widths': [320, 480, 640, 960, 1280, 1600],
6
+ 'formats': ['avif', 'webp'],
7
+ 'fallback_format': 'jpeg',
8
+ 'fit': 'contain',
9
+ 'quality': {'avif': 52, 'webp': 72, 'jpeg': 80},
10
+ 'target_bytes': {},
11
+ },
12
+ 'avatar': {
13
+ 'widths': [96, 192, 384],
14
+ 'formats': ['avif', 'webp'],
15
+ 'fallback_format': 'jpeg',
16
+ 'fit': 'cover',
17
+ 'aspect_ratio': [1, 1],
18
+ 'quality': {'avif': 48, 'webp': 68, 'jpeg': 78},
19
+ 'target_bytes': {96: 25000, 192: 40000, 384: 70000},
20
+ },
21
+ 'card': {
22
+ 'widths': [320, 480, 640, 768],
23
+ 'formats': ['avif', 'webp'],
24
+ 'fallback_format': 'jpeg',
25
+ 'fit': 'cover',
26
+ 'aspect_ratio': [4, 3],
27
+ 'quality': {'avif': 50, 'webp': 70, 'jpeg': 80},
28
+ 'target_bytes': {320: 45000, 480: 70000, 640: 95000, 768: 120000},
29
+ },
30
+ 'hero': {
31
+ 'widths': [640, 960, 1280, 1600, 1920],
32
+ 'formats': ['avif', 'webp'],
33
+ 'fallback_format': 'jpeg',
34
+ 'fit': 'cover',
35
+ 'aspect_ratio': [16, 9],
36
+ 'quality': {'avif': 56, 'webp': 76, 'jpeg': 84},
37
+ 'target_bytes': {640: 120000, 960: 170000, 1280: 220000, 1600: 300000, 1920: 360000},
38
+ },
39
+ 'panorama.preview': {
40
+ 'widths': [1024],
41
+ 'formats': ['webp'],
42
+ 'fallback_format': 'jpeg',
43
+ 'fit': 'contain',
44
+ 'quality': {'webp': 58, 'jpeg': 72},
45
+ 'target_bytes': {1024: 140000},
46
+ },
47
+ 'panorama.multires': {
48
+ 'widths': [],
49
+ 'formats': ['avif', 'webp'],
50
+ 'fallback_format': 'webp',
51
+ 'fit': 'contain',
52
+ 'quality': {'avif': 52, 'webp': 72},
53
+ 'target_bytes': {},
54
+ 'tile_size': 512,
55
+ 'projection': 'equirectangular',
56
+ },
57
+ }
58
+
59
+
60
+ def get_profile(name):
61
+ profiles = DEFAULT_PROFILES.copy()
62
+ profiles.update(getattr(settings, 'MEDIA_ENGINE_PROFILES', {}) or {})
63
+ if name not in profiles:
64
+ raise KeyError(f'Unknown media profile: {name}')
65
+ return profiles[name]
66
+
67
+
68
+ def requested_widths(profile, original_width):
69
+ return [w for w in profile.get('widths', []) if w <= original_width] or [min(original_width, max(profile.get('widths', [original_width])))]
70
+
71
+
72
+ def all_formats(profile):
73
+ formats = list(profile.get('formats', []))
74
+ fallback = profile.get('fallback_format')
75
+ if fallback and fallback not in formats:
76
+ formats.append(fallback)
77
+ return formats
@@ -0,0 +1,34 @@
1
+ from django.conf import settings
2
+
3
+
4
+ def resolved_task_mode():
5
+ """Return the effective execution mode without requiring Docker/Redis locally."""
6
+ configured = str(getattr(settings, 'MEDIA_ENGINE_TASK_MODE', 'auto') or 'auto').lower()
7
+ if configured in {'eager', 'inline', 'sync', 'synchronous'}:
8
+ return 'eager'
9
+ if configured in {'celery', 'async', 'asynchronous'}:
10
+ return 'celery'
11
+ if getattr(settings, 'CELERY_TASK_ALWAYS_EAGER', False):
12
+ return 'eager'
13
+ return 'eager' if bool(getattr(settings, 'DEBUG', False)) else 'celery'
14
+
15
+
16
+ def queue_for_profile(profile, backfill=False):
17
+ if backfill:
18
+ return 'image_backfill'
19
+ if profile == 'hero' or profile.startswith('panorama.'):
20
+ return 'image_critical'
21
+ return 'image_normal'
22
+
23
+
24
+ def enqueue_asset(asset_id, profile='default', force=False, backfill=False):
25
+ if resolved_task_mode() == 'eager':
26
+ from .models import MediaAsset
27
+ from .services import generate_all_variants
28
+ asset = MediaAsset.objects.get(pk=asset_id)
29
+ return generate_all_variants(asset, profile_name=profile, force=force)
30
+ from .tasks import generate_asset_variants
31
+ return generate_asset_variants.apply_async(
32
+ args=[str(asset_id), profile, force],
33
+ queue=queue_for_profile(profile, backfill=backfill),
34
+ )