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,49 @@
1
+ from dataclasses import dataclass
2
+ from django.db import transaction
3
+ from django.db.models.signals import post_save
4
+
5
+ _REGISTRY = []
6
+ _REGISTRY_KEYS = set()
7
+
8
+
9
+ @dataclass
10
+ class RegistryEntry:
11
+ model: object
12
+ field_name: str
13
+ profile: str
14
+ role: str
15
+
16
+
17
+ def register_model_image(model, field_name, profile='default', role='content'):
18
+ key = (model._meta.label_lower, field_name)
19
+ if key in _REGISTRY_KEYS:
20
+ return next(entry for entry in _REGISTRY if (entry.model._meta.label_lower, entry.field_name) == key)
21
+
22
+ model._meta.get_field(field_name)
23
+ entry = RegistryEntry(model=model, field_name=field_name, profile=profile, role=role)
24
+ _REGISTRY.append(entry)
25
+ _REGISTRY_KEYS.add(key)
26
+
27
+ def handler(sender, instance, created=False, raw=False, update_fields=None, **kwargs):
28
+ if raw:
29
+ return
30
+ if update_fields is not None and field_name not in update_fields:
31
+ return
32
+
33
+ def _ingest():
34
+ from .integration import ingest_model_field
35
+ ingest_model_field(instance, field_name=field_name, profile=profile, role=role)
36
+
37
+ transaction.on_commit(_ingest)
38
+
39
+ post_save.connect(
40
+ handler,
41
+ sender=model,
42
+ weak=False,
43
+ dispatch_uid=f'media-engine:{model._meta.label_lower}:{field_name}',
44
+ )
45
+ return entry
46
+
47
+
48
+ def entries():
49
+ return list(_REGISTRY)
@@ -0,0 +1,37 @@
1
+ from copy import deepcopy
2
+ from django.conf import settings
3
+ from .control_plane import load_last_known_good
4
+
5
+
6
+ def _merge_profile(base: dict, override: dict) -> dict:
7
+ result = deepcopy(base)
8
+ for key, value in (override or {}).items():
9
+ if key in {'quality', 'target_bytes'} and isinstance(value, dict):
10
+ merged = dict(result.get(key, {}))
11
+ for subkey, subvalue in value.items():
12
+ if key == 'target_bytes':
13
+ try:
14
+ subkey = int(subkey)
15
+ except (TypeError, ValueError):
16
+ pass
17
+ merged[subkey] = subvalue
18
+ result[key] = merged
19
+ else:
20
+ result[key] = value
21
+ return result
22
+
23
+
24
+ def effective_profiles() -> dict:
25
+ profiles = deepcopy(getattr(settings, 'MEDIA_ENGINE_PROFILES', {}))
26
+ snapshot = load_last_known_good()
27
+ for name, override in snapshot.get('profiles', {}).items():
28
+ if name in profiles:
29
+ profiles[name] = _merge_profile(profiles[name], override)
30
+ else:
31
+ profiles[name] = deepcopy(override)
32
+ return profiles
33
+
34
+
35
+ def effective_feature_flags() -> dict:
36
+ snapshot = load_last_known_good()
37
+ return dict(snapshot.get('feature_flags', {}))
@@ -0,0 +1,29 @@
1
+ from rest_framework import serializers
2
+ from .models import MediaAsset
3
+ from .manifest import build_manifest
4
+
5
+
6
+ class MediaUploadSerializer(serializers.Serializer):
7
+ file = serializers.ImageField()
8
+ profile = serializers.CharField(required=False, default='default')
9
+ owner_ref = serializers.CharField(required=False, allow_blank=True, default='')
10
+ focal_x = serializers.FloatField(required=False, min_value=0, max_value=1)
11
+ focal_y = serializers.FloatField(required=False, min_value=0, max_value=1)
12
+ media_kind = serializers.ChoiceField(required=False, choices=['image', 'panorama'])
13
+ projection = serializers.ChoiceField(required=False, choices=['equirectangular', 'cubemap'])
14
+
15
+
16
+ class MediaAssetSerializer(serializers.ModelSerializer):
17
+ manifest = serializers.SerializerMethodField()
18
+
19
+ class Meta:
20
+ model = MediaAsset
21
+ fields = [
22
+ 'id', 'status', 'original_name', 'mime_type', 'width', 'height',
23
+ 'original_size', 'media_kind', 'projection', 'processor', 'dominant_color', 'blurhash', 'focal_x', 'focal_y',
24
+ 'processor_version', 'last_error', 'created_at', 'updated_at', 'manifest',
25
+ ]
26
+
27
+ def get_manifest(self, obj):
28
+ profile = self.context.get('profile', 'default')
29
+ return build_manifest(obj, profile=profile)
@@ -0,0 +1,187 @@
1
+ import hashlib
2
+ import io
3
+ from pathlib import Path
4
+ from django.conf import settings
5
+ from django.core.files.base import ContentFile
6
+ from django.core.files.storage import default_storage
7
+ from django.db import transaction, IntegrityError
8
+ from django.utils import timezone
9
+ from .image_ops import normalized_image, dominant_color, make_blurhash, placeholder_data_url, resized, encode_with_budget
10
+ from .metrics import IMAGES_INGESTED, ORIGINAL_BYTES, VARIANTS_GENERATED, VARIANT_FAILURES, GENERATION_SECONDS, DERIVATIVE_BYTES
11
+ from .models import MediaAsset, MediaVariant
12
+ from .profiles import get_profile, requested_widths, all_formats
13
+ from .storage_paths import safe_extension, original_storage_name, derivative_storage_name
14
+ from .validators import inspect_upload
15
+ from .locks import cache_lock
16
+ from .runtime_config import effective_feature_flags
17
+
18
+
19
+ def ingest_uploaded_file(uploaded_file, *, owner_ref='', focal_x=None, focal_y=None, profile='default', enqueue=True, media_kind='', projection=''):
20
+ raw, fmt, mime, width, height = inspect_upload(uploaded_file)
21
+ sha = hashlib.sha256(raw).hexdigest()
22
+ extension = safe_extension(fmt)
23
+ version = getattr(settings, 'MEDIA_ENGINE_PIPELINE_VERSION', 1)
24
+
25
+ existing = MediaAsset.objects.filter(original_sha256=sha).first()
26
+ if existing and getattr(settings, 'MEDIA_ENGINE_DEDUPLICATE', True):
27
+ if enqueue:
28
+ from .queueing import enqueue_asset
29
+ transaction.on_commit(lambda: enqueue_asset(existing.id, profile=profile))
30
+ return existing, False
31
+
32
+ storage_name = original_storage_name(sha, extension)
33
+ if not default_storage.exists(storage_name):
34
+ default_storage.save(storage_name, ContentFile(raw))
35
+
36
+ image = normalized_image(io.BytesIO(raw))
37
+ from .processors.panorama.detect import detect_equirectangular
38
+ detection = detect_equirectangular(width, height, getattr(image, 'info', {}) or {})
39
+ if not media_kind:
40
+ media_kind = 'panorama' if (profile.startswith('panorama.') or detection.is_panorama) else 'image'
41
+ if media_kind == 'panorama' and not projection:
42
+ projection = detection.projection or 'equirectangular'
43
+ feature_flags = effective_feature_flags()
44
+ auto_focal = feature_flags.get('auto_focal', getattr(settings, 'MEDIA_ENGINE_AUTO_FOCAL', False))
45
+ if auto_focal and focal_x is None and focal_y is None:
46
+ from .focal_detectors import detect_focal_point
47
+ auto_x, auto_y = detect_focal_point(image)
48
+ focal_x = auto_x
49
+ focal_y = auto_y
50
+ try:
51
+ asset = MediaAsset.objects.create(
52
+ original_sha256=sha,
53
+ original_file=storage_name,
54
+ original_name=Path(getattr(uploaded_file, 'name', '')).name,
55
+ mime_type=mime,
56
+ width=width,
57
+ height=height,
58
+ original_size=len(raw),
59
+ media_kind=media_kind,
60
+ projection=projection,
61
+ processor='panorama_360' if media_kind == 'panorama' else 'standard_image',
62
+ metadata_json={
63
+ 'panorama_detection_confidence': detection.confidence,
64
+ 'panorama_detection_reason': detection.reason,
65
+ },
66
+ dominant_color=dominant_color(image),
67
+ blurhash=make_blurhash(image),
68
+ placeholder_data_url=placeholder_data_url(image),
69
+ focal_x=focal_x,
70
+ focal_y=focal_y,
71
+ processor_version=version,
72
+ owner_ref=owner_ref,
73
+ )
74
+ created = True
75
+ IMAGES_INGESTED.inc()
76
+ ORIGINAL_BYTES.inc(len(raw))
77
+ except IntegrityError:
78
+ asset = MediaAsset.objects.get(original_sha256=sha)
79
+ created = False
80
+ if enqueue:
81
+ from .queueing import enqueue_asset
82
+ transaction.on_commit(lambda: enqueue_asset(asset.id, profile=profile))
83
+ return asset, created
84
+
85
+
86
+ def generate_variant(asset, *, profile_name, width, fmt, force=False):
87
+ version = getattr(settings, 'MEDIA_ENGINE_PIPELINE_VERSION', 1)
88
+ profile = get_profile(profile_name)
89
+ lock_key = f'media-engine:{asset.id}:{version}:{profile_name}:{width}:{fmt}'
90
+ with cache_lock(lock_key) as acquired:
91
+ if not acquired:
92
+ return None
93
+ existing = MediaVariant.objects.filter(
94
+ asset=asset,
95
+ profile=profile_name,
96
+ width=width,
97
+ format=fmt,
98
+ processor_version=version,
99
+ status=MediaVariant.Status.READY,
100
+ ).first()
101
+ if existing and not force:
102
+ return existing
103
+
104
+ variant, _ = MediaVariant.objects.get_or_create(
105
+ asset=asset,
106
+ profile=profile_name,
107
+ width=width,
108
+ format=fmt,
109
+ processor_version=version,
110
+ defaults={'height': 0, 'status': MediaVariant.Status.PROCESSING},
111
+ )
112
+ variant.status = MediaVariant.Status.PROCESSING
113
+ variant.last_error = ''
114
+ variant.save(update_fields=['status', 'last_error', 'updated_at'])
115
+
116
+ try:
117
+ with default_storage.open(asset.original_file.name, 'rb') as source:
118
+ image = normalized_image(source)
119
+ work = resized(
120
+ image,
121
+ width,
122
+ fit=profile.get('fit', 'contain'),
123
+ aspect_ratio=profile.get('aspect_ratio'),
124
+ focal_x=asset.focal_x,
125
+ focal_y=asset.focal_y,
126
+ )
127
+ quality = profile.get('quality', {}).get(fmt, 75)
128
+ target = profile.get('target_bytes', {}).get(width)
129
+ with GENERATION_SECONDS.labels(format=fmt, profile=profile_name).time():
130
+ encoded = encode_with_budget(work, fmt, quality, target_bytes=target)
131
+ path = derivative_storage_name(asset.original_sha256, version, profile_name, width, fmt)
132
+ if default_storage.exists(path):
133
+ default_storage.delete(path)
134
+ stored = default_storage.save(path, ContentFile(encoded.content))
135
+ variant.file = stored
136
+ variant.height = encoded.height
137
+ variant.file_size = len(encoded.content)
138
+ variant.quality = encoded.quality
139
+ variant.status = MediaVariant.Status.READY
140
+ variant.last_error = ''
141
+ variant.save()
142
+ VARIANTS_GENERATED.labels(format=fmt, profile=profile_name).inc()
143
+ DERIVATIVE_BYTES.labels(format=fmt).inc(len(encoded.content))
144
+ return variant
145
+ except Exception as exc:
146
+ variant.status = MediaVariant.Status.FAILED
147
+ variant.last_error = str(exc)
148
+ variant.save(update_fields=['status', 'last_error', 'updated_at'])
149
+ VARIANT_FAILURES.labels(format=fmt, profile=profile_name).inc()
150
+ raise
151
+
152
+
153
+ def generate_standard_variants(asset, profile_name='default', force=False):
154
+ profile = get_profile(profile_name)
155
+ expected = []
156
+ errors = []
157
+ asset.status = MediaAsset.Status.PROCESSING
158
+ asset.last_error = ''
159
+ asset.save(update_fields=['status', 'last_error', 'updated_at'])
160
+ for width in requested_widths(profile, asset.width):
161
+ for fmt in all_formats(profile):
162
+ expected.append((width, fmt))
163
+ try:
164
+ generate_variant(asset, profile_name=profile_name, width=width, fmt=fmt, force=force)
165
+ except Exception as exc:
166
+ errors.append(f'{width}.{fmt}: {exc}')
167
+
168
+ ready_count = MediaVariant.objects.filter(
169
+ asset=asset,
170
+ profile=profile_name,
171
+ processor_version=getattr(settings, 'MEDIA_ENGINE_PIPELINE_VERSION', 1),
172
+ status=MediaVariant.Status.READY,
173
+ ).count()
174
+ asset.status = MediaAsset.Status.READY if ready_count >= len(expected) and not errors else (MediaAsset.Status.PARTIAL if ready_count else MediaAsset.Status.FAILED)
175
+ asset.last_error = '\n'.join(errors[-20:])
176
+ asset.processed_at = timezone.now()
177
+ asset.processor_version = getattr(settings, 'MEDIA_ENGINE_PIPELINE_VERSION', 1)
178
+ asset.save(update_fields=['status', 'last_error', 'processed_at', 'processor_version', 'updated_at'])
179
+ from .manifest import invalidate_manifest
180
+ invalidate_manifest(asset.id)
181
+ return asset
182
+
183
+
184
+ def generate_all_variants(asset, profile_name='default', force=False):
185
+ from .processors.registry import get_processor
186
+ processor = get_processor(asset, profile_name)
187
+ return processor.process(asset, profile_name, force=force)
@@ -0,0 +1,5 @@
1
+ # Project-specific model registrations can be imported here from your integration app.
2
+ # Example:
3
+ # from myapp.models import Product
4
+ # from .registry import register_model_image
5
+ # register_model_image(Product, 'cover', profile='hero', role='hero')
@@ -0,0 +1,191 @@
1
+ openapi: 3.1.0
2
+ info:
3
+ title: Media Optimization Engineer API
4
+ version: 1.4.0
5
+ description: >-
6
+ Autonomous media optimization API for Django/Python projects and standalone nodes.
7
+ Normal image processing and delivery do not depend on an optional external control plane.
8
+ servers:
9
+ - url: /
10
+ paths:
11
+ /.well-known/media-optimization-engine.json:
12
+ get:
13
+ summary: Discover the engine and its machine-readable endpoints.
14
+ responses:
15
+ '200':
16
+ description: Discovery document.
17
+ /api/v1/system/capabilities/:
18
+ get:
19
+ summary: Node formats, profiles and supported features.
20
+ responses:
21
+ '200':
22
+ description: Capability document.
23
+ /api/v1/system/health/:
24
+ get:
25
+ summary: Local node health. Control-plane failures do not make local runtime unhealthy.
26
+ responses:
27
+ '200':
28
+ description: Healthy or locally degraded node.
29
+ '503':
30
+ description: A local core dependency is unhealthy.
31
+ /api/v1/system/version/:
32
+ get:
33
+ summary: Engine, pipeline and node version metadata.
34
+ responses:
35
+ '200':
36
+ description: Version document.
37
+ /api/v1/images/:
38
+ post:
39
+ summary: Upload an original image and enqueue responsive optimization.
40
+ security:
41
+ - bearerApiKey: []
42
+ requestBody:
43
+ required: true
44
+ content:
45
+ multipart/form-data:
46
+ schema:
47
+ type: object
48
+ required: [file]
49
+ properties:
50
+ file:
51
+ type: string
52
+ format: binary
53
+ profile:
54
+ type: string
55
+ default: default
56
+ enum: [default, avatar, card, hero, panorama.preview, panorama.multires, panorama.marzipano, panorama.cube]
57
+ owner_ref:
58
+ type: string
59
+ media_kind:
60
+ type: string
61
+ enum: [image, panorama]
62
+ projection:
63
+ type: string
64
+ enum: [equirectangular, cubemap]
65
+ focal_x:
66
+ type: number
67
+ minimum: 0
68
+ maximum: 1
69
+ focal_y:
70
+ type: number
71
+ minimum: 0
72
+ maximum: 1
73
+ responses:
74
+ '200':
75
+ description: Existing deduplicated asset.
76
+ '201':
77
+ description: New asset accepted.
78
+ '400':
79
+ description: Invalid upload.
80
+ '403':
81
+ description: Invalid/missing API key when authentication is required.
82
+ /api/v1/images/{id}/:
83
+ get:
84
+ summary: Retrieve asset metadata and manifest.
85
+ security:
86
+ - bearerApiKey: []
87
+ parameters:
88
+ - in: path
89
+ name: id
90
+ required: true
91
+ schema: {type: string, format: uuid}
92
+ - in: query
93
+ name: profile
94
+ schema: {type: string, default: default}
95
+ responses:
96
+ '200': {description: Asset metadata.}
97
+ '404': {description: Asset not found.}
98
+ patch:
99
+ summary: Update focal coordinates.
100
+ security:
101
+ - bearerApiKey: []
102
+ parameters:
103
+ - in: path
104
+ name: id
105
+ required: true
106
+ schema: {type: string, format: uuid}
107
+ requestBody:
108
+ content:
109
+ application/json:
110
+ schema:
111
+ type: object
112
+ properties:
113
+ focal_x: {type: number, minimum: 0, maximum: 1}
114
+ focal_y: {type: number, minimum: 0, maximum: 1}
115
+ responses:
116
+ '200': {description: Updated asset.}
117
+ /api/v1/images/{id}/manifest/:
118
+ get:
119
+ summary: Retrieve immutable responsive derivative manifest.
120
+ security:
121
+ - bearerApiKey: []
122
+ parameters:
123
+ - in: path
124
+ name: id
125
+ required: true
126
+ schema: {type: string, format: uuid}
127
+ - in: query
128
+ name: profile
129
+ schema: {type: string, default: default}
130
+ responses:
131
+ '200': {description: Responsive manifest.}
132
+ /api/v1/images/{id}/regenerate/:
133
+ post:
134
+ summary: Enqueue a forced regeneration for a profile.
135
+ security:
136
+ - bearerApiKey: []
137
+ parameters:
138
+ - in: path
139
+ name: id
140
+ required: true
141
+ schema: {type: string, format: uuid}
142
+ responses:
143
+ '202': {description: Regeneration queued.}
144
+ /api/v1/images/{id}/render/:
145
+ get:
146
+ summary: Fallback on-demand derivative generation and redirect.
147
+ security:
148
+ - bearerApiKey: []
149
+ parameters:
150
+ - in: path
151
+ name: id
152
+ required: true
153
+ schema: {type: string, format: uuid}
154
+ - in: query
155
+ name: profile
156
+ schema: {type: string, default: default}
157
+ - in: query
158
+ name: width
159
+ schema: {type: integer, default: 640}
160
+ - in: query
161
+ name: format
162
+ schema: {type: string, default: avif, enum: [avif, webp, jpeg]}
163
+ responses:
164
+ '302': {description: Redirect to immutable derivative.}
165
+ '202': {description: Generation already in progress.}
166
+ /api/v1/images/{id}/panorama/:
167
+ get:
168
+ summary: Get a 360 panorama multiresolution manifest
169
+ security:
170
+ - bearerApiKey: []
171
+ parameters:
172
+ - in: path
173
+ name: id
174
+ required: true
175
+ schema: {type: string, format: uuid}
176
+ - in: query
177
+ name: adapter
178
+ required: false
179
+ schema:
180
+ type: string
181
+ enum: [generic, pannellum, marzipano, threejs]
182
+ responses:
183
+ '200':
184
+ description: Panorama manifest
185
+
186
+ components:
187
+ securitySchemes:
188
+ bearerApiKey:
189
+ type: http
190
+ scheme: bearer
191
+ bearerFormat: APIKey
@@ -0,0 +1,77 @@
1
+ (() => {
2
+ const root = document.documentElement;
3
+ const connection =
4
+ navigator.connection || navigator.mozConnection || navigator.webkitConnection;
5
+
6
+ if (connection) {
7
+ root.dataset.moeSaveData = connection.saveData ? '1' : '0';
8
+ root.dataset.moeEffectiveType = connection.effectiveType || '';
9
+ }
10
+
11
+ const observed = new WeakSet();
12
+
13
+ const applyRenderedSize = (picture) => {
14
+ const width = Math.ceil(picture.getBoundingClientRect().width);
15
+ if (!width) return;
16
+
17
+ const sizes = width + 'px';
18
+ picture.querySelectorAll('source[srcset]').forEach((source) => {
19
+ source.sizes = sizes;
20
+ });
21
+
22
+ const image = picture.querySelector('img[srcset]');
23
+ if (image) {
24
+ image.sizes = sizes;
25
+ }
26
+
27
+ picture.dataset.moeRenderedWidth = String(width);
28
+ picture.dataset.moeDpr = String(window.devicePixelRatio || 1);
29
+ };
30
+
31
+ const resizeObserver =
32
+ typeof ResizeObserver === 'undefined'
33
+ ? null
34
+ : new ResizeObserver((entries) => {
35
+ entries.forEach((entry) => applyRenderedSize(entry.target));
36
+ });
37
+
38
+ const register = (picture) => {
39
+ if (!picture || observed.has(picture)) return;
40
+ observed.add(picture);
41
+ applyRenderedSize(picture);
42
+ if (resizeObserver) {
43
+ resizeObserver.observe(picture);
44
+ }
45
+ };
46
+
47
+ const scan = (rootNode) => {
48
+ if (rootNode.matches && rootNode.matches('picture[data-moe-adaptive="1"]')) {
49
+ register(rootNode);
50
+ }
51
+ if (rootNode.querySelectorAll) {
52
+ rootNode
53
+ .querySelectorAll('picture[data-moe-adaptive="1"]')
54
+ .forEach(register);
55
+ }
56
+ };
57
+
58
+ const start = () => {
59
+ scan(document);
60
+
61
+ if (typeof MutationObserver !== 'undefined') {
62
+ new MutationObserver((mutations) => {
63
+ mutations.forEach((mutation) => {
64
+ mutation.addedNodes.forEach((node) => {
65
+ if (node.nodeType === 1) scan(node);
66
+ });
67
+ });
68
+ }).observe(document.documentElement, { childList: true, subtree: true });
69
+ }
70
+ };
71
+
72
+ if (document.readyState === 'loading') {
73
+ document.addEventListener('DOMContentLoaded', start, { once: true });
74
+ } else {
75
+ start();
76
+ }
77
+ })();
@@ -0,0 +1,17 @@
1
+ const CACHE = 'media-engine-immutable-v1';
2
+
3
+ self.addEventListener('fetch', (event) => {
4
+ const url = new URL(event.request.url);
5
+ if (event.request.method !== 'GET') return;
6
+ if (!url.pathname.includes('/media_engine/derivatives/')) return;
7
+
8
+ event.respondWith(
9
+ caches.open(CACHE).then(async (cache) => {
10
+ const hit = await cache.match(event.request);
11
+ if (hit) return hit;
12
+ const response = await fetch(event.request);
13
+ if (response.ok) cache.put(event.request, response.clone());
14
+ return response;
15
+ })
16
+ );
17
+ });
@@ -0,0 +1,13 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def safe_extension(fmt):
5
+ return {'JPEG': 'jpg', 'JPG': 'jpg', 'PNG': 'png', 'WEBP': 'webp', 'AVIF': 'avif'}.get(fmt.upper(), fmt.lower())
6
+
7
+
8
+ def original_storage_name(sha256_hex, extension):
9
+ return f'media_engine/originals/{sha256_hex[:2]}/{sha256_hex[2:4]}/{sha256_hex}.{extension}'
10
+
11
+
12
+ def derivative_storage_name(sha256_hex, version, profile, width, fmt):
13
+ return f'media_engine/derivatives/{sha256_hex[:2]}/{sha256_hex[2:4]}/v{version}/{profile}/{width}.{fmt}'