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.
- media_engine/__init__.py +7 -0
- media_engine/adapters/__init__.py +0 -0
- media_engine/adapters/flutter/README.md +10 -0
- media_engine/adapters/flutter/__init__.py +0 -0
- media_engine/adapters/flutter/models.dart +88 -0
- media_engine/adapters/marzipano/__init__.py +2 -0
- media_engine/adapters/marzipano/manifest.py +53 -0
- media_engine/adapters/pannellum/__init__.py +2 -0
- media_engine/adapters/pannellum/manifest.py +23 -0
- media_engine/adapters/threejs/__init__.py +2 -0
- media_engine/adapters/threejs/manifest.py +10 -0
- media_engine/admin.py +36 -0
- media_engine/apps.py +11 -0
- media_engine/auth.py +33 -0
- media_engine/autoregister.py +108 -0
- media_engine/control_plane.py +274 -0
- media_engine/doctor.py +177 -0
- media_engine/focal_detectors.py +28 -0
- media_engine/health.py +78 -0
- media_engine/image_ops.py +142 -0
- media_engine/integration.py +34 -0
- media_engine/locks.py +20 -0
- media_engine/management/__init__.py +0 -0
- media_engine/management/commands/__init__.py +0 -0
- media_engine/management/commands/audit_media_engine.py +54 -0
- media_engine/management/commands/audit_panorama_media.py +20 -0
- media_engine/management/commands/backfill_panorama_media.py +32 -0
- media_engine/management/commands/backfill_registered_media.py +21 -0
- media_engine/management/commands/backfill_responsive_images.py +27 -0
- media_engine/management/commands/cleanup_image_derivatives.py +30 -0
- media_engine/management/commands/media_engine_doctor.py +15 -0
- media_engine/management/commands/media_engine_node_report.py +24 -0
- media_engine/management/commands/rollback_media_engine_config.py +14 -0
- media_engine/management/commands/sync_media_engine_hub.py +20 -0
- media_engine/manifest.py +89 -0
- media_engine/metrics.py +34 -0
- media_engine/migrations/0001_initial.py +87 -0
- media_engine/migrations/0002_panorama_multires.py +44 -0
- media_engine/migrations/0003_panorama_cube_tiles.py +31 -0
- media_engine/migrations/__init__.py +0 -0
- media_engine/models.py +145 -0
- media_engine/node.py +29 -0
- media_engine/processors/__init__.py +0 -0
- media_engine/processors/base.py +21 -0
- media_engine/processors/image/__init__.py +0 -0
- media_engine/processors/image/standard.py +16 -0
- media_engine/processors/panorama/__init__.py +5 -0
- media_engine/processors/panorama/cube_tiles.py +121 -0
- media_engine/processors/panorama/cubemap.py +40 -0
- media_engine/processors/panorama/detect.py +23 -0
- media_engine/processors/panorama/equirectangular.py +149 -0
- media_engine/processors/panorama/manifest.py +105 -0
- media_engine/processors/panorama/tiles.py +88 -0
- media_engine/processors/registry.py +17 -0
- media_engine/profiles.py +77 -0
- media_engine/queueing.py +34 -0
- media_engine/registry.py +49 -0
- media_engine/runtime_config.py +37 -0
- media_engine/serializers.py +29 -0
- media_engine/services.py +187 -0
- media_engine/signals.py +5 -0
- media_engine/spec/openapi.yaml +191 -0
- media_engine/static/media_engine/adaptive-media.js +77 -0
- media_engine/static/media_engine/media-cache-sw.js +17 -0
- media_engine/storage_paths.py +13 -0
- media_engine/system_views.py +102 -0
- media_engine/tasks.py +44 -0
- media_engine/templatetags/__init__.py +0 -0
- media_engine/templatetags/responsive_media.py +262 -0
- media_engine/tests/__init__.py +0 -0
- media_engine/tests/test_distributed_node.py +72 -0
- media_engine/tests/test_image_ops.py +14 -0
- media_engine/tests/test_panorama.py +31 -0
- media_engine/tests/test_paths.py +9 -0
- media_engine/tests/test_profiles.py +8 -0
- media_engine/tests/test_responsive_media.py +153 -0
- media_engine/urls.py +14 -0
- media_engine/validators.py +42 -0
- media_engine/views.py +111 -0
- media_optimization_engine-1.4.0.dist-info/METADATA +253 -0
- media_optimization_engine-1.4.0.dist-info/RECORD +85 -0
- media_optimization_engine-1.4.0.dist-info/WHEEL +5 -0
- media_optimization_engine-1.4.0.dist-info/entry_points.txt +2 -0
- media_optimization_engine-1.4.0.dist-info/licenses/LICENSE +21 -0
- 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)
|
media_engine/profiles.py
ADDED
|
@@ -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
|
media_engine/queueing.py
ADDED
|
@@ -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
|
+
)
|