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/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Flutter panorama adapter
|
|
2
|
+
|
|
3
|
+
`models.dart` parses the generic MOE panorama manifest. A Flutter viewer can select the lowest level that satisfies current viewport width and devicePixelRatio, then request only visible tiles.
|
|
4
|
+
|
|
5
|
+
Recommended policy:
|
|
6
|
+
- show `preview` immediately;
|
|
7
|
+
- select a level from viewport width x DPR;
|
|
8
|
+
- request only tiles intersecting the current field of view;
|
|
9
|
+
- keep adjacent tiles warm for smooth panning;
|
|
10
|
+
- use AVIF where the Flutter image stack supports it, otherwise WebP.
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
class PanoramaTile {
|
|
2
|
+
final int level;
|
|
3
|
+
final int x;
|
|
4
|
+
final int y;
|
|
5
|
+
final int width;
|
|
6
|
+
final int height;
|
|
7
|
+
final String url;
|
|
8
|
+
|
|
9
|
+
const PanoramaTile({
|
|
10
|
+
required this.level,
|
|
11
|
+
required this.x,
|
|
12
|
+
required this.y,
|
|
13
|
+
required this.width,
|
|
14
|
+
required this.height,
|
|
15
|
+
required this.url,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
factory PanoramaTile.fromJson(int level, Map<String, dynamic> json) => PanoramaTile(
|
|
19
|
+
level: level,
|
|
20
|
+
x: json['x'] as int,
|
|
21
|
+
y: json['y'] as int,
|
|
22
|
+
width: json['width'] as int,
|
|
23
|
+
height: json['height'] as int,
|
|
24
|
+
url: json['url'] as String,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class PanoramaLevel {
|
|
29
|
+
final int level;
|
|
30
|
+
final int width;
|
|
31
|
+
final int height;
|
|
32
|
+
final int cols;
|
|
33
|
+
final int rows;
|
|
34
|
+
final int tileSize;
|
|
35
|
+
final Map<String, List<PanoramaTile>> formats;
|
|
36
|
+
|
|
37
|
+
const PanoramaLevel({
|
|
38
|
+
required this.level,
|
|
39
|
+
required this.width,
|
|
40
|
+
required this.height,
|
|
41
|
+
required this.cols,
|
|
42
|
+
required this.rows,
|
|
43
|
+
required this.tileSize,
|
|
44
|
+
required this.formats,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
factory PanoramaLevel.fromJson(Map<String, dynamic> json) {
|
|
48
|
+
final level = json['level'] as int;
|
|
49
|
+
final rawFormats = Map<String, dynamic>.from(json['formats'] as Map);
|
|
50
|
+
return PanoramaLevel(
|
|
51
|
+
level: level,
|
|
52
|
+
width: json['width'] as int,
|
|
53
|
+
height: json['height'] as int,
|
|
54
|
+
cols: json['cols'] as int,
|
|
55
|
+
rows: json['rows'] as int,
|
|
56
|
+
tileSize: json['tile_size'] as int,
|
|
57
|
+
formats: rawFormats.map((key, value) => MapEntry(
|
|
58
|
+
key,
|
|
59
|
+
(value as List)
|
|
60
|
+
.map((e) => PanoramaTile.fromJson(level, Map<String, dynamic>.from(e as Map)))
|
|
61
|
+
.toList(),
|
|
62
|
+
)),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class PanoramaManifest {
|
|
68
|
+
final String assetId;
|
|
69
|
+
final String projection;
|
|
70
|
+
final String preview;
|
|
71
|
+
final List<PanoramaLevel> levels;
|
|
72
|
+
|
|
73
|
+
const PanoramaManifest({
|
|
74
|
+
required this.assetId,
|
|
75
|
+
required this.projection,
|
|
76
|
+
required this.preview,
|
|
77
|
+
required this.levels,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
factory PanoramaManifest.fromJson(Map<String, dynamic> json) => PanoramaManifest(
|
|
81
|
+
assetId: json['asset_id'] as String,
|
|
82
|
+
projection: json['projection'] as String? ?? 'equirectangular',
|
|
83
|
+
preview: json['preview'] as String? ?? '',
|
|
84
|
+
levels: (json['levels'] as List? ?? const [])
|
|
85
|
+
.map((e) => PanoramaLevel.fromJson(Map<String, dynamic>.from(e as Map)))
|
|
86
|
+
.toList(),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def to_marzipano(manifest: dict) -> dict:
|
|
5
|
+
"""Return a browser-friendly Marzipano description.
|
|
6
|
+
|
|
7
|
+
For ``panorama.marzipano`` the engine emits a true multires CubeGeometry
|
|
8
|
+
pyramid. Tiles stay explicit so this works with local storage, S3 and R2
|
|
9
|
+
without assuming a specific URL layout.
|
|
10
|
+
"""
|
|
11
|
+
layout = manifest.get('layout', '')
|
|
12
|
+
if layout != 'cube-multires':
|
|
13
|
+
# Graceful preview fallback for a generic equirectangular pipeline.
|
|
14
|
+
return {
|
|
15
|
+
'type': 'equirectangular',
|
|
16
|
+
'preview': manifest.get('preview', ''),
|
|
17
|
+
'projection': manifest.get('projection', 'equirectangular'),
|
|
18
|
+
'levels': manifest.get('levels', []),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
levels = []
|
|
22
|
+
tiles = []
|
|
23
|
+
preferred_format = 'webp'
|
|
24
|
+
for item in manifest.get('levels', []):
|
|
25
|
+
formats = item.get('formats', {})
|
|
26
|
+
selected = formats.get('webp') or formats.get('avif') or next(iter(formats.values()), [])
|
|
27
|
+
if not selected:
|
|
28
|
+
continue
|
|
29
|
+
if formats.get('webp') is None and formats.get('avif'):
|
|
30
|
+
preferred_format = 'avif'
|
|
31
|
+
levels.append({
|
|
32
|
+
'level': item['level'],
|
|
33
|
+
'size': item.get('size') or item.get('width'),
|
|
34
|
+
'tileSize': item.get('tile_size', 512),
|
|
35
|
+
'fallbackOnly': bool(item.get('fallback_only', False)),
|
|
36
|
+
})
|
|
37
|
+
for tile in selected:
|
|
38
|
+
tiles.append({
|
|
39
|
+
'z': item['level'],
|
|
40
|
+
'face': tile.get('face', ''),
|
|
41
|
+
'x': tile['x'],
|
|
42
|
+
'y': tile['y'],
|
|
43
|
+
'url': tile['url'],
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
'type': 'cube-multires',
|
|
48
|
+
'preview': manifest.get('preview', ''),
|
|
49
|
+
'projection': manifest.get('projection', 'equirectangular'),
|
|
50
|
+
'format': preferred_format,
|
|
51
|
+
'geometry': {'type': 'cube', 'levels': levels},
|
|
52
|
+
'tiles': tiles,
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def to_pannellum(manifest: dict) -> dict:
|
|
5
|
+
levels = manifest.get('levels', [])
|
|
6
|
+
if not levels:
|
|
7
|
+
return {'type': 'equirectangular', 'panorama': manifest.get('preview', '')}
|
|
8
|
+
max_level = max(level['level'] for level in levels)
|
|
9
|
+
tile_size = levels[0].get('tile_size', 512)
|
|
10
|
+
base = {
|
|
11
|
+
'type': 'multires',
|
|
12
|
+
'multiRes': {
|
|
13
|
+
'basePath': '',
|
|
14
|
+
'path': '{z}/{x}_{y}.webp',
|
|
15
|
+
'fallbackPath': '{z}/{x}_{y}.webp',
|
|
16
|
+
'extension': 'webp',
|
|
17
|
+
'tileResolution': tile_size,
|
|
18
|
+
'maxLevel': max_level,
|
|
19
|
+
'cubeResolution': levels[-1].get('width', 0),
|
|
20
|
+
},
|
|
21
|
+
'preview': manifest.get('preview', ''),
|
|
22
|
+
}
|
|
23
|
+
return base
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def to_threejs(manifest: dict) -> dict:
|
|
5
|
+
return {
|
|
6
|
+
'type': 'equirectangular-multires',
|
|
7
|
+
'preview': manifest.get('preview', ''),
|
|
8
|
+
'levels': manifest.get('levels', []),
|
|
9
|
+
'projection': manifest.get('projection', 'equirectangular'),
|
|
10
|
+
}
|
media_engine/admin.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from .models import MediaAsset, MediaVariant, MediaBinding, PanoramaTile
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MediaVariantInline(admin.TabularInline):
|
|
6
|
+
model = MediaVariant
|
|
7
|
+
extra = 0
|
|
8
|
+
readonly_fields = ('profile', 'width', 'height', 'format', 'file_size', 'quality', 'status', 'processor_version')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@admin.register(MediaAsset)
|
|
12
|
+
class MediaAssetAdmin(admin.ModelAdmin):
|
|
13
|
+
list_display = ('id', 'original_name', 'status', 'width', 'height', 'original_size', 'processor_version', 'created_at')
|
|
14
|
+
list_filter = ('status', 'processor_version', 'created_at')
|
|
15
|
+
search_fields = ('original_name', 'original_sha256', 'owner_ref')
|
|
16
|
+
readonly_fields = ('original_sha256', 'width', 'height', 'original_size', 'dominant_color', 'blurhash', 'placeholder_data_url')
|
|
17
|
+
inlines = [MediaVariantInline]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@admin.register(MediaVariant)
|
|
21
|
+
class MediaVariantAdmin(admin.ModelAdmin):
|
|
22
|
+
list_display = ('asset', 'profile', 'width', 'height', 'format', 'status', 'file_size', 'processor_version')
|
|
23
|
+
list_filter = ('profile', 'format', 'status', 'processor_version')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@admin.register(MediaBinding)
|
|
27
|
+
class MediaBindingAdmin(admin.ModelAdmin):
|
|
28
|
+
list_display = ('content_type', 'object_id', 'field_name', 'profile', 'role', 'asset')
|
|
29
|
+
list_filter = ('profile', 'role', 'content_type')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@admin.register(PanoramaTile)
|
|
33
|
+
class PanoramaTileAdmin(admin.ModelAdmin):
|
|
34
|
+
list_display = ('asset', 'profile', 'level', 'col', 'row', 'format', 'status', 'file_size')
|
|
35
|
+
list_filter = ('profile', 'format', 'status', 'level')
|
|
36
|
+
search_fields = ('asset__original_sha256', 'asset__original_name')
|
media_engine/apps.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MediaEngineConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "media_engine"
|
|
7
|
+
|
|
8
|
+
def ready(self):
|
|
9
|
+
from . import signals # noqa: F401
|
|
10
|
+
from .autoregister import configure_auto_integration
|
|
11
|
+
configure_auto_integration()
|
media_engine/auth.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import hmac
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
from rest_framework.permissions import BasePermission
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def configured_api_keys():
|
|
7
|
+
value = getattr(settings, 'MEDIA_ENGINE_API_KEYS', '')
|
|
8
|
+
if isinstance(value, (tuple, list, set)):
|
|
9
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
10
|
+
return [item.strip() for item in str(value).split(',') if item.strip()]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def extract_api_key(request):
|
|
14
|
+
auth = request.headers.get('Authorization', '').strip()
|
|
15
|
+
if auth.lower().startswith('bearer '):
|
|
16
|
+
return auth.split(' ', 1)[1].strip()
|
|
17
|
+
return request.headers.get('X-Media-Engine-Key', '').strip()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def valid_api_key(value):
|
|
21
|
+
keys = configured_api_keys()
|
|
22
|
+
if not keys:
|
|
23
|
+
return False
|
|
24
|
+
return any(hmac.compare_digest(value or '', key) for key in keys)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MediaEngineApiKeyPermission(BasePermission):
|
|
28
|
+
message = 'A valid Media Optimization Engine API key is required.'
|
|
29
|
+
|
|
30
|
+
def has_permission(self, request, view):
|
|
31
|
+
if not getattr(settings, 'MEDIA_ENGINE_REQUIRE_API_KEY', False):
|
|
32
|
+
return True
|
|
33
|
+
return valid_api_key(extract_api_key(request))
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Portable automatic Django ImageField integration.
|
|
2
|
+
|
|
3
|
+
The package never has to know the host project's models at build time.
|
|
4
|
+
A project may explicitly list fields in ``MEDIA_ENGINE_AUTO_FIELDS`` or enable
|
|
5
|
+
opt-in ImageField discovery. Registered fields are connected to the normal MOE
|
|
6
|
+
post-save pipeline and therefore follow the host's task mode:
|
|
7
|
+
|
|
8
|
+
* local DEBUG / task mode ``auto`` -> inline (eager)
|
|
9
|
+
* production -> Celery
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
|
|
16
|
+
from django.apps import apps
|
|
17
|
+
from django.conf import settings
|
|
18
|
+
from django.db.models import ImageField
|
|
19
|
+
|
|
20
|
+
from .registry import register_model_image
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
DEFAULT_EXCLUDED_APPS = {
|
|
25
|
+
"admin",
|
|
26
|
+
"auth",
|
|
27
|
+
"contenttypes",
|
|
28
|
+
"sessions",
|
|
29
|
+
"messages",
|
|
30
|
+
"staticfiles",
|
|
31
|
+
"media_engine",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _field_profile(field_name: str) -> tuple[str, str]:
|
|
36
|
+
name = field_name.lower()
|
|
37
|
+
if "360" in name or "panorama" in name:
|
|
38
|
+
return "panorama.multires", "panorama"
|
|
39
|
+
if any(token in name for token in ("avatar", "profile", "logo", "icon")):
|
|
40
|
+
return "avatar", "content"
|
|
41
|
+
if any(token in name for token in ("hero", "cover", "banner")):
|
|
42
|
+
return "hero", "hero"
|
|
43
|
+
return "default", "content"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _register_explicit_config() -> int:
|
|
47
|
+
configured = getattr(settings, "MEDIA_ENGINE_AUTO_FIELDS", {}) or {}
|
|
48
|
+
if not isinstance(configured, Mapping):
|
|
49
|
+
raise TypeError("MEDIA_ENGINE_AUTO_FIELDS must be a mapping")
|
|
50
|
+
|
|
51
|
+
count = 0
|
|
52
|
+
for model_label, fields in configured.items():
|
|
53
|
+
model = apps.get_model(model_label)
|
|
54
|
+
if model is None:
|
|
55
|
+
logger.warning("MOE auto integration: unknown model %s", model_label)
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
if isinstance(fields, (list, tuple, set)):
|
|
59
|
+
fields = {name: {} for name in fields}
|
|
60
|
+
if not isinstance(fields, Mapping):
|
|
61
|
+
raise TypeError(f"MEDIA_ENGINE_AUTO_FIELDS[{model_label!r}] must be a mapping or list")
|
|
62
|
+
|
|
63
|
+
for field_name, options in fields.items():
|
|
64
|
+
options = options or {}
|
|
65
|
+
if not isinstance(options, Mapping):
|
|
66
|
+
raise TypeError(f"MOE options for {model_label}.{field_name} must be a mapping")
|
|
67
|
+
inferred_profile, inferred_role = _field_profile(field_name)
|
|
68
|
+
register_model_image(
|
|
69
|
+
model,
|
|
70
|
+
field_name,
|
|
71
|
+
profile=str(options.get("profile") or inferred_profile),
|
|
72
|
+
role=str(options.get("role") or inferred_role),
|
|
73
|
+
)
|
|
74
|
+
count += 1
|
|
75
|
+
return count
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _register_discovered_fields() -> int:
|
|
79
|
+
if not getattr(settings, "MEDIA_ENGINE_AUTO_DISCOVER_IMAGE_FIELDS", False):
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
excluded = set(getattr(settings, "MEDIA_ENGINE_AUTO_DISCOVER_EXCLUDE_APPS", ()) or ())
|
|
83
|
+
excluded |= DEFAULT_EXCLUDED_APPS
|
|
84
|
+
count = 0
|
|
85
|
+
|
|
86
|
+
for model in apps.get_models():
|
|
87
|
+
if model._meta.app_label in excluded:
|
|
88
|
+
continue
|
|
89
|
+
for field in model._meta.get_fields():
|
|
90
|
+
if not isinstance(field, ImageField):
|
|
91
|
+
continue
|
|
92
|
+
profile, role = _field_profile(field.name)
|
|
93
|
+
register_model_image(model, field.name, profile=profile, role=role)
|
|
94
|
+
count += 1
|
|
95
|
+
return count
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def configure_auto_integration() -> dict[str, int]:
|
|
99
|
+
"""Register configured host fields once during Django app startup."""
|
|
100
|
+
explicit = _register_explicit_config()
|
|
101
|
+
discovered = _register_discovered_fields()
|
|
102
|
+
if explicit or discovered:
|
|
103
|
+
logger.info(
|
|
104
|
+
"MOE automatic integration registered %s explicit and %s discovered ImageField(s)",
|
|
105
|
+
explicit,
|
|
106
|
+
discovered,
|
|
107
|
+
)
|
|
108
|
+
return {"explicit": explicit, "discovered": discovered}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Optional external control-plane integration.
|
|
2
|
+
|
|
3
|
+
Runtime image processing NEVER calls the control plane. This module is invoked only
|
|
4
|
+
by an explicit management command or a background Celery sync task. The engine
|
|
5
|
+
always uses local settings plus the last-known-good local snapshot.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from django.conf import settings
|
|
18
|
+
from django.core.cache import cache
|
|
19
|
+
|
|
20
|
+
from .node import get_node_identity
|
|
21
|
+
|
|
22
|
+
STATUS_CACHE_KEY = 'media-engine:control-plane:status'
|
|
23
|
+
CIRCUIT_CACHE_KEY = 'media-engine:control-plane:circuit-open-until'
|
|
24
|
+
CONFIG_CACHE_KEY = 'media-engine:control-plane:last-known-good'
|
|
25
|
+
_ALLOWED_FORMATS = {'avif', 'webp', 'jpeg', 'jpg'}
|
|
26
|
+
_ALLOWED_FITS = {'contain', 'cover'}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _config_dir() -> Path:
|
|
30
|
+
return Path(getattr(settings, 'MEDIA_ENGINE_CONFIG_DIR', '/var/lib/media-engine/config'))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _path(name: str) -> Path:
|
|
34
|
+
return _config_dir() / name
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _safe_cache_get(key, default=None):
|
|
38
|
+
try:
|
|
39
|
+
return cache.get(key, default)
|
|
40
|
+
except Exception:
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _safe_cache_set(key, value, timeout=None):
|
|
45
|
+
try:
|
|
46
|
+
cache.set(key, value, timeout=timeout)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _atomic_write_json(path: Path, payload: dict):
|
|
52
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f'.{path.name}.', dir=str(path.parent))
|
|
54
|
+
try:
|
|
55
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
|
56
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
|
57
|
+
handle.flush()
|
|
58
|
+
os.fsync(handle.fileno())
|
|
59
|
+
os.replace(tmp_name, path)
|
|
60
|
+
finally:
|
|
61
|
+
if os.path.exists(tmp_name):
|
|
62
|
+
os.unlink(tmp_name)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_json(path: Path, default=None):
|
|
66
|
+
try:
|
|
67
|
+
with path.open('r', encoding='utf-8') as handle:
|
|
68
|
+
value = json.load(handle)
|
|
69
|
+
return value if isinstance(value, dict) else (default or {})
|
|
70
|
+
except (FileNotFoundError, OSError, ValueError, TypeError):
|
|
71
|
+
return default or {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _validate_profile(name: str, value: dict) -> dict:
|
|
75
|
+
if not isinstance(name, str) or not name or not isinstance(value, dict):
|
|
76
|
+
raise ValueError('Invalid profile payload.')
|
|
77
|
+
result = {}
|
|
78
|
+
if 'widths' in value:
|
|
79
|
+
widths = sorted({int(w) for w in value['widths'] if 16 <= int(w) <= 8192})
|
|
80
|
+
if not widths:
|
|
81
|
+
raise ValueError(f'Profile {name}: widths cannot be empty.')
|
|
82
|
+
result['widths'] = widths
|
|
83
|
+
if 'formats' in value:
|
|
84
|
+
formats = [str(fmt).lower() for fmt in value['formats']]
|
|
85
|
+
if not formats or any(fmt not in _ALLOWED_FORMATS for fmt in formats):
|
|
86
|
+
raise ValueError(f'Profile {name}: invalid formats.')
|
|
87
|
+
result['formats'] = formats
|
|
88
|
+
if 'fallback_format' in value:
|
|
89
|
+
fallback = str(value['fallback_format']).lower()
|
|
90
|
+
if fallback not in _ALLOWED_FORMATS:
|
|
91
|
+
raise ValueError(f'Profile {name}: invalid fallback format.')
|
|
92
|
+
result['fallback_format'] = fallback
|
|
93
|
+
if 'fit' in value:
|
|
94
|
+
fit = str(value['fit']).lower()
|
|
95
|
+
if fit not in _ALLOWED_FITS:
|
|
96
|
+
raise ValueError(f'Profile {name}: invalid fit.')
|
|
97
|
+
result['fit'] = fit
|
|
98
|
+
if 'aspect_ratio' in value:
|
|
99
|
+
ratio = value['aspect_ratio']
|
|
100
|
+
if not isinstance(ratio, (list, tuple)) or len(ratio) != 2:
|
|
101
|
+
raise ValueError(f'Profile {name}: invalid aspect_ratio.')
|
|
102
|
+
result['aspect_ratio'] = [max(1, int(ratio[0])), max(1, int(ratio[1]))]
|
|
103
|
+
for mapping_name in ('quality', 'target_bytes'):
|
|
104
|
+
if mapping_name in value:
|
|
105
|
+
mapping = value[mapping_name]
|
|
106
|
+
if not isinstance(mapping, dict):
|
|
107
|
+
raise ValueError(f'Profile {name}: {mapping_name} must be an object.')
|
|
108
|
+
cleaned = {}
|
|
109
|
+
for key, item in mapping.items():
|
|
110
|
+
cleaned[str(key)] = int(item)
|
|
111
|
+
result[mapping_name] = cleaned
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def validate_remote_config(payload: dict) -> dict:
|
|
116
|
+
if not isinstance(payload, dict):
|
|
117
|
+
raise ValueError('Control-plane payload must be an object.')
|
|
118
|
+
version = int(payload.get('version', 0))
|
|
119
|
+
if version < 1:
|
|
120
|
+
raise ValueError('Control-plane config version must be >= 1.')
|
|
121
|
+
|
|
122
|
+
result = {
|
|
123
|
+
'version': version,
|
|
124
|
+
'updated_at': payload.get('updated_at'),
|
|
125
|
+
'profiles': {},
|
|
126
|
+
'feature_flags': {},
|
|
127
|
+
}
|
|
128
|
+
profiles = payload.get('profiles', {})
|
|
129
|
+
if profiles:
|
|
130
|
+
if not isinstance(profiles, dict):
|
|
131
|
+
raise ValueError('profiles must be an object.')
|
|
132
|
+
result['profiles'] = {name: _validate_profile(name, value) for name, value in profiles.items()}
|
|
133
|
+
|
|
134
|
+
flags = payload.get('feature_flags', {})
|
|
135
|
+
if flags:
|
|
136
|
+
if not isinstance(flags, dict):
|
|
137
|
+
raise ValueError('feature_flags must be an object.')
|
|
138
|
+
allowed = {'auto_focal'}
|
|
139
|
+
result['feature_flags'] = {key: bool(value) for key, value in flags.items() if key in allowed}
|
|
140
|
+
|
|
141
|
+
# Security-critical settings such as storage credentials, API keys, DB/Redis,
|
|
142
|
+
# and hostnames are deliberately NOT accepted from the control plane.
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_last_known_good() -> dict:
|
|
147
|
+
cached = _safe_cache_get(CONFIG_CACHE_KEY)
|
|
148
|
+
if isinstance(cached, dict):
|
|
149
|
+
return cached
|
|
150
|
+
value = _read_json(_path('current.json'), default={})
|
|
151
|
+
_safe_cache_set(CONFIG_CACHE_KEY, value, timeout=60)
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_previous_config() -> dict:
|
|
156
|
+
return _read_json(_path('previous.json'), default={})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def control_plane_status() -> dict:
|
|
160
|
+
cached = _safe_cache_get(STATUS_CACHE_KEY)
|
|
161
|
+
if isinstance(cached, dict):
|
|
162
|
+
return cached
|
|
163
|
+
return _read_json(_path('status.json'), default={'state': 'unknown'})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _set_status(payload: dict):
|
|
167
|
+
payload = dict(payload)
|
|
168
|
+
payload['node_id'] = get_node_identity().node_id
|
|
169
|
+
payload['recorded_at_epoch'] = int(time.time())
|
|
170
|
+
_safe_cache_set(STATUS_CACHE_KEY, payload, timeout=None)
|
|
171
|
+
try:
|
|
172
|
+
_atomic_write_json(_path('status.json'), payload)
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _circuit_open() -> bool:
|
|
178
|
+
until = _safe_cache_get(CIRCUIT_CACHE_KEY, 0) or 0
|
|
179
|
+
if not until:
|
|
180
|
+
status = _read_json(_path('status.json'), default={})
|
|
181
|
+
until = status.get('circuit_open_until_epoch', 0) or 0
|
|
182
|
+
return float(until) > time.time()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _open_circuit(error: str):
|
|
186
|
+
seconds = int(getattr(settings, 'MEDIA_ENGINE_CONTROL_PLANE_CIRCUIT_SECONDS', 300))
|
|
187
|
+
until = int(time.time()) + max(10, seconds)
|
|
188
|
+
_safe_cache_set(CIRCUIT_CACHE_KEY, until, timeout=seconds)
|
|
189
|
+
_set_status({
|
|
190
|
+
'state': 'degraded',
|
|
191
|
+
'last_error': error[:1000],
|
|
192
|
+
'circuit_open_until_epoch': until,
|
|
193
|
+
'last_known_good_version': load_last_known_good().get('version'),
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def sync_from_control_plane(force=False) -> dict:
|
|
198
|
+
if not getattr(settings, 'MEDIA_ENGINE_CONTROL_PLANE_ENABLED', False):
|
|
199
|
+
result = {'state': 'disabled', 'changed': False}
|
|
200
|
+
_set_status(result)
|
|
201
|
+
return result
|
|
202
|
+
if _circuit_open() and not force:
|
|
203
|
+
return {'state': 'circuit_open', 'changed': False, 'using_last_known_good': True}
|
|
204
|
+
|
|
205
|
+
base_url = getattr(settings, 'MEDIA_ENGINE_CONTROL_PLANE_URL', '').rstrip('/')
|
|
206
|
+
if not base_url:
|
|
207
|
+
result = {'state': 'disabled', 'changed': False, 'reason': 'missing_url'}
|
|
208
|
+
_set_status(result)
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
identity = get_node_identity()
|
|
212
|
+
query = urllib.parse.urlencode({
|
|
213
|
+
'node_id': identity.node_id,
|
|
214
|
+
'project_slug': identity.project_slug,
|
|
215
|
+
'tenant_slug': identity.tenant_slug,
|
|
216
|
+
'deployment_id': identity.deployment_id,
|
|
217
|
+
})
|
|
218
|
+
url = f'{base_url}/api/v1/media-engine/config/?{query}'
|
|
219
|
+
headers = {
|
|
220
|
+
'Accept': 'application/json',
|
|
221
|
+
'User-Agent': f'MediaOptimizationEngine/{getattr(settings, "MEDIA_ENGINE_VERSION", "1.4.0")}',
|
|
222
|
+
}
|
|
223
|
+
token = getattr(settings, 'MEDIA_ENGINE_CONTROL_PLANE_TOKEN', '')
|
|
224
|
+
if token:
|
|
225
|
+
headers['Authorization'] = f'Bearer {token}'
|
|
226
|
+
|
|
227
|
+
request = urllib.request.Request(url, headers=headers, method='GET')
|
|
228
|
+
timeout = float(getattr(settings, 'MEDIA_ENGINE_CONTROL_PLANE_TIMEOUT_SECONDS', 3))
|
|
229
|
+
try:
|
|
230
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
231
|
+
if response.status != 200:
|
|
232
|
+
raise RuntimeError(f'HTTP {response.status}')
|
|
233
|
+
remote = json.loads(response.read().decode('utf-8'))
|
|
234
|
+
validated = validate_remote_config(remote)
|
|
235
|
+
current = load_last_known_good()
|
|
236
|
+
changed = current != validated
|
|
237
|
+
if changed:
|
|
238
|
+
if current:
|
|
239
|
+
_atomic_write_json(_path('previous.json'), current)
|
|
240
|
+
_atomic_write_json(_path('current.json'), validated)
|
|
241
|
+
_safe_cache_set(CONFIG_CACHE_KEY, validated, timeout=None)
|
|
242
|
+
result = {
|
|
243
|
+
'state': 'healthy',
|
|
244
|
+
'changed': changed,
|
|
245
|
+
'version': validated['version'],
|
|
246
|
+
'using_last_known_good': True,
|
|
247
|
+
'last_error': '',
|
|
248
|
+
'circuit_open_until_epoch': 0,
|
|
249
|
+
}
|
|
250
|
+
_safe_cache_set(CIRCUIT_CACHE_KEY, 0, timeout=1)
|
|
251
|
+
_set_status(result)
|
|
252
|
+
return result
|
|
253
|
+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError, ValueError, RuntimeError) as exc:
|
|
254
|
+
_open_circuit(str(exc))
|
|
255
|
+
return {
|
|
256
|
+
'state': 'degraded',
|
|
257
|
+
'changed': False,
|
|
258
|
+
'using_last_known_good': True,
|
|
259
|
+
'last_error': str(exc),
|
|
260
|
+
'last_known_good_version': load_last_known_good().get('version'),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def rollback_to_previous() -> dict:
|
|
265
|
+
previous = load_previous_config()
|
|
266
|
+
if not previous:
|
|
267
|
+
raise RuntimeError('No previous control-plane configuration exists.')
|
|
268
|
+
current = load_last_known_good()
|
|
269
|
+
if current:
|
|
270
|
+
_atomic_write_json(_path('rollback-source.json'), current)
|
|
271
|
+
_atomic_write_json(_path('current.json'), previous)
|
|
272
|
+
_safe_cache_set(CONFIG_CACHE_KEY, previous, timeout=None)
|
|
273
|
+
_set_status({'state': 'rolled_back', 'version': previous.get('version'), 'changed': True})
|
|
274
|
+
return previous
|