media-optimization-engine 1.4.0__tar.gz → 1.4.2__tar.gz

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 (95) hide show
  1. {media_optimization_engine-1.4.0/media_optimization_engine.egg-info → media_optimization_engine-1.4.2}/PKG-INFO +33 -1
  2. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/README.md +32 -0
  3. media_optimization_engine-1.4.2/media_engine/adapters/flutter/README.md +63 -0
  4. media_optimization_engine-1.4.2/media_engine/adapters/flutter/standard_images.dart +106 -0
  5. media_optimization_engine-1.4.2/media_engine/management/commands/backfill_registered_media.py +72 -0
  6. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/storage_paths.py +1 -1
  7. media_optimization_engine-1.4.2/media_engine/tests/test_backfill_registered_media.py +51 -0
  8. media_optimization_engine-1.4.2/media_engine/tests/test_validators.py +30 -0
  9. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/validators.py +7 -1
  10. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2/media_optimization_engine.egg-info}/PKG-INFO +33 -1
  11. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_optimization_engine.egg-info/SOURCES.txt +3 -0
  12. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/pyproject.toml +1 -1
  13. media_optimization_engine-1.4.0/media_engine/adapters/flutter/README.md +0 -10
  14. media_optimization_engine-1.4.0/media_engine/management/commands/backfill_registered_media.py +0 -21
  15. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/LICENSE +0 -0
  16. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/__init__.py +0 -0
  17. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/adapters/__init__.py +0 -0
  18. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/adapters/flutter/__init__.py +0 -0
  19. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/adapters/flutter/models.dart +0 -0
  20. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/adapters/marzipano/__init__.py +0 -0
  21. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/adapters/marzipano/manifest.py +0 -0
  22. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/adapters/pannellum/__init__.py +0 -0
  23. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/adapters/pannellum/manifest.py +0 -0
  24. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/adapters/threejs/__init__.py +0 -0
  25. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/adapters/threejs/manifest.py +0 -0
  26. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/admin.py +0 -0
  27. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/apps.py +0 -0
  28. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/auth.py +0 -0
  29. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/autoregister.py +0 -0
  30. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/control_plane.py +0 -0
  31. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/doctor.py +0 -0
  32. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/focal_detectors.py +0 -0
  33. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/health.py +0 -0
  34. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/image_ops.py +0 -0
  35. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/integration.py +0 -0
  36. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/locks.py +0 -0
  37. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/management/__init__.py +0 -0
  38. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/management/commands/__init__.py +0 -0
  39. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/management/commands/audit_media_engine.py +0 -0
  40. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/management/commands/audit_panorama_media.py +0 -0
  41. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/management/commands/backfill_panorama_media.py +0 -0
  42. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/management/commands/backfill_responsive_images.py +0 -0
  43. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/management/commands/cleanup_image_derivatives.py +0 -0
  44. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/management/commands/media_engine_doctor.py +0 -0
  45. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/management/commands/media_engine_node_report.py +0 -0
  46. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/management/commands/rollback_media_engine_config.py +0 -0
  47. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/management/commands/sync_media_engine_hub.py +0 -0
  48. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/manifest.py +0 -0
  49. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/metrics.py +0 -0
  50. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/migrations/0001_initial.py +0 -0
  51. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/migrations/0002_panorama_multires.py +0 -0
  52. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/migrations/0003_panorama_cube_tiles.py +0 -0
  53. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/migrations/__init__.py +0 -0
  54. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/models.py +0 -0
  55. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/node.py +0 -0
  56. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/processors/__init__.py +0 -0
  57. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/processors/base.py +0 -0
  58. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/processors/image/__init__.py +0 -0
  59. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/processors/image/standard.py +0 -0
  60. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/processors/panorama/__init__.py +0 -0
  61. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/processors/panorama/cube_tiles.py +0 -0
  62. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/processors/panorama/cubemap.py +0 -0
  63. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/processors/panorama/detect.py +0 -0
  64. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/processors/panorama/equirectangular.py +0 -0
  65. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/processors/panorama/manifest.py +0 -0
  66. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/processors/panorama/tiles.py +0 -0
  67. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/processors/registry.py +0 -0
  68. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/profiles.py +0 -0
  69. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/queueing.py +0 -0
  70. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/registry.py +0 -0
  71. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/runtime_config.py +0 -0
  72. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/serializers.py +0 -0
  73. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/services.py +0 -0
  74. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/signals.py +0 -0
  75. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/spec/openapi.yaml +0 -0
  76. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/static/media_engine/adaptive-media.js +0 -0
  77. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/static/media_engine/media-cache-sw.js +0 -0
  78. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/system_views.py +0 -0
  79. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/tasks.py +0 -0
  80. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/templatetags/__init__.py +0 -0
  81. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/templatetags/responsive_media.py +0 -0
  82. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/tests/__init__.py +0 -0
  83. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/tests/test_distributed_node.py +0 -0
  84. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/tests/test_image_ops.py +0 -0
  85. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/tests/test_panorama.py +0 -0
  86. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/tests/test_paths.py +0 -0
  87. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/tests/test_profiles.py +0 -0
  88. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/tests/test_responsive_media.py +0 -0
  89. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/urls.py +0 -0
  90. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_engine/views.py +0 -0
  91. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_optimization_engine.egg-info/dependency_links.txt +0 -0
  92. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_optimization_engine.egg-info/entry_points.txt +0 -0
  93. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_optimization_engine.egg-info/requires.txt +0 -0
  94. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/media_optimization_engine.egg-info/top_level.txt +0 -0
  95. {media_optimization_engine-1.4.0 → media_optimization_engine-1.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: media-optimization-engine
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Summary: Media Optimization Engineer for Django/Python: automatic model-field optimization, responsive picture/srcset rendering, AVIF/WebP derivatives, 360 media, autonomous nodes, and CDN-ready storage.
5
5
  Author-email: Achille Kabasele <pepexykabasele@gmail.com>
6
6
  Maintainer-email: Achille Kabasele <pepexykabasele@gmail.com>
@@ -233,6 +233,38 @@ JavaScript is not required for normal responsive images.
233
233
 
234
234
  Runtime measurement is appropriate only for highly dynamic components whose rendered width cannot be described reliably with sizes, such as resizable editors, canvas-like builders, or asynchronously mounted panels.
235
235
 
236
+ ## Docker image
237
+
238
+ The standalone service is also published as a multi-architecture GHCR image:
239
+
240
+ ~~~bash
241
+ docker pull ghcr.io/kabasele754/media-optimization-engine:1.4.1
242
+ ~~~
243
+
244
+ For Compose deployment, see `docs/DOCKER.md` and `docker-compose.ghcr.yml`.
245
+
246
+ ## Flutter / native clients
247
+
248
+ MOE also ships reference Dart adapters for native applications.
249
+
250
+ Standard Flutter clients should fetch the image manifest, measure the rendered widget width, multiply it by device pixel ratio, and select the first derivative at or above that physical width.
251
+
252
+ ~~~dart
253
+ final variant = manifest.select(
254
+ logicalWidth: constraints.maxWidth,
255
+ devicePixelRatio: MediaQuery.devicePixelRatioOf(context),
256
+ preferredFormat: 'webp',
257
+ );
258
+ ~~~
259
+
260
+ Reference adapters are packaged under:
261
+
262
+ ~~~text
263
+ media_engine/adapters/flutter/
264
+ ~~~
265
+
266
+ This keeps optimization and derivative generation on the Django/Python backend while allowing Flutter to select and cache the most appropriate immutable URL. Panorama manifests use the same principle at tile/level granularity.
267
+
236
268
  ## Backfill
237
269
 
238
270
  Historical media can be ingested with maintenance commands:
@@ -177,6 +177,38 @@ JavaScript is not required for normal responsive images.
177
177
 
178
178
  Runtime measurement is appropriate only for highly dynamic components whose rendered width cannot be described reliably with sizes, such as resizable editors, canvas-like builders, or asynchronously mounted panels.
179
179
 
180
+ ## Docker image
181
+
182
+ The standalone service is also published as a multi-architecture GHCR image:
183
+
184
+ ~~~bash
185
+ docker pull ghcr.io/kabasele754/media-optimization-engine:1.4.1
186
+ ~~~
187
+
188
+ For Compose deployment, see `docs/DOCKER.md` and `docker-compose.ghcr.yml`.
189
+
190
+ ## Flutter / native clients
191
+
192
+ MOE also ships reference Dart adapters for native applications.
193
+
194
+ Standard Flutter clients should fetch the image manifest, measure the rendered widget width, multiply it by device pixel ratio, and select the first derivative at or above that physical width.
195
+
196
+ ~~~dart
197
+ final variant = manifest.select(
198
+ logicalWidth: constraints.maxWidth,
199
+ devicePixelRatio: MediaQuery.devicePixelRatioOf(context),
200
+ preferredFormat: 'webp',
201
+ );
202
+ ~~~
203
+
204
+ Reference adapters are packaged under:
205
+
206
+ ~~~text
207
+ media_engine/adapters/flutter/
208
+ ~~~
209
+
210
+ This keeps optimization and derivative generation on the Django/Python backend while allowing Flutter to select and cache the most appropriate immutable URL. Panorama manifests use the same principle at tile/level granularity.
211
+
180
212
  ## Backfill
181
213
 
182
214
  Historical media can be ingested with maintenance commands:
@@ -0,0 +1,63 @@
1
+ # Flutter adapter
2
+
3
+ Media Optimization Engineer can be consumed by Flutter through the same immutable manifest API used by other native clients.
4
+
5
+ The Python package ships Dart reference models under this directory. They are framework-neutral examples that consuming Flutter applications can copy or wrap in their own networking/cache layer.
6
+
7
+ ## Standard images
8
+
9
+ \`standard_images.dart\` parses a standard MOE manifest and selects a derivative from:
10
+
11
+ ~~~text
12
+ target physical width = rendered logical width × devicePixelRatio
13
+ ~~~
14
+
15
+ Recommended policy:
16
+
17
+ 1. fetch the asset manifest once;
18
+ 2. cache the manifest in memory;
19
+ 3. measure the actual widget width with \`LayoutBuilder\`;
20
+ 4. multiply by \`MediaQuery.devicePixelRatioOf(context)\`;
21
+ 5. select the first WebP derivative at or above that physical width;
22
+ 6. if none is large enough, use the largest available derivative;
23
+ 7. cache the selected immutable URL with the application's normal image cache;
24
+ 8. fall back to the manifest fallback/original URL if needed.
25
+
26
+ Example:
27
+
28
+ ~~~dart
29
+ final targetLogicalWidth = constraints.maxWidth;
30
+ final dpr = MediaQuery.devicePixelRatioOf(context);
31
+
32
+ final variant = manifest.select(
33
+ logicalWidth: targetLogicalWidth,
34
+ devicePixelRatio: dpr,
35
+ preferredFormat: 'webp',
36
+ );
37
+ ~~~
38
+
39
+ WebP is a conservative default for broad Android/iOS compatibility. Applications may prefer AVIF on platforms where their Flutter image stack reliably supports it.
40
+
41
+ ## Panorama 360
42
+
43
+ \`models.dart\` parses the generic panorama manifest.
44
+
45
+ Recommended panorama policy:
46
+
47
+ - show \`preview\` immediately;
48
+ - select a level from viewport width × DPR;
49
+ - request only tiles intersecting the current field of view;
50
+ - keep adjacent tiles warm for smooth panning;
51
+ - use AVIF where supported, otherwise WebP.
52
+
53
+ ## API
54
+
55
+ A standard image manifest is available from the REST viewset:
56
+
57
+ ~~~text
58
+ GET /api/v1/images/<asset-id>/manifest/?profile=<profile>
59
+ ~~~
60
+
61
+ When MOE is mounted under another prefix in a Django project, keep the same route relative to that prefix.
62
+
63
+ The native client should never upload or process an image merely to render it. Processing stays server-side; Flutter only chooses among READY immutable derivatives.
@@ -0,0 +1,106 @@
1
+ class MediaVariant {
2
+ final int width;
3
+ final int height;
4
+ final String url;
5
+ final int bytes;
6
+ final int quality;
7
+
8
+ const MediaVariant({
9
+ required this.width,
10
+ required this.height,
11
+ required this.url,
12
+ this.bytes = 0,
13
+ this.quality = 0,
14
+ });
15
+
16
+ factory MediaVariant.fromJson(Map<String, dynamic> json) => MediaVariant(
17
+ width: (json['width'] as num?)?.toInt() ?? 0,
18
+ height: (json['height'] as num?)?.toInt() ?? 0,
19
+ url: (json['url'] ?? '').toString(),
20
+ bytes: (json['bytes'] as num?)?.toInt() ?? 0,
21
+ quality: (json['quality'] as num?)?.toInt() ?? 0,
22
+ );
23
+ }
24
+
25
+ class MediaManifest {
26
+ final String id;
27
+ final String status;
28
+ final int width;
29
+ final int height;
30
+ final String profile;
31
+ final String dominantColor;
32
+ final String blurhash;
33
+ final String placeholderDataUrl;
34
+ final String originalUrl;
35
+ final MediaVariant? fallback;
36
+ final Map<String, List<MediaVariant>> variants;
37
+
38
+ const MediaManifest({
39
+ required this.id,
40
+ required this.status,
41
+ required this.width,
42
+ required this.height,
43
+ required this.profile,
44
+ required this.variants,
45
+ this.dominantColor = '',
46
+ this.blurhash = '',
47
+ this.placeholderDataUrl = '',
48
+ this.originalUrl = '',
49
+ this.fallback,
50
+ });
51
+
52
+ factory MediaManifest.fromJson(Map<String, dynamic> json) {
53
+ final rawVariants =
54
+ Map<String, dynamic>.from(json['variants'] as Map? ?? const {});
55
+ final parsed = <String, List<MediaVariant>>{};
56
+ for (final entry in rawVariants.entries) {
57
+ final items = (entry.value as List? ?? const [])
58
+ .whereType<Map>()
59
+ .map((item) => MediaVariant.fromJson(
60
+ Map<String, dynamic>.from(item),
61
+ ))
62
+ .toList()
63
+ ..sort((a, b) => a.width.compareTo(b.width));
64
+ parsed[entry.key] = items;
65
+ }
66
+
67
+ return MediaManifest(
68
+ id: (json['id'] ?? '').toString(),
69
+ status: (json['status'] ?? '').toString(),
70
+ width: (json['width'] as num?)?.toInt() ?? 0,
71
+ height: (json['height'] as num?)?.toInt() ?? 0,
72
+ profile: (json['profile'] ?? '').toString(),
73
+ dominantColor: (json['dominant_color'] ?? '').toString(),
74
+ blurhash: (json['blurhash'] ?? '').toString(),
75
+ placeholderDataUrl: (json['placeholder_data_url'] ?? '').toString(),
76
+ originalUrl: (json['original_url'] ?? '').toString(),
77
+ fallback: json['fallback'] is Map
78
+ ? MediaVariant.fromJson(
79
+ Map<String, dynamic>.from(json['fallback'] as Map),
80
+ )
81
+ : null,
82
+ variants: parsed,
83
+ );
84
+ }
85
+
86
+ MediaVariant? select({
87
+ required double logicalWidth,
88
+ required double devicePixelRatio,
89
+ String preferredFormat = 'webp',
90
+ }) {
91
+ final target = (logicalWidth * devicePixelRatio).ceil().clamp(1, 10000);
92
+ final order = <String>[preferredFormat, 'webp', 'avif'];
93
+
94
+ for (final format in order.toSet()) {
95
+ final candidates = variants[format];
96
+ if (candidates == null || candidates.isEmpty) continue;
97
+
98
+ for (final candidate in candidates) {
99
+ if (candidate.width >= target) return candidate;
100
+ }
101
+ return candidates.last;
102
+ }
103
+
104
+ return fallback;
105
+ }
106
+ }
@@ -0,0 +1,72 @@
1
+ from django.core.exceptions import ValidationError
2
+ from django.core.management.base import BaseCommand
3
+ from media_engine.integration import ingest_model_field
4
+ from media_engine.registry import entries
5
+
6
+
7
+ class Command(BaseCommand):
8
+ help = 'Ingest image fields declared through register_model_image().'
9
+
10
+ def add_arguments(self, parser):
11
+ parser.add_argument('--limit', type=int)
12
+ parser.add_argument(
13
+ '--fail-fast',
14
+ action='store_true',
15
+ help='Stop immediately on the first invalid/unreadable image.',
16
+ )
17
+
18
+ def handle(self, *args, **options):
19
+ processed = 0
20
+ skipped = 0
21
+ failed = 0
22
+ failures = []
23
+
24
+ for entry in entries():
25
+ qs = entry.model._default_manager.all()
26
+ if options['limit']:
27
+ qs = qs[:options['limit']]
28
+
29
+ for obj in qs.iterator():
30
+ try:
31
+ asset = ingest_model_field(
32
+ obj,
33
+ field_name=entry.field_name,
34
+ profile=entry.profile,
35
+ role=entry.role,
36
+ )
37
+ if asset is None:
38
+ skipped += 1
39
+ else:
40
+ processed += 1
41
+ except ValidationError as exc:
42
+ failed += 1
43
+ message = (
44
+ f'{entry.model._meta.label}.{entry.field_name} '
45
+ f'pk={obj.pk}: {exc}'
46
+ )
47
+ failures.append(message)
48
+ self.stderr.write(self.style.WARNING(f'SKIP {message}'))
49
+ if options['fail_fast']:
50
+ raise
51
+ except (FileNotFoundError, OSError, ValueError) as exc:
52
+ failed += 1
53
+ message = (
54
+ f'{entry.model._meta.label}.{entry.field_name} '
55
+ f'pk={obj.pk}: {exc}'
56
+ )
57
+ failures.append(message)
58
+ self.stderr.write(self.style.WARNING(f'SKIP {message}'))
59
+ if options['fail_fast']:
60
+ raise
61
+
62
+ summary = (
63
+ f'Processed {processed} registered model images; '
64
+ f'skipped {skipped}; failures {failed}.'
65
+ )
66
+ if failed:
67
+ self.stdout.write(self.style.WARNING(summary))
68
+ self.stdout.write('Last failures:')
69
+ for message in failures[-20:]:
70
+ self.stdout.write(f' - {message}')
71
+ else:
72
+ self.stdout.write(self.style.SUCCESS(summary))
@@ -2,7 +2,7 @@ from pathlib import Path
2
2
 
3
3
 
4
4
  def safe_extension(fmt):
5
- return {'JPEG': 'jpg', 'JPG': 'jpg', 'PNG': 'png', 'WEBP': 'webp', 'AVIF': 'avif'}.get(fmt.upper(), fmt.lower())
5
+ return {'JPEG': 'jpg', 'JPG': 'jpg', 'MPO': 'jpg', 'PNG': 'png', 'WEBP': 'webp', 'AVIF': 'avif'}.get(fmt.upper(), fmt.lower())
6
6
 
7
7
 
8
8
  def original_storage_name(sha256_hex, extension):
@@ -0,0 +1,51 @@
1
+ from types import SimpleNamespace
2
+ from unittest.mock import patch
3
+
4
+ from django.core.exceptions import ValidationError
5
+ from django.core.management import call_command
6
+ from django.test import SimpleTestCase
7
+
8
+
9
+ class _Manager:
10
+ def __init__(self, objects):
11
+ self._objects = list(objects)
12
+
13
+ def all(self):
14
+ return self
15
+
16
+ def __iter__(self):
17
+ return iter(self._objects)
18
+
19
+ def __getitem__(self, item):
20
+ if isinstance(item, slice):
21
+ return _Manager(self._objects[item])
22
+ return self._objects[item]
23
+
24
+ def iterator(self):
25
+ return iter(self._objects)
26
+
27
+
28
+ class BackfillRegisteredMediaTests(SimpleTestCase):
29
+ @patch("media_engine.management.commands.backfill_registered_media.entries")
30
+ @patch("media_engine.management.commands.backfill_registered_media.ingest_model_field")
31
+ def test_invalid_image_does_not_abort_backfill(self, ingest, entries):
32
+ model = SimpleNamespace(
33
+ _default_manager=_Manager([
34
+ SimpleNamespace(pk=1),
35
+ SimpleNamespace(pk=2),
36
+ ]),
37
+ _meta=SimpleNamespace(label="demo.Photo"),
38
+ )
39
+ entries.return_value = [
40
+ SimpleNamespace(
41
+ model=model,
42
+ field_name="image",
43
+ profile="default",
44
+ role="content",
45
+ )
46
+ ]
47
+ ingest.side_effect = [ValidationError("Unsupported image format"), object()]
48
+
49
+ call_command("backfill_registered_media")
50
+
51
+ self.assertEqual(ingest.call_count, 2)
@@ -0,0 +1,30 @@
1
+ from io import BytesIO
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ from django.core.files.uploadedfile import SimpleUploadedFile
5
+ from django.test import SimpleTestCase
6
+
7
+ from media_engine.validators import inspect_upload
8
+
9
+
10
+ class MpoValidationTests(SimpleTestCase):
11
+ @patch("media_engine.validators.Image.open")
12
+ def test_mpo_is_normalized_as_jpeg(self, image_open):
13
+ image = MagicMock()
14
+ image.format = "MPO"
15
+ image.size = (1200, 800)
16
+ image.verify.return_value = None
17
+ image_open.return_value = image
18
+
19
+ upload = SimpleUploadedFile(
20
+ "portrait.jpg",
21
+ b"fake-mpo-container",
22
+ content_type="image/jpeg",
23
+ )
24
+
25
+ raw, fmt, mime, width, height = inspect_upload(upload)
26
+
27
+ self.assertEqual(raw, b"fake-mpo-container")
28
+ self.assertEqual(fmt, "JPEG")
29
+ self.assertEqual(mime, "image/jpeg")
30
+ self.assertEqual((width, height), (1200, 800))
@@ -7,9 +7,10 @@ try:
7
7
  except Exception:
8
8
  pillow_avif = None
9
9
 
10
- ALLOWED_FORMATS = {'JPEG', 'PNG', 'WEBP', 'AVIF'}
10
+ ALLOWED_FORMATS = {'JPEG', 'PNG', 'WEBP', 'AVIF', 'MPO'}
11
11
  MIME_BY_FORMAT = {
12
12
  'JPEG': 'image/jpeg',
13
+ 'MPO': 'image/jpeg',
13
14
  'PNG': 'image/png',
14
15
  'WEBP': 'image/webp',
15
16
  'AVIF': 'image/avif',
@@ -31,6 +32,11 @@ def inspect_upload(uploaded_file):
31
32
  fmt = (image.format or '').upper()
32
33
  if fmt not in ALLOWED_FORMATS:
33
34
  raise ValidationError(f'Unsupported image format: {fmt or "unknown"}')
35
+ # Pillow reports some JPEG containers as MPO. For optimization purposes,
36
+ # the primary frame is a normal JPEG-compatible raster and can safely
37
+ # enter the standard image pipeline.
38
+ if fmt == 'MPO':
39
+ fmt = 'JPEG'
34
40
  width, height = image.size
35
41
  max_pixels = getattr(settings, 'MEDIA_ENGINE_MAX_PIXELS', 50_000_000)
36
42
  if width * height > max_pixels:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: media-optimization-engine
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Summary: Media Optimization Engineer for Django/Python: automatic model-field optimization, responsive picture/srcset rendering, AVIF/WebP derivatives, 360 media, autonomous nodes, and CDN-ready storage.
5
5
  Author-email: Achille Kabasele <pepexykabasele@gmail.com>
6
6
  Maintainer-email: Achille Kabasele <pepexykabasele@gmail.com>
@@ -233,6 +233,38 @@ JavaScript is not required for normal responsive images.
233
233
 
234
234
  Runtime measurement is appropriate only for highly dynamic components whose rendered width cannot be described reliably with sizes, such as resizable editors, canvas-like builders, or asynchronously mounted panels.
235
235
 
236
+ ## Docker image
237
+
238
+ The standalone service is also published as a multi-architecture GHCR image:
239
+
240
+ ~~~bash
241
+ docker pull ghcr.io/kabasele754/media-optimization-engine:1.4.1
242
+ ~~~
243
+
244
+ For Compose deployment, see `docs/DOCKER.md` and `docker-compose.ghcr.yml`.
245
+
246
+ ## Flutter / native clients
247
+
248
+ MOE also ships reference Dart adapters for native applications.
249
+
250
+ Standard Flutter clients should fetch the image manifest, measure the rendered widget width, multiply it by device pixel ratio, and select the first derivative at or above that physical width.
251
+
252
+ ~~~dart
253
+ final variant = manifest.select(
254
+ logicalWidth: constraints.maxWidth,
255
+ devicePixelRatio: MediaQuery.devicePixelRatioOf(context),
256
+ preferredFormat: 'webp',
257
+ );
258
+ ~~~
259
+
260
+ Reference adapters are packaged under:
261
+
262
+ ~~~text
263
+ media_engine/adapters/flutter/
264
+ ~~~
265
+
266
+ This keeps optimization and derivative generation on the Django/Python backend while allowing Flutter to select and cache the most appropriate immutable URL. Panorama manifests use the same principle at tile/level granularity.
267
+
236
268
  ## Backfill
237
269
 
238
270
  Historical media can be ingested with maintenance commands:
@@ -34,6 +34,7 @@ media_engine/adapters/__init__.py
34
34
  media_engine/adapters/flutter/README.md
35
35
  media_engine/adapters/flutter/__init__.py
36
36
  media_engine/adapters/flutter/models.dart
37
+ media_engine/adapters/flutter/standard_images.dart
37
38
  media_engine/adapters/marzipano/__init__.py
38
39
  media_engine/adapters/marzipano/manifest.py
39
40
  media_engine/adapters/pannellum/__init__.py
@@ -74,12 +75,14 @@ media_engine/static/media_engine/media-cache-sw.js
74
75
  media_engine/templatetags/__init__.py
75
76
  media_engine/templatetags/responsive_media.py
76
77
  media_engine/tests/__init__.py
78
+ media_engine/tests/test_backfill_registered_media.py
77
79
  media_engine/tests/test_distributed_node.py
78
80
  media_engine/tests/test_image_ops.py
79
81
  media_engine/tests/test_panorama.py
80
82
  media_engine/tests/test_paths.py
81
83
  media_engine/tests/test_profiles.py
82
84
  media_engine/tests/test_responsive_media.py
85
+ media_engine/tests/test_validators.py
83
86
  media_optimization_engine.egg-info/PKG-INFO
84
87
  media_optimization_engine.egg-info/SOURCES.txt
85
88
  media_optimization_engine.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "media-optimization-engine"
7
- version = "1.4.0"
7
+ version = "1.4.2"
8
8
  description = "Media Optimization Engineer for Django/Python: automatic model-field optimization, responsive picture/srcset rendering, AVIF/WebP derivatives, 360 media, autonomous nodes, and CDN-ready storage."
9
9
  requires-python = ">=3.11"
10
10
  authors = [{ name = "Achille Kabasele", email = "pepexykabasele@gmail.com" }]
@@ -1,10 +0,0 @@
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.
@@ -1,21 +0,0 @@
1
- from django.core.management.base import BaseCommand
2
- from media_engine.integration import ingest_model_field
3
- from media_engine.registry import entries
4
-
5
-
6
- class Command(BaseCommand):
7
- help = 'Ingest image fields declared through register_model_image().'
8
-
9
- def add_arguments(self, parser):
10
- parser.add_argument('--limit', type=int)
11
-
12
- def handle(self, *args, **options):
13
- processed = 0
14
- for entry in entries():
15
- qs = entry.model._default_manager.all()
16
- if options['limit']:
17
- qs = qs[:options['limit']]
18
- for obj in qs.iterator():
19
- ingest_model_field(obj, field_name=entry.field_name, profile=entry.profile, role=entry.role)
20
- processed += 1
21
- self.stdout.write(self.style.SUCCESS(f'Processed {processed} registered model images.'))