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
media_engine/registry.py
ADDED
|
@@ -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)
|
media_engine/services.py
ADDED
|
@@ -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)
|
media_engine/signals.py
ADDED
|
@@ -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}'
|