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,7 @@
1
+ __version__ = '1.4.0rc3'
2
+
3
+ def register_model_image(*args, **kwargs):
4
+ from .registry import register_model_image as _register
5
+ return _register(*args, **kwargs)
6
+
7
+ default_app_config = 'media_engine.apps.MediaEngineConfig'
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,2 @@
1
+ from .manifest import to_marzipano
2
+ __all__ = ['to_marzipano']
@@ -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,2 @@
1
+ from .manifest import to_pannellum
2
+ __all__ = ['to_pannellum']
@@ -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,2 @@
1
+ from .manifest import to_threejs
2
+ __all__ = ['to_threejs']
@@ -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