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
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand
|
|
2
|
+
|
|
3
|
+
from media_engine.doctor import format_report, run_diagnostics
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Command(BaseCommand):
|
|
7
|
+
help = "Diagnose Media Optimization Engine integration, migrations, storage and codecs."
|
|
8
|
+
|
|
9
|
+
def handle(self, *args, **options):
|
|
10
|
+
checks = run_diagnostics()
|
|
11
|
+
report = format_report(checks)
|
|
12
|
+
self.stdout.write(report)
|
|
13
|
+
errors = [c for c in checks if not c.ok and c.level != "WARN"]
|
|
14
|
+
if errors:
|
|
15
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
from django.core.management.base import BaseCommand
|
|
4
|
+
from media_engine.control_plane import control_plane_status, load_last_known_good
|
|
5
|
+
from media_engine.node import get_node_identity
|
|
6
|
+
from media_engine.runtime_config import effective_profiles
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Command(BaseCommand):
|
|
10
|
+
help = 'Print node identity, autonomy configuration and active image profiles.'
|
|
11
|
+
|
|
12
|
+
def handle(self, *args, **options):
|
|
13
|
+
payload = {
|
|
14
|
+
'version': getattr(settings, 'MEDIA_ENGINE_VERSION', '1.4.0'),
|
|
15
|
+
'pipeline_version': getattr(settings, 'MEDIA_ENGINE_PIPELINE_VERSION', 1),
|
|
16
|
+
'node': get_node_identity().as_dict(),
|
|
17
|
+
'require_api_key': getattr(settings, 'MEDIA_ENGINE_REQUIRE_API_KEY', False),
|
|
18
|
+
'control_plane_enabled': getattr(settings, 'MEDIA_ENGINE_CONTROL_PLANE_ENABLED', False),
|
|
19
|
+
'control_plane_runtime_dependency': False,
|
|
20
|
+
'control_plane_status': control_plane_status(),
|
|
21
|
+
'last_known_good': load_last_known_good(),
|
|
22
|
+
'profiles': effective_profiles(),
|
|
23
|
+
}
|
|
24
|
+
self.stdout.write(json.dumps(payload, indent=2, sort_keys=True))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
3
|
+
from media_engine.control_plane import rollback_to_previous
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Command(BaseCommand):
|
|
7
|
+
help = 'Rollback to the previous last-known-good control-plane profile configuration.'
|
|
8
|
+
|
|
9
|
+
def handle(self, *args, **options):
|
|
10
|
+
try:
|
|
11
|
+
result = rollback_to_previous()
|
|
12
|
+
except RuntimeError as exc:
|
|
13
|
+
raise CommandError(str(exc)) from exc
|
|
14
|
+
self.stdout.write(json.dumps(result, indent=2, sort_keys=True))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from django.core.management.base import BaseCommand
|
|
3
|
+
from media_engine.control_plane import sync_from_control_plane
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Command(BaseCommand):
|
|
7
|
+
help = 'Synchronize optional external control-plane configuration without making runtime depend on it.'
|
|
8
|
+
|
|
9
|
+
def add_arguments(self, parser):
|
|
10
|
+
parser.add_argument('--force', action='store_true')
|
|
11
|
+
|
|
12
|
+
def handle(self, *args, **options):
|
|
13
|
+
result = sync_from_control_plane(force=options['force'])
|
|
14
|
+
self.stdout.write(json.dumps(result, indent=2, sort_keys=True))
|
|
15
|
+
if result.get('state') == 'degraded':
|
|
16
|
+
self.stderr.write(
|
|
17
|
+
self.style.WARNING(
|
|
18
|
+
'Control plane unavailable; engine continues with last-known-good/local config.'
|
|
19
|
+
)
|
|
20
|
+
)
|
media_engine/manifest.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.core.cache import cache
|
|
3
|
+
from .models import MediaAsset, MediaVariant
|
|
4
|
+
from .profiles import get_profile
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def manifest_cache_key(asset_id, profile='default'):
|
|
8
|
+
version = getattr(settings, 'MEDIA_ENGINE_PIPELINE_VERSION', 1)
|
|
9
|
+
return f'media-engine:manifest:v{version}:{asset_id}:{profile}'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def invalidate_manifest(asset_id):
|
|
13
|
+
profiles = set(getattr(settings, 'MEDIA_ENGINE_PROFILES', {}).keys())
|
|
14
|
+
profiles.update({'panorama.multires', 'panorama.preview', 'panorama.marzipano', 'panorama.cube'})
|
|
15
|
+
for profile in profiles:
|
|
16
|
+
cache.delete(manifest_cache_key(asset_id, profile))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def public_url(file_field):
|
|
20
|
+
url = file_field.url
|
|
21
|
+
cdn = getattr(settings, 'MEDIA_ENGINE_CDN_BASE_URL', '')
|
|
22
|
+
if cdn and url.startswith('/'):
|
|
23
|
+
return f'{cdn}{url}'
|
|
24
|
+
return url
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _standard_manifest(asset, profile):
|
|
28
|
+
profile_cfg = get_profile(profile)
|
|
29
|
+
version = getattr(settings, 'MEDIA_ENGINE_PIPELINE_VERSION', 1)
|
|
30
|
+
variants = MediaVariant.objects.filter(
|
|
31
|
+
asset=asset,
|
|
32
|
+
profile=profile,
|
|
33
|
+
processor_version=version,
|
|
34
|
+
status=MediaVariant.Status.READY,
|
|
35
|
+
).order_by('width')
|
|
36
|
+
grouped = {}
|
|
37
|
+
for variant in variants:
|
|
38
|
+
grouped.setdefault(variant.format, []).append({
|
|
39
|
+
'width': variant.width,
|
|
40
|
+
'height': variant.height,
|
|
41
|
+
'url': public_url(variant.file),
|
|
42
|
+
'bytes': variant.file_size,
|
|
43
|
+
'quality': variant.quality,
|
|
44
|
+
})
|
|
45
|
+
fallback_format = profile_cfg.get('fallback_format', 'jpeg')
|
|
46
|
+
fallback = (grouped.get(fallback_format) or grouped.get('webp') or grouped.get('avif') or [])[-1:] or None
|
|
47
|
+
return {
|
|
48
|
+
'id': str(asset.id),
|
|
49
|
+
'status': asset.status,
|
|
50
|
+
'media_kind': asset.media_kind,
|
|
51
|
+
'projection': asset.projection,
|
|
52
|
+
'sha256': asset.original_sha256,
|
|
53
|
+
'width': asset.width,
|
|
54
|
+
'height': asset.height,
|
|
55
|
+
'dominant_color': asset.dominant_color,
|
|
56
|
+
'blurhash': asset.blurhash,
|
|
57
|
+
'placeholder_data_url': asset.placeholder_data_url,
|
|
58
|
+
'profile': profile,
|
|
59
|
+
'processor': asset.processor,
|
|
60
|
+
'processor_version': version,
|
|
61
|
+
'variants': grouped,
|
|
62
|
+
'fallback': fallback[0] if fallback else None,
|
|
63
|
+
'original_url': public_url(asset.original_file) if getattr(settings, 'MEDIA_ENGINE_PUBLIC_ORIGINAL_FALLBACK', False) else None,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_manifest(asset_or_id, profile='default'):
|
|
68
|
+
asset_id = getattr(asset_or_id, 'id', asset_or_id)
|
|
69
|
+
key = manifest_cache_key(asset_id, profile)
|
|
70
|
+
cached = cache.get(key)
|
|
71
|
+
if cached:
|
|
72
|
+
return cached
|
|
73
|
+
asset = asset_or_id if isinstance(asset_or_id, MediaAsset) else MediaAsset.objects.get(pk=asset_id)
|
|
74
|
+
if asset.media_kind == 'panorama' or profile.startswith('panorama.'):
|
|
75
|
+
from .processors.panorama.manifest import panorama_manifest
|
|
76
|
+
result = panorama_manifest(asset, profile=profile if profile.startswith('panorama.') else 'panorama.multires')
|
|
77
|
+
if result.get('preview'):
|
|
78
|
+
result['preview'] = public_url(asset.panorama_preview)
|
|
79
|
+
for level in result.get('levels', []):
|
|
80
|
+
for tiles in level.get('formats', {}).values():
|
|
81
|
+
for tile in tiles:
|
|
82
|
+
url = tile.get('url', '')
|
|
83
|
+
cdn = getattr(settings, 'MEDIA_ENGINE_CDN_BASE_URL', '')
|
|
84
|
+
if cdn and url.startswith('/'):
|
|
85
|
+
tile['url'] = f'{cdn}{url}'
|
|
86
|
+
else:
|
|
87
|
+
result = _standard_manifest(asset, profile)
|
|
88
|
+
cache.set(key, result, 3600)
|
|
89
|
+
return result
|
media_engine/metrics.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from django.http import HttpResponse
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from prometheus_client import Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST
|
|
5
|
+
except ImportError:
|
|
6
|
+
class _Metric:
|
|
7
|
+
def labels(self, *args, **kwargs):
|
|
8
|
+
return self
|
|
9
|
+
def inc(self, *args, **kwargs):
|
|
10
|
+
return None
|
|
11
|
+
def set(self, *args, **kwargs):
|
|
12
|
+
return None
|
|
13
|
+
def time(self):
|
|
14
|
+
class _Timer:
|
|
15
|
+
def __enter__(self): return self
|
|
16
|
+
def __exit__(self, *args): return False
|
|
17
|
+
return _Timer()
|
|
18
|
+
def Counter(*args, **kwargs): return _Metric()
|
|
19
|
+
def Histogram(*args, **kwargs): return _Metric()
|
|
20
|
+
def Gauge(*args, **kwargs): return _Metric()
|
|
21
|
+
def generate_latest(): return b''
|
|
22
|
+
CONTENT_TYPE_LATEST = 'text/plain; charset=utf-8'
|
|
23
|
+
|
|
24
|
+
IMAGES_INGESTED = Counter('media_engine_images_ingested_total', 'Number of media assets ingested')
|
|
25
|
+
VARIANTS_GENERATED = Counter('media_engine_variants_generated_total', 'Generated variants', ['format', 'profile'])
|
|
26
|
+
VARIANT_FAILURES = Counter('media_engine_variant_failures_total', 'Failed variant generations', ['format', 'profile'])
|
|
27
|
+
GENERATION_SECONDS = Histogram('media_engine_generation_seconds', 'Variant generation duration', ['format', 'profile'])
|
|
28
|
+
ORIGINAL_BYTES = Counter('media_engine_original_bytes_total', 'Bytes ingested as originals')
|
|
29
|
+
DERIVATIVE_BYTES = Counter('media_engine_derivative_bytes_total', 'Bytes written as derivatives', ['format'])
|
|
30
|
+
READY_ASSETS = Gauge('media_engine_ready_assets', 'Number of ready media assets')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def metrics_view(request):
|
|
34
|
+
return HttpResponse(generate_latest(), content_type=CONTENT_TYPE_LATEST)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Generated for Media Optimization Engine 1.0.0
|
|
2
|
+
import uuid
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
import django.db.models.deletion
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
initial = True
|
|
9
|
+
dependencies = [('contenttypes', '0002_remove_content_type_name')]
|
|
10
|
+
|
|
11
|
+
operations = [
|
|
12
|
+
migrations.CreateModel(
|
|
13
|
+
name='MediaAsset',
|
|
14
|
+
fields=[
|
|
15
|
+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
16
|
+
('original_sha256', models.CharField(db_index=True, max_length=64, unique=True)),
|
|
17
|
+
('original_file', models.FileField(upload_to='media_engine/originals/')),
|
|
18
|
+
('original_name', models.CharField(blank=True, max_length=255)),
|
|
19
|
+
('mime_type', models.CharField(max_length=100)),
|
|
20
|
+
('width', models.PositiveIntegerField()),
|
|
21
|
+
('height', models.PositiveIntegerField()),
|
|
22
|
+
('original_size', models.BigIntegerField(default=0)),
|
|
23
|
+
('dominant_color', models.CharField(blank=True, max_length=16)),
|
|
24
|
+
('blurhash', models.CharField(blank=True, max_length=255)),
|
|
25
|
+
('placeholder_data_url', models.TextField(blank=True)),
|
|
26
|
+
('focal_x', models.FloatField(blank=True, null=True)),
|
|
27
|
+
('focal_y', models.FloatField(blank=True, null=True)),
|
|
28
|
+
('status', models.CharField(choices=[('PENDING', 'Pending'), ('PROCESSING', 'Processing'), ('READY', 'Ready'), ('PARTIAL', 'Partial'), ('FAILED', 'Failed')], default='PENDING', max_length=20)),
|
|
29
|
+
('last_error', models.TextField(blank=True)),
|
|
30
|
+
('processor_version', models.PositiveIntegerField(default=1)),
|
|
31
|
+
('processed_at', models.DateTimeField(blank=True, null=True)),
|
|
32
|
+
('owner_ref', models.CharField(blank=True, db_index=True, max_length=255)),
|
|
33
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
34
|
+
('updated_at', models.DateTimeField(auto_now=True)),
|
|
35
|
+
],
|
|
36
|
+
options={'ordering': ['-created_at']},
|
|
37
|
+
),
|
|
38
|
+
migrations.CreateModel(
|
|
39
|
+
name='MediaVariant',
|
|
40
|
+
fields=[
|
|
41
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
42
|
+
('profile', models.CharField(default='default', max_length=50)),
|
|
43
|
+
('width', models.PositiveIntegerField()),
|
|
44
|
+
('height', models.PositiveIntegerField(default=0)),
|
|
45
|
+
('format', models.CharField(max_length=10)),
|
|
46
|
+
('file', models.FileField(upload_to='media_engine/derivatives/')),
|
|
47
|
+
('file_size', models.BigIntegerField(default=0)),
|
|
48
|
+
('quality', models.PositiveSmallIntegerField(default=0)),
|
|
49
|
+
('status', models.CharField(choices=[('PENDING', 'Pending'), ('PROCESSING', 'Processing'), ('READY', 'Ready'), ('FAILED', 'Failed')], default='PENDING', max_length=20)),
|
|
50
|
+
('processor_version', models.PositiveIntegerField(default=1)),
|
|
51
|
+
('last_error', models.TextField(blank=True)),
|
|
52
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
53
|
+
('updated_at', models.DateTimeField(auto_now=True)),
|
|
54
|
+
('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variants', to='media_engine.mediaasset')),
|
|
55
|
+
],
|
|
56
|
+
),
|
|
57
|
+
migrations.CreateModel(
|
|
58
|
+
name='MediaBinding',
|
|
59
|
+
fields=[
|
|
60
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
61
|
+
('object_id', models.CharField(max_length=128)),
|
|
62
|
+
('field_name', models.CharField(max_length=100)),
|
|
63
|
+
('profile', models.CharField(default='default', max_length=50)),
|
|
64
|
+
('role', models.CharField(default='content', max_length=50)),
|
|
65
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
66
|
+
('updated_at', models.DateTimeField(auto_now=True)),
|
|
67
|
+
('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bindings', to='media_engine.mediaasset')),
|
|
68
|
+
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
|
69
|
+
],
|
|
70
|
+
),
|
|
71
|
+
migrations.AddConstraint(
|
|
72
|
+
model_name='mediavariant',
|
|
73
|
+
constraint=models.UniqueConstraint(fields=('asset', 'profile', 'width', 'format', 'processor_version'), name='uniq_media_variant_version'),
|
|
74
|
+
),
|
|
75
|
+
migrations.AddIndex(
|
|
76
|
+
model_name='mediavariant',
|
|
77
|
+
index=models.Index(fields=['asset', 'profile', 'status'], name='media_engin_asset_i_60f63d_idx'),
|
|
78
|
+
),
|
|
79
|
+
migrations.AddIndex(
|
|
80
|
+
model_name='mediavariant',
|
|
81
|
+
index=models.Index(fields=['processor_version', 'status'], name='media_engin_process_7c2c36_idx'),
|
|
82
|
+
),
|
|
83
|
+
migrations.AddConstraint(
|
|
84
|
+
model_name='mediabinding',
|
|
85
|
+
constraint=models.UniqueConstraint(fields=('content_type', 'object_id', 'field_name'), name='uniq_media_binding_field'),
|
|
86
|
+
),
|
|
87
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
import django.db.models.deletion
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Migration(migrations.Migration):
|
|
6
|
+
dependencies = [('media_engine', '0001_initial')]
|
|
7
|
+
|
|
8
|
+
operations = [
|
|
9
|
+
migrations.AddField(model_name='mediaasset', name='media_kind', field=models.CharField(db_index=True, default='image', max_length=32)),
|
|
10
|
+
migrations.AddField(model_name='mediaasset', name='projection', field=models.CharField(blank=True, default='', max_length=32)),
|
|
11
|
+
migrations.AddField(model_name='mediaasset', name='processor', field=models.CharField(blank=True, default='standard_image', max_length=64)),
|
|
12
|
+
migrations.AddField(model_name='mediaasset', name='metadata_json', field=models.JSONField(blank=True, default=dict)),
|
|
13
|
+
migrations.AddField(model_name='mediaasset', name='panorama_preview', field=models.FileField(blank=True, upload_to='media_engine/panoramas/previews/')),
|
|
14
|
+
migrations.CreateModel(
|
|
15
|
+
name='PanoramaTile',
|
|
16
|
+
fields=[
|
|
17
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
18
|
+
('profile', models.CharField(default='panorama.multires', max_length=64)),
|
|
19
|
+
('processor_version', models.PositiveIntegerField(default=1)),
|
|
20
|
+
('level', models.PositiveSmallIntegerField()),
|
|
21
|
+
('level_width', models.PositiveIntegerField(default=0)),
|
|
22
|
+
('level_height', models.PositiveIntegerField(default=0)),
|
|
23
|
+
('col', models.PositiveIntegerField()),
|
|
24
|
+
('row', models.PositiveIntegerField()),
|
|
25
|
+
('width', models.PositiveIntegerField()),
|
|
26
|
+
('height', models.PositiveIntegerField()),
|
|
27
|
+
('format', models.CharField(max_length=10)),
|
|
28
|
+
('file', models.FileField(upload_to='media_engine/panoramas/')),
|
|
29
|
+
('file_size', models.BigIntegerField(default=0)),
|
|
30
|
+
('quality', models.PositiveSmallIntegerField(default=0)),
|
|
31
|
+
('status', models.CharField(choices=[('PENDING', 'Pending'), ('PROCESSING', 'Processing'), ('READY', 'Ready'), ('FAILED', 'Failed')], default='PENDING', max_length=20)),
|
|
32
|
+
('last_error', models.TextField(blank=True)),
|
|
33
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
34
|
+
('updated_at', models.DateTimeField(auto_now=True)),
|
|
35
|
+
('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='panorama_tiles', to='media_engine.mediaasset')),
|
|
36
|
+
],
|
|
37
|
+
),
|
|
38
|
+
migrations.AddConstraint(
|
|
39
|
+
model_name='panoramatile',
|
|
40
|
+
constraint=models.UniqueConstraint(fields=('asset', 'profile', 'processor_version', 'level', 'col', 'row', 'format'), name='uniq_panorama_tile_version'),
|
|
41
|
+
),
|
|
42
|
+
migrations.AddIndex(model_name='panoramatile', index=models.Index(fields=['asset', 'profile', 'level'], name='media_engin_asset_i_360lvl_idx')),
|
|
43
|
+
migrations.AddIndex(model_name='panoramatile', index=models.Index(fields=['asset', 'status'], name='media_engin_asset_s_360_idx')),
|
|
44
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
dependencies = [('media_engine', '0002_panorama_multires')]
|
|
6
|
+
|
|
7
|
+
operations = [
|
|
8
|
+
migrations.RemoveConstraint(
|
|
9
|
+
model_name='panoramatile',
|
|
10
|
+
name='uniq_panorama_tile_version',
|
|
11
|
+
),
|
|
12
|
+
migrations.AddField(
|
|
13
|
+
model_name='panoramatile',
|
|
14
|
+
name='face',
|
|
15
|
+
field=models.CharField(blank=True, default='', max_length=1),
|
|
16
|
+
),
|
|
17
|
+
migrations.AddConstraint(
|
|
18
|
+
model_name='panoramatile',
|
|
19
|
+
constraint=models.UniqueConstraint(
|
|
20
|
+
fields=('asset', 'profile', 'processor_version', 'level', 'face', 'col', 'row', 'format'),
|
|
21
|
+
name='uniq_panorama_tile_version_v2',
|
|
22
|
+
),
|
|
23
|
+
),
|
|
24
|
+
migrations.AddIndex(
|
|
25
|
+
model_name='panoramatile',
|
|
26
|
+
index=models.Index(
|
|
27
|
+
fields=['asset', 'profile', 'level', 'face'],
|
|
28
|
+
name='media_engin_cube_face_idx',
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
]
|
|
File without changes
|
media_engine/models.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
3
|
+
from django.contrib.contenttypes.models import ContentType
|
|
4
|
+
from django.db import models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MediaAsset(models.Model):
|
|
8
|
+
class Status(models.TextChoices):
|
|
9
|
+
PENDING = 'PENDING', 'Pending'
|
|
10
|
+
PROCESSING = 'PROCESSING', 'Processing'
|
|
11
|
+
READY = 'READY', 'Ready'
|
|
12
|
+
PARTIAL = 'PARTIAL', 'Partial'
|
|
13
|
+
FAILED = 'FAILED', 'Failed'
|
|
14
|
+
|
|
15
|
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
16
|
+
original_sha256 = models.CharField(max_length=64, unique=True, db_index=True)
|
|
17
|
+
original_file = models.FileField(upload_to='media_engine/originals/')
|
|
18
|
+
original_name = models.CharField(max_length=255, blank=True)
|
|
19
|
+
mime_type = models.CharField(max_length=100)
|
|
20
|
+
width = models.PositiveIntegerField()
|
|
21
|
+
height = models.PositiveIntegerField()
|
|
22
|
+
original_size = models.BigIntegerField(default=0)
|
|
23
|
+
media_kind = models.CharField(max_length=32, default='image', db_index=True)
|
|
24
|
+
projection = models.CharField(max_length=32, blank=True, default='')
|
|
25
|
+
processor = models.CharField(max_length=64, blank=True, default='standard_image')
|
|
26
|
+
metadata_json = models.JSONField(default=dict, blank=True)
|
|
27
|
+
panorama_preview = models.FileField(upload_to='media_engine/panoramas/previews/', blank=True)
|
|
28
|
+
dominant_color = models.CharField(max_length=16, blank=True)
|
|
29
|
+
blurhash = models.CharField(max_length=255, blank=True)
|
|
30
|
+
placeholder_data_url = models.TextField(blank=True)
|
|
31
|
+
focal_x = models.FloatField(null=True, blank=True)
|
|
32
|
+
focal_y = models.FloatField(null=True, blank=True)
|
|
33
|
+
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
|
|
34
|
+
last_error = models.TextField(blank=True)
|
|
35
|
+
processor_version = models.PositiveIntegerField(default=1)
|
|
36
|
+
processed_at = models.DateTimeField(null=True, blank=True)
|
|
37
|
+
owner_ref = models.CharField(max_length=255, blank=True, db_index=True)
|
|
38
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
39
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
40
|
+
|
|
41
|
+
class Meta:
|
|
42
|
+
ordering = ['-created_at']
|
|
43
|
+
|
|
44
|
+
def __str__(self):
|
|
45
|
+
return f'{self.id} {self.original_name or self.original_sha256[:12]}'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MediaVariant(models.Model):
|
|
49
|
+
class Status(models.TextChoices):
|
|
50
|
+
PENDING = 'PENDING', 'Pending'
|
|
51
|
+
PROCESSING = 'PROCESSING', 'Processing'
|
|
52
|
+
READY = 'READY', 'Ready'
|
|
53
|
+
FAILED = 'FAILED', 'Failed'
|
|
54
|
+
|
|
55
|
+
asset = models.ForeignKey(MediaAsset, related_name='variants', on_delete=models.CASCADE)
|
|
56
|
+
profile = models.CharField(max_length=50, default='default')
|
|
57
|
+
width = models.PositiveIntegerField()
|
|
58
|
+
height = models.PositiveIntegerField(default=0)
|
|
59
|
+
format = models.CharField(max_length=10)
|
|
60
|
+
file = models.FileField(upload_to='media_engine/derivatives/')
|
|
61
|
+
file_size = models.BigIntegerField(default=0)
|
|
62
|
+
quality = models.PositiveSmallIntegerField(default=0)
|
|
63
|
+
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
|
|
64
|
+
processor_version = models.PositiveIntegerField(default=1)
|
|
65
|
+
last_error = models.TextField(blank=True)
|
|
66
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
67
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
68
|
+
|
|
69
|
+
class Meta:
|
|
70
|
+
constraints = [
|
|
71
|
+
models.UniqueConstraint(
|
|
72
|
+
fields=['asset', 'profile', 'width', 'format', 'processor_version'],
|
|
73
|
+
name='uniq_media_variant_version',
|
|
74
|
+
)
|
|
75
|
+
]
|
|
76
|
+
indexes = [
|
|
77
|
+
models.Index(fields=['asset', 'profile', 'status']),
|
|
78
|
+
models.Index(fields=['processor_version', 'status']),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
def __str__(self):
|
|
82
|
+
return f'{self.asset_id} {self.profile} {self.width}.{self.format}'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PanoramaTile(models.Model):
|
|
86
|
+
class Status(models.TextChoices):
|
|
87
|
+
PENDING = 'PENDING', 'Pending'
|
|
88
|
+
PROCESSING = 'PROCESSING', 'Processing'
|
|
89
|
+
READY = 'READY', 'Ready'
|
|
90
|
+
FAILED = 'FAILED', 'Failed'
|
|
91
|
+
|
|
92
|
+
asset = models.ForeignKey(MediaAsset, related_name='panorama_tiles', on_delete=models.CASCADE)
|
|
93
|
+
profile = models.CharField(max_length=64, default='panorama.multires')
|
|
94
|
+
processor_version = models.PositiveIntegerField(default=1)
|
|
95
|
+
level = models.PositiveSmallIntegerField()
|
|
96
|
+
face = models.CharField(max_length=1, blank=True, default='')
|
|
97
|
+
level_width = models.PositiveIntegerField(default=0)
|
|
98
|
+
level_height = models.PositiveIntegerField(default=0)
|
|
99
|
+
col = models.PositiveIntegerField()
|
|
100
|
+
row = models.PositiveIntegerField()
|
|
101
|
+
width = models.PositiveIntegerField()
|
|
102
|
+
height = models.PositiveIntegerField()
|
|
103
|
+
format = models.CharField(max_length=10)
|
|
104
|
+
file = models.FileField(upload_to='media_engine/panoramas/')
|
|
105
|
+
file_size = models.BigIntegerField(default=0)
|
|
106
|
+
quality = models.PositiveSmallIntegerField(default=0)
|
|
107
|
+
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
|
|
108
|
+
last_error = models.TextField(blank=True)
|
|
109
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
110
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
111
|
+
|
|
112
|
+
class Meta:
|
|
113
|
+
constraints = [
|
|
114
|
+
models.UniqueConstraint(
|
|
115
|
+
fields=['asset', 'profile', 'processor_version', 'level', 'face', 'col', 'row', 'format'],
|
|
116
|
+
name='uniq_panorama_tile_version_v2',
|
|
117
|
+
)
|
|
118
|
+
]
|
|
119
|
+
indexes = [
|
|
120
|
+
models.Index(fields=['asset', 'profile', 'level']),
|
|
121
|
+
models.Index(fields=['asset', 'status']),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
def __str__(self):
|
|
125
|
+
return f"{self.asset_id} L{self.level} {self.face or 'eq'} {self.col},{self.row}.{self.format}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class MediaBinding(models.Model):
|
|
129
|
+
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
|
130
|
+
object_id = models.CharField(max_length=128)
|
|
131
|
+
content_object = GenericForeignKey('content_type', 'object_id')
|
|
132
|
+
field_name = models.CharField(max_length=100)
|
|
133
|
+
profile = models.CharField(max_length=50, default='default')
|
|
134
|
+
role = models.CharField(max_length=50, default='content')
|
|
135
|
+
asset = models.ForeignKey(MediaAsset, related_name='bindings', on_delete=models.CASCADE)
|
|
136
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
137
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
138
|
+
|
|
139
|
+
class Meta:
|
|
140
|
+
constraints = [
|
|
141
|
+
models.UniqueConstraint(
|
|
142
|
+
fields=['content_type', 'object_id', 'field_name'],
|
|
143
|
+
name='uniq_media_binding_field',
|
|
144
|
+
)
|
|
145
|
+
]
|
media_engine/node.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dataclasses import dataclass, asdict
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class NodeIdentity:
|
|
7
|
+
node_id: str
|
|
8
|
+
project_slug: str
|
|
9
|
+
tenant_slug: str
|
|
10
|
+
deployment_id: str
|
|
11
|
+
|
|
12
|
+
def as_dict(self):
|
|
13
|
+
return asdict(self)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_node_identity() -> NodeIdentity:
|
|
17
|
+
return NodeIdentity(
|
|
18
|
+
node_id=getattr(settings, 'MEDIA_ENGINE_NODE_ID', 'local-node'),
|
|
19
|
+
project_slug=getattr(settings, 'MEDIA_ENGINE_PROJECT_SLUG', 'local-project'),
|
|
20
|
+
tenant_slug=getattr(settings, 'MEDIA_ENGINE_TENANT_SLUG', 'default'),
|
|
21
|
+
deployment_id=getattr(settings, 'MEDIA_ENGINE_DEPLOYMENT_ID', 'standalone'),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def scoped_owner_ref(owner_ref: str) -> str:
|
|
26
|
+
identity = get_node_identity()
|
|
27
|
+
clean = (owner_ref or '').strip()
|
|
28
|
+
prefix = f'{identity.project_slug}:{identity.tenant_slug}'
|
|
29
|
+
return f'{prefix}:{clean}' if clean else prefix
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ProcessorResult:
|
|
9
|
+
asset_id: str
|
|
10
|
+
processor: str
|
|
11
|
+
status: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MediaProcessor(Protocol):
|
|
15
|
+
name: str
|
|
16
|
+
|
|
17
|
+
def supports(self, asset, profile_name: str) -> bool:
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def process(self, asset, profile_name: str, force: bool = False) -> ProcessorResult:
|
|
21
|
+
...
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..base import ProcessorResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StandardImageProcessor:
|
|
7
|
+
name = 'standard_image'
|
|
8
|
+
|
|
9
|
+
def supports(self, asset, profile_name: str) -> bool:
|
|
10
|
+
return getattr(asset, 'media_kind', 'image') == 'image' and not profile_name.startswith('panorama.')
|
|
11
|
+
|
|
12
|
+
def process(self, asset, profile_name: str, force: bool = False) -> ProcessorResult:
|
|
13
|
+
from ...services import generate_standard_variants
|
|
14
|
+
|
|
15
|
+
generate_standard_variants(asset, profile_name=profile_name, force=force)
|
|
16
|
+
return ProcessorResult(str(asset.pk), self.name, asset.status)
|