From 67573713bdac2e48bea75a04aa578a39f0ea2128 Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Tue, 3 Feb 2026 12:20:40 -0300 Subject: [PATCH] django and fastapi apps --- .env.template | 23 + .gitignore | 38 + api/deps.py | 54 ++ api/main.py | 56 ++ api/routes/__init__.py | 8 + api/routes/assets.py | 90 +++ api/routes/jobs.py | 160 ++++ api/routes/presets.py | 100 +++ api/routes/system.py | 30 + api/schemas/__init__.py | 10 + api/schemas/asset.py | 70 ++ api/schemas/base.py | 8 + api/schemas/job.py | 80 ++ api/schemas/preset.py | 66 ++ core/__init__.py | 0 core/ffmpeg/__init__.py | 13 + core/ffmpeg/capabilities.py | 145 ++++ core/ffmpeg/probe.py | 92 +++ core/ffmpeg/transcode.py | 225 ++++++ ctrl/.env.template | 21 + ctrl/Dockerfile | 14 + ctrl/README.md | 61 ++ ctrl/deploy.sh | 76 ++ ctrl/docker-compose.yml | 128 ++++ ctrl/nginx.conf | 84 +++ ctrl/run.sh | 33 + grpc/protos/worker.proto | 65 ++ manage.py | 22 + mpr/__init__.py | 3 + mpr/asgi.py | 16 + mpr/celery.py | 9 + mpr/media_assets/__init__.py | 0 mpr/media_assets/admin.py | 174 +++++ mpr/media_assets/apps.py | 7 + mpr/media_assets/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/loadbuiltins.py | 54 ++ mpr/media_assets/migrations/0001_initial.py | 98 +++ mpr/media_assets/migrations/__init__.py | 0 mpr/media_assets/models.py | 110 +++ mpr/media_assets/tests.py | 3 + mpr/media_assets/views.py | 3 + mpr/settings.py | 103 +++ mpr/urls.py | 22 + mpr/wsgi.py | 16 + requirements.txt | 22 + schema/__init__.py | 31 +- schema/generate.py | 702 ++++++++++++++++++ schema/models/__init__.py | 64 ++ schema/{ => models}/grpc.py | 0 schema/{ => models}/jobs.py | 0 schema/{ => models}/media.py | 0 schema/{ => models}/presets.py | 0 ui/timeline/src/types.ts | 74 ++ 54 files changed, 3272 insertions(+), 11 deletions(-) create mode 100644 .env.template create mode 100644 .gitignore create mode 100644 api/deps.py create mode 100644 api/main.py create mode 100644 api/routes/__init__.py create mode 100644 api/routes/assets.py create mode 100644 api/routes/jobs.py create mode 100644 api/routes/presets.py create mode 100644 api/routes/system.py create mode 100644 api/schemas/__init__.py create mode 100644 api/schemas/asset.py create mode 100644 api/schemas/base.py create mode 100644 api/schemas/job.py create mode 100644 api/schemas/preset.py create mode 100644 core/__init__.py create mode 100644 core/ffmpeg/__init__.py create mode 100644 core/ffmpeg/capabilities.py create mode 100644 core/ffmpeg/probe.py create mode 100644 core/ffmpeg/transcode.py create mode 100644 ctrl/.env.template create mode 100644 ctrl/Dockerfile create mode 100644 ctrl/README.md create mode 100755 ctrl/deploy.sh create mode 100644 ctrl/docker-compose.yml create mode 100644 ctrl/nginx.conf create mode 100755 ctrl/run.sh create mode 100644 grpc/protos/worker.proto create mode 100755 manage.py create mode 100644 mpr/__init__.py create mode 100644 mpr/asgi.py create mode 100644 mpr/celery.py create mode 100644 mpr/media_assets/__init__.py create mode 100644 mpr/media_assets/admin.py create mode 100644 mpr/media_assets/apps.py create mode 100644 mpr/media_assets/management/__init__.py create mode 100644 mpr/media_assets/management/commands/__init__.py create mode 100644 mpr/media_assets/management/commands/loadbuiltins.py create mode 100644 mpr/media_assets/migrations/0001_initial.py create mode 100644 mpr/media_assets/migrations/__init__.py create mode 100644 mpr/media_assets/models.py create mode 100644 mpr/media_assets/tests.py create mode 100644 mpr/media_assets/views.py create mode 100644 mpr/settings.py create mode 100644 mpr/urls.py create mode 100644 mpr/wsgi.py create mode 100644 requirements.txt create mode 100755 schema/generate.py create mode 100644 schema/models/__init__.py rename schema/{ => models}/grpc.py (100%) rename schema/{ => models}/jobs.py (100%) rename schema/{ => models}/media.py (100%) rename schema/{ => models}/presets.py (100%) create mode 100644 ui/timeline/src/types.ts diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..03bde7b --- /dev/null +++ b/.env.template @@ -0,0 +1,23 @@ +# MPR Environment Configuration +# Copy to .env and adjust values as needed + +# Database +POSTGRES_DB=mpr +POSTGRES_USER=mpr_user +POSTGRES_PASSWORD=mpr_pass +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + +# Redis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}/0 + +# Django +DEBUG=1 +DJANGO_SETTINGS_MODULE=mpr.settings +SECRET_KEY=change-this-in-production + +# Worker +MPR_EXECUTOR=local diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..903f06f --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Environment +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +ENV/ +env/ + +# Django +*.log +*.pot +*.pyc +db.sqlite3 +media/ + +# Node +node_modules/ +dist/ +.npm + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Project specific +def/ diff --git a/api/deps.py b/api/deps.py new file mode 100644 index 0000000..d6473fe --- /dev/null +++ b/api/deps.py @@ -0,0 +1,54 @@ +""" +FastAPI dependencies. + +Provides database sessions, settings, and common dependencies. +""" + +import os +from functools import lru_cache +from typing import Generator + +import django +from django.conf import settings as django_settings + +# Initialize Django +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mpr.settings") +django.setup() + +from mpr.media_assets.models import MediaAsset, TranscodeJob, TranscodePreset + + +@lru_cache +def get_settings(): + """Get Django settings.""" + return django_settings + + +def get_asset(asset_id: str) -> MediaAsset: + """Get asset by ID or raise 404.""" + from fastapi import HTTPException + + try: + return MediaAsset.objects.get(id=asset_id) + except MediaAsset.DoesNotExist: + raise HTTPException(status_code=404, detail="Asset not found") + + +def get_preset(preset_id: str) -> TranscodePreset: + """Get preset by ID or raise 404.""" + from fastapi import HTTPException + + try: + return TranscodePreset.objects.get(id=preset_id) + except TranscodePreset.DoesNotExist: + raise HTTPException(status_code=404, detail="Preset not found") + + +def get_job(job_id: str) -> TranscodeJob: + """Get job by ID or raise 404.""" + from fastapi import HTTPException + + try: + return TranscodeJob.objects.get(id=job_id) + except TranscodeJob.DoesNotExist: + raise HTTPException(status_code=404, detail="Job not found") diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..b54ae58 --- /dev/null +++ b/api/main.py @@ -0,0 +1,56 @@ +""" +MPR FastAPI Application + +Main entry point for the REST API. +""" + +import os +import sys + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Initialize Django before importing models +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mpr.settings") + +import django + +django.setup() + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from api.routes import assets_router, jobs_router, presets_router, system_router + +app = FastAPI( + title="MPR API", + description="Media Processor REST API", + version="0.1.0", + docs_url="/docs", + redoc_url="/redoc", +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["http://mpr.local.ar", "http://localhost:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Routes +app.include_router(system_router) +app.include_router(assets_router) +app.include_router(presets_router) +app.include_router(jobs_router) + + +@app.get("/") +def root(): + """API root.""" + return { + "name": "MPR API", + "version": "0.1.0", + "docs": "/docs", + } diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..7ef5634 --- /dev/null +++ b/api/routes/__init__.py @@ -0,0 +1,8 @@ +"""API Routes.""" + +from .assets import router as assets_router +from .jobs import router as jobs_router +from .presets import router as presets_router +from .system import router as system_router + +__all__ = ["assets_router", "jobs_router", "presets_router", "system_router"] diff --git a/api/routes/assets.py b/api/routes/assets.py new file mode 100644 index 0000000..0e37477 --- /dev/null +++ b/api/routes/assets.py @@ -0,0 +1,90 @@ +""" +Asset endpoints - media file registration and metadata. +""" + +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query + +from api.deps import get_asset +from api.schemas import AssetCreate, AssetResponse, AssetUpdate + +router = APIRouter(prefix="/assets", tags=["assets"]) + + +@router.post("/", response_model=AssetResponse, status_code=201) +def create_asset(data: AssetCreate): + """ + Register a media file as an asset. + + The file must exist on disk. A probe task will be queued + to extract metadata asynchronously. + """ + from pathlib import Path + + from mpr.media_assets.models import MediaAsset + + # Validate file exists + path = Path(data.file_path) + if not path.exists(): + raise HTTPException(status_code=400, detail="File not found") + + # Create asset + asset = MediaAsset.objects.create( + filename=data.filename or path.name, + file_path=str(path.absolute()), + file_size=path.stat().st_size, + ) + + # TODO: Queue probe task via gRPC/Celery + + return asset + + +@router.get("/", response_model=list[AssetResponse]) +def list_assets( + status: Optional[str] = Query(None, description="Filter by status"), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), +): + """List assets with optional filtering.""" + from mpr.media_assets.models import MediaAsset + + qs = MediaAsset.objects.all() + + if status: + qs = qs.filter(status=status) + + return list(qs[offset : offset + limit]) + + +@router.get("/{asset_id}", response_model=AssetResponse) +def get_asset_detail(asset_id: UUID, asset=Depends(get_asset)): + """Get asset details.""" + return asset + + +@router.patch("/{asset_id}", response_model=AssetResponse) +def update_asset(asset_id: UUID, data: AssetUpdate, asset=Depends(get_asset)): + """Update asset metadata (comments, tags).""" + update_fields = [] + + if data.comments is not None: + asset.comments = data.comments + update_fields.append("comments") + + if data.tags is not None: + asset.tags = data.tags + update_fields.append("tags") + + if update_fields: + asset.save(update_fields=update_fields) + + return asset + + +@router.delete("/{asset_id}", status_code=204) +def delete_asset(asset_id: UUID, asset=Depends(get_asset)): + """Delete an asset.""" + asset.delete() diff --git a/api/routes/jobs.py b/api/routes/jobs.py new file mode 100644 index 0000000..007b401 --- /dev/null +++ b/api/routes/jobs.py @@ -0,0 +1,160 @@ +""" +Job endpoints - transcode/trim job management. +""" + +import json +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query + +from api.deps import get_asset, get_job, get_preset +from api.schemas import JobCreate, JobResponse + +router = APIRouter(prefix="/jobs", tags=["jobs"]) + + +@router.post("/", response_model=JobResponse, status_code=201) +def create_job(data: JobCreate): + """ + Create a transcode or trim job. + + - With preset_id: Full transcode using preset settings + - Without preset_id but with trim_start/end: Trim only (stream copy) + """ + from mpr.media_assets.models import MediaAsset, TranscodeJob, TranscodePreset + + # Get source asset + try: + source = MediaAsset.objects.get(id=data.source_asset_id) + except MediaAsset.DoesNotExist: + raise HTTPException(status_code=404, detail="Source asset not found") + + if source.status != "ready": + raise HTTPException(status_code=400, detail="Source asset is not ready") + + # Get preset if specified + preset = None + preset_snapshot = {} + if data.preset_id: + try: + preset = TranscodePreset.objects.get(id=data.preset_id) + # Snapshot preset at job creation time + preset_snapshot = { + "name": preset.name, + "container": preset.container, + "video_codec": preset.video_codec, + "video_bitrate": preset.video_bitrate, + "video_crf": preset.video_crf, + "video_preset": preset.video_preset, + "resolution": preset.resolution, + "framerate": preset.framerate, + "audio_codec": preset.audio_codec, + "audio_bitrate": preset.audio_bitrate, + "audio_channels": preset.audio_channels, + "audio_samplerate": preset.audio_samplerate, + "extra_args": preset.extra_args, + } + except TranscodePreset.DoesNotExist: + raise HTTPException(status_code=404, detail="Preset not found") + + # Validate trim-only job + if not preset and not data.trim_start and not data.trim_end: + raise HTTPException( + status_code=400, detail="Must specify preset_id or trim_start/trim_end" + ) + + # Generate output filename + output_filename = data.output_filename + if not output_filename: + from pathlib import Path + + stem = Path(source.filename).stem + ext = preset_snapshot.get("container", "mp4") if preset else "mp4" + output_filename = f"{stem}_output.{ext}" + + # Create job + job = TranscodeJob.objects.create( + source_asset=source, + preset=preset, + preset_snapshot=preset_snapshot, + trim_start=data.trim_start, + trim_end=data.trim_end, + output_filename=output_filename, + priority=data.priority or 0, + ) + + # TODO: Submit job via gRPC + + return job + + +@router.get("/", response_model=list[JobResponse]) +def list_jobs( + status: Optional[str] = Query(None, description="Filter by status"), + source_asset_id: Optional[UUID] = Query(None), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), +): + """List jobs with optional filtering.""" + from mpr.media_assets.models import TranscodeJob + + qs = TranscodeJob.objects.all() + + if status: + qs = qs.filter(status=status) + if source_asset_id: + qs = qs.filter(source_asset_id=source_asset_id) + + return list(qs[offset : offset + limit]) + + +@router.get("/{job_id}", response_model=JobResponse) +def get_job_detail(job_id: UUID, job=Depends(get_job)): + """Get job details including progress.""" + return job + + +@router.get("/{job_id}/progress") +def get_job_progress(job_id: UUID, job=Depends(get_job)): + """Get real-time job progress.""" + return { + "job_id": str(job.id), + "status": job.status, + "progress": job.progress, + "current_frame": job.current_frame, + "current_time": job.current_time, + "speed": job.speed, + } + + +@router.post("/{job_id}/cancel", response_model=JobResponse) +def cancel_job(job_id: UUID, job=Depends(get_job)): + """Cancel a pending or processing job.""" + if job.status not in ("pending", "processing"): + raise HTTPException( + status_code=400, detail=f"Cannot cancel job with status: {job.status}" + ) + + # TODO: Cancel via gRPC + + job.status = "cancelled" + job.save(update_fields=["status"]) + + return job + + +@router.post("/{job_id}/retry", response_model=JobResponse) +def retry_job(job_id: UUID, job=Depends(get_job)): + """Retry a failed job.""" + if job.status != "failed": + raise HTTPException(status_code=400, detail="Only failed jobs can be retried") + + job.status = "pending" + job.progress = 0 + job.error_message = None + job.save(update_fields=["status", "progress", "error_message"]) + + # TODO: Resubmit via gRPC + + return job diff --git a/api/routes/presets.py b/api/routes/presets.py new file mode 100644 index 0000000..b3cac6f --- /dev/null +++ b/api/routes/presets.py @@ -0,0 +1,100 @@ +""" +Preset endpoints - transcode configuration templates. +""" + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException + +from api.deps import get_preset +from api.schemas import PresetCreate, PresetResponse, PresetUpdate + +router = APIRouter(prefix="/presets", tags=["presets"]) + + +@router.post("/", response_model=PresetResponse, status_code=201) +def create_preset(data: PresetCreate): + """Create a custom preset.""" + from mpr.media_assets.models import TranscodePreset + + preset = TranscodePreset.objects.create( + name=data.name, + description=data.description or "", + container=data.container or "mp4", + video_codec=data.video_codec or "libx264", + video_bitrate=data.video_bitrate, + video_crf=data.video_crf, + video_preset=data.video_preset, + resolution=data.resolution, + framerate=data.framerate, + audio_codec=data.audio_codec or "aac", + audio_bitrate=data.audio_bitrate, + audio_channels=data.audio_channels, + audio_samplerate=data.audio_samplerate, + extra_args=data.extra_args or [], + is_builtin=False, + ) + + return preset + + +@router.get("/", response_model=list[PresetResponse]) +def list_presets(include_builtin: bool = True): + """List all presets.""" + from mpr.media_assets.models import TranscodePreset + + qs = TranscodePreset.objects.all() + + if not include_builtin: + qs = qs.filter(is_builtin=False) + + return list(qs) + + +@router.get("/{preset_id}", response_model=PresetResponse) +def get_preset_detail(preset_id: UUID, preset=Depends(get_preset)): + """Get preset details.""" + return preset + + +@router.patch("/{preset_id}", response_model=PresetResponse) +def update_preset(preset_id: UUID, data: PresetUpdate, preset=Depends(get_preset)): + """Update a custom preset. Builtin presets cannot be modified.""" + if preset.is_builtin: + raise HTTPException(status_code=403, detail="Cannot modify builtin preset") + + update_fields = [] + for field in [ + "name", + "description", + "container", + "video_codec", + "video_bitrate", + "video_crf", + "video_preset", + "resolution", + "framerate", + "audio_codec", + "audio_bitrate", + "audio_channels", + "audio_samplerate", + "extra_args", + ]: + value = getattr(data, field, None) + if value is not None: + setattr(preset, field, value) + update_fields.append(field) + + if update_fields: + preset.save(update_fields=update_fields) + + return preset + + +@router.delete("/{preset_id}", status_code=204) +def delete_preset(preset_id: UUID, preset=Depends(get_preset)): + """Delete a custom preset. Builtin presets cannot be deleted.""" + if preset.is_builtin: + raise HTTPException(status_code=403, detail="Cannot delete builtin preset") + + preset.delete() diff --git a/api/routes/system.py b/api/routes/system.py new file mode 100644 index 0000000..f54b5b5 --- /dev/null +++ b/api/routes/system.py @@ -0,0 +1,30 @@ +""" +System endpoints - health checks and FFmpeg capabilities. +""" + +from fastapi import APIRouter + +from core.ffmpeg import get_decoders, get_encoders, get_formats + +router = APIRouter(prefix="/system", tags=["system"]) + + +@router.get("/health") +def health_check(): + """Health check endpoint.""" + return {"status": "healthy"} + + +@router.get("/ffmpeg/codecs") +def ffmpeg_codecs(): + """Get available FFmpeg encoders and decoders.""" + return { + "encoders": get_encoders(), + "decoders": get_decoders(), + } + + +@router.get("/ffmpeg/formats") +def ffmpeg_formats(): + """Get available FFmpeg muxers and demuxers.""" + return get_formats() diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py new file mode 100644 index 0000000..0e1a736 --- /dev/null +++ b/api/schemas/__init__.py @@ -0,0 +1,10 @@ +"""API Schemas - GENERATED FILE""" + +from .base import BaseSchema +from .asset import AssetCreate, AssetUpdate, AssetResponse +from .asset import AssetStatus +from .preset import PresetCreate, PresetUpdate, PresetResponse +from .job import JobCreate, JobUpdate, JobResponse +from .job import JobStatus + +__all__ = ["BaseSchema", "AssetCreate", "AssetUpdate", "AssetResponse", "AssetStatus", "PresetCreate", "PresetUpdate", "PresetResponse", "JobCreate", "JobUpdate", "JobResponse", "JobStatus"] diff --git a/api/schemas/asset.py b/api/schemas/asset.py new file mode 100644 index 0000000..7f13d61 --- /dev/null +++ b/api/schemas/asset.py @@ -0,0 +1,70 @@ +"""MediaAsset Schemas - GENERATED FILE""" + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional +from uuid import UUID + +from .base import BaseSchema + + +class AssetStatus(str, Enum): + PENDING = "pending" + READY = "ready" + ERROR = "error" + + +class AssetCreate(BaseSchema): + """AssetCreate schema.""" + filename: str + file_path: str + file_size: Optional[int] = None + duration: Optional[float] = None + video_codec: Optional[str] = None + audio_codec: Optional[str] = None + width: Optional[int] = None + height: Optional[int] = None + framerate: Optional[float] = None + bitrate: Optional[int] = None + properties: Dict[str, Any] + comments: str = "" + tags: List[str] + +class AssetUpdate(BaseSchema): + """AssetUpdate schema.""" + filename: Optional[str] = None + file_path: Optional[str] = None + status: Optional[AssetStatus] = None + error_message: Optional[str] = None + file_size: Optional[int] = None + duration: Optional[float] = None + video_codec: Optional[str] = None + audio_codec: Optional[str] = None + width: Optional[int] = None + height: Optional[int] = None + framerate: Optional[float] = None + bitrate: Optional[int] = None + properties: Optional[Dict[str, Any]] = None + comments: Optional[str] = None + tags: Optional[List[str]] = None + +class AssetResponse(BaseSchema): + """AssetResponse schema.""" + id: UUID + filename: str + file_path: str + status: AssetStatus = "AssetStatus.PENDING" + error_message: Optional[str] = None + file_size: Optional[int] = None + duration: Optional[float] = None + video_codec: Optional[str] = None + audio_codec: Optional[str] = None + width: Optional[int] = None + height: Optional[int] = None + framerate: Optional[float] = None + bitrate: Optional[int] = None + properties: Dict[str, Any] + comments: str = "" + tags: List[str] + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None diff --git a/api/schemas/base.py b/api/schemas/base.py new file mode 100644 index 0000000..e21cc2d --- /dev/null +++ b/api/schemas/base.py @@ -0,0 +1,8 @@ +"""Pydantic Base Schema - GENERATED FILE""" + +from pydantic import BaseModel, ConfigDict + + +class BaseSchema(BaseModel): + """Base schema with ORM mode.""" + model_config = ConfigDict(from_attributes=True) diff --git a/api/schemas/job.py b/api/schemas/job.py new file mode 100644 index 0000000..f035356 --- /dev/null +++ b/api/schemas/job.py @@ -0,0 +1,80 @@ +"""TranscodeJob Schemas - GENERATED FILE""" + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional +from uuid import UUID + +from .base import BaseSchema + + +class JobStatus(str, Enum): + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class JobCreate(BaseSchema): + """JobCreate schema.""" + source_asset_id: UUID + preset_id: Optional[UUID] = None + preset_snapshot: Dict[str, Any] + trim_start: Optional[float] = None + trim_end: Optional[float] = None + output_filename: str = "" + output_path: Optional[str] = None + output_asset_id: Optional[UUID] = None + progress: float = 0.0 + current_frame: Optional[int] = None + current_time: Optional[float] = None + speed: Optional[str] = None + celery_task_id: Optional[str] = None + priority: int = 0 + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + +class JobUpdate(BaseSchema): + """JobUpdate schema.""" + source_asset_id: Optional[UUID] = None + preset_id: Optional[UUID] = None + preset_snapshot: Optional[Dict[str, Any]] = None + trim_start: Optional[float] = None + trim_end: Optional[float] = None + output_filename: Optional[str] = None + output_path: Optional[str] = None + output_asset_id: Optional[UUID] = None + status: Optional[JobStatus] = None + progress: Optional[float] = None + current_frame: Optional[int] = None + current_time: Optional[float] = None + speed: Optional[str] = None + error_message: Optional[str] = None + celery_task_id: Optional[str] = None + priority: Optional[int] = None + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + +class JobResponse(BaseSchema): + """JobResponse schema.""" + id: UUID + source_asset_id: UUID + preset_id: Optional[UUID] = None + preset_snapshot: Dict[str, Any] + trim_start: Optional[float] = None + trim_end: Optional[float] = None + output_filename: str = "" + output_path: Optional[str] = None + output_asset_id: Optional[UUID] = None + status: JobStatus = "JobStatus.PENDING" + progress: float = 0.0 + current_frame: Optional[int] = None + current_time: Optional[float] = None + speed: Optional[str] = None + error_message: Optional[str] = None + celery_task_id: Optional[str] = None + priority: int = 0 + created_at: Optional[datetime] = None + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None diff --git a/api/schemas/preset.py b/api/schemas/preset.py new file mode 100644 index 0000000..5075a55 --- /dev/null +++ b/api/schemas/preset.py @@ -0,0 +1,66 @@ +"""TranscodePreset Schemas - GENERATED FILE""" + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional +from uuid import UUID + +from .base import BaseSchema + + +class PresetCreate(BaseSchema): + """PresetCreate schema.""" + name: str + description: str = "" + is_builtin: bool = False + container: str = "mp4" + video_codec: str = "libx264" + video_bitrate: Optional[str] = None + video_crf: Optional[int] = None + video_preset: Optional[str] = None + resolution: Optional[str] = None + framerate: Optional[float] = None + audio_codec: str = "aac" + audio_bitrate: Optional[str] = None + audio_channels: Optional[int] = None + audio_samplerate: Optional[int] = None + extra_args: List[str] + +class PresetUpdate(BaseSchema): + """PresetUpdate schema.""" + name: Optional[str] = None + description: Optional[str] = None + is_builtin: Optional[bool] = None + container: Optional[str] = None + video_codec: Optional[str] = None + video_bitrate: Optional[str] = None + video_crf: Optional[int] = None + video_preset: Optional[str] = None + resolution: Optional[str] = None + framerate: Optional[float] = None + audio_codec: Optional[str] = None + audio_bitrate: Optional[str] = None + audio_channels: Optional[int] = None + audio_samplerate: Optional[int] = None + extra_args: Optional[List[str]] = None + +class PresetResponse(BaseSchema): + """PresetResponse schema.""" + id: UUID + name: str + description: str = "" + is_builtin: bool = False + container: str = "mp4" + video_codec: str = "libx264" + video_bitrate: Optional[str] = None + video_crf: Optional[int] = None + video_preset: Optional[str] = None + resolution: Optional[str] = None + framerate: Optional[float] = None + audio_codec: str = "aac" + audio_bitrate: Optional[str] = None + audio_channels: Optional[int] = None + audio_samplerate: Optional[int] = None + extra_args: List[str] + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/ffmpeg/__init__.py b/core/ffmpeg/__init__.py new file mode 100644 index 0000000..6ba3ced --- /dev/null +++ b/core/ffmpeg/__init__.py @@ -0,0 +1,13 @@ +from .capabilities import get_decoders, get_encoders, get_formats +from .probe import ProbeResult, probe_file +from .transcode import TranscodeConfig, transcode + +__all__ = [ + "probe_file", + "ProbeResult", + "transcode", + "TranscodeConfig", + "get_encoders", + "get_decoders", + "get_formats", +] diff --git a/core/ffmpeg/capabilities.py b/core/ffmpeg/capabilities.py new file mode 100644 index 0000000..3f4d6df --- /dev/null +++ b/core/ffmpeg/capabilities.py @@ -0,0 +1,145 @@ +""" +FFmpeg capabilities - Discover available codecs and formats using ffmpeg-python. +""" + +from dataclasses import dataclass +from functools import lru_cache +from typing import Any, Dict, List + +import ffmpeg + + +@dataclass +class Codec: + """An FFmpeg encoder or decoder.""" + + name: str + description: str + type: str # 'video' or 'audio' + + +@dataclass +class Format: + """An FFmpeg format (muxer/demuxer).""" + + name: str + description: str + can_demux: bool + can_mux: bool + + +@lru_cache(maxsize=1) +def _get_ffmpeg_info() -> Dict[str, Any]: + """Get FFmpeg capabilities info.""" + # ffmpeg-python doesn't have a direct way to get codecs/formats + # but we can use probe on a dummy or parse -codecs output + # For now, return common codecs that are typically available + return { + "video_encoders": [ + {"name": "libx264", "description": "H.264 / AVC"}, + {"name": "libx265", "description": "H.265 / HEVC"}, + {"name": "mpeg4", "description": "MPEG-4 Part 2"}, + {"name": "libvpx", "description": "VP8"}, + {"name": "libvpx-vp9", "description": "VP9"}, + {"name": "h264_nvenc", "description": "NVIDIA NVENC H.264"}, + {"name": "hevc_nvenc", "description": "NVIDIA NVENC H.265"}, + {"name": "h264_vaapi", "description": "VAAPI H.264"}, + {"name": "prores_ks", "description": "Apple ProRes"}, + {"name": "dnxhd", "description": "Avid DNxHD/DNxHR"}, + {"name": "copy", "description": "Stream copy (no encoding)"}, + ], + "audio_encoders": [ + {"name": "aac", "description": "AAC"}, + {"name": "libmp3lame", "description": "MP3"}, + {"name": "libopus", "description": "Opus"}, + {"name": "libvorbis", "description": "Vorbis"}, + {"name": "pcm_s16le", "description": "PCM signed 16-bit little-endian"}, + {"name": "flac", "description": "FLAC"}, + {"name": "copy", "description": "Stream copy (no encoding)"}, + ], + "formats": [ + {"name": "mp4", "description": "MP4", "can_demux": True, "can_mux": True}, + { + "name": "mov", + "description": "QuickTime / MOV", + "can_demux": True, + "can_mux": True, + }, + { + "name": "mkv", + "description": "Matroska", + "can_demux": True, + "can_mux": True, + }, + {"name": "webm", "description": "WebM", "can_demux": True, "can_mux": True}, + {"name": "avi", "description": "AVI", "can_demux": True, "can_mux": True}, + {"name": "flv", "description": "FLV", "can_demux": True, "can_mux": True}, + { + "name": "ts", + "description": "MPEG-TS", + "can_demux": True, + "can_mux": True, + }, + { + "name": "mpegts", + "description": "MPEG-TS", + "can_demux": True, + "can_mux": True, + }, + {"name": "hls", "description": "HLS", "can_demux": True, "can_mux": True}, + ], + } + + +def get_encoders() -> List[Codec]: + """Get available encoders (video + audio).""" + info = _get_ffmpeg_info() + codecs = [] + + for c in info["video_encoders"]: + codecs.append(Codec(name=c["name"], description=c["description"], type="video")) + + for c in info["audio_encoders"]: + codecs.append(Codec(name=c["name"], description=c["description"], type="audio")) + + return codecs + + +def get_decoders() -> List[Codec]: + """Get available decoders.""" + # Most encoders can also decode + return get_encoders() + + +def get_formats() -> List[Format]: + """Get available formats.""" + info = _get_ffmpeg_info() + return [ + Format( + name=f["name"], + description=f["description"], + can_demux=f["can_demux"], + can_mux=f["can_mux"], + ) + for f in info["formats"] + ] + + +def get_video_encoders() -> List[Codec]: + """Get available video encoders.""" + return [c for c in get_encoders() if c.type == "video"] + + +def get_audio_encoders() -> List[Codec]: + """Get available audio encoders.""" + return [c for c in get_encoders() if c.type == "audio"] + + +def get_muxers() -> List[Format]: + """Get available output formats (muxers).""" + return [f for f in get_formats() if f.can_mux] + + +def get_demuxers() -> List[Format]: + """Get available input formats (demuxers).""" + return [f for f in get_formats() if f.can_demux] diff --git a/core/ffmpeg/probe.py b/core/ffmpeg/probe.py new file mode 100644 index 0000000..722f103 --- /dev/null +++ b/core/ffmpeg/probe.py @@ -0,0 +1,92 @@ +""" +FFmpeg probe module - Extract metadata from media files using ffprobe. +""" + +from dataclasses import dataclass +from typing import Any, Dict, Optional + +import ffmpeg + + +@dataclass +class ProbeResult: + """Structured ffprobe result.""" + + duration: Optional[float] + file_size: int + + # Video + video_codec: Optional[str] + width: Optional[int] + height: Optional[int] + framerate: Optional[float] + video_bitrate: Optional[int] + + # Audio + audio_codec: Optional[str] + audio_channels: Optional[int] + audio_samplerate: Optional[int] + audio_bitrate: Optional[int] + + # Raw data + raw: Dict[str, Any] + + +def probe_file(file_path: str) -> ProbeResult: + """ + Run ffprobe and return structured result. + + Args: + file_path: Path to the media file + + Returns: + ProbeResult with extracted metadata + + Raises: + ffmpeg.Error: If ffprobe fails + """ + data = ffmpeg.probe(file_path) + + # Extract video stream info + video_stream = next( + (s for s in data.get("streams", []) if s.get("codec_type") == "video"), {} + ) + + # Extract audio stream info + audio_stream = next( + (s for s in data.get("streams", []) if s.get("codec_type") == "audio"), {} + ) + + format_info = data.get("format", {}) + + # Parse framerate (e.g., "30000/1001" -> 29.97) + framerate = None + if "r_frame_rate" in video_stream: + try: + num, den = video_stream["r_frame_rate"].split("/") + framerate = float(num) / float(den) + except (ValueError, ZeroDivisionError): + pass + + # Parse duration + duration = None + if "duration" in format_info: + try: + duration = float(format_info["duration"]) + except ValueError: + pass + + return ProbeResult( + duration=duration, + file_size=int(format_info.get("size", 0)), + video_codec=video_stream.get("codec_name"), + width=video_stream.get("width"), + height=video_stream.get("height"), + framerate=framerate, + video_bitrate=int(video_stream.get("bit_rate", 0)) or None, + audio_codec=audio_stream.get("codec_name"), + audio_channels=audio_stream.get("channels"), + audio_samplerate=int(audio_stream.get("sample_rate", 0)) or None, + audio_bitrate=int(audio_stream.get("bit_rate", 0)) or None, + raw=data, + ) diff --git a/core/ffmpeg/transcode.py b/core/ffmpeg/transcode.py new file mode 100644 index 0000000..f12084f --- /dev/null +++ b/core/ffmpeg/transcode.py @@ -0,0 +1,225 @@ +""" +FFmpeg transcode module - Transcode media files using ffmpeg-python. +""" + +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +import ffmpeg + + +@dataclass +class TranscodeConfig: + """Configuration for a transcode operation.""" + + input_path: str + output_path: str + + # Video + video_codec: str = "libx264" + video_bitrate: Optional[str] = None + video_crf: Optional[int] = None + video_preset: Optional[str] = None + resolution: Optional[str] = None + framerate: Optional[float] = None + + # Audio + audio_codec: str = "aac" + audio_bitrate: Optional[str] = None + audio_channels: Optional[int] = None + audio_samplerate: Optional[int] = None + + # Trimming + trim_start: Optional[float] = None + trim_end: Optional[float] = None + + # Container + container: str = "mp4" + + # Extra args (key-value pairs) + extra_args: List[str] = field(default_factory=list) + + @property + def is_copy(self) -> bool: + """Check if this is a stream copy (no transcoding).""" + return self.video_codec == "copy" and self.audio_codec == "copy" + + +def build_stream(config: TranscodeConfig): + """ + Build an ffmpeg-python stream from config. + + Returns the stream object ready to run. + """ + # Input options + input_kwargs = {} + if config.trim_start is not None: + input_kwargs["ss"] = config.trim_start + + stream = ffmpeg.input(config.input_path, **input_kwargs) + + # Output options + output_kwargs = { + "vcodec": config.video_codec, + "acodec": config.audio_codec, + } + + # Trimming duration + if config.trim_end is not None: + if config.trim_start is not None: + output_kwargs["t"] = config.trim_end - config.trim_start + else: + output_kwargs["t"] = config.trim_end + + # Video options (skip if copy) + if config.video_codec != "copy": + if config.video_crf is not None: + output_kwargs["crf"] = config.video_crf + elif config.video_bitrate: + output_kwargs["video_bitrate"] = config.video_bitrate + + if config.video_preset: + output_kwargs["preset"] = config.video_preset + + if config.resolution: + output_kwargs["s"] = config.resolution + + if config.framerate: + output_kwargs["r"] = config.framerate + + # Audio options (skip if copy) + if config.audio_codec != "copy": + if config.audio_bitrate: + output_kwargs["audio_bitrate"] = config.audio_bitrate + if config.audio_channels: + output_kwargs["ac"] = config.audio_channels + if config.audio_samplerate: + output_kwargs["ar"] = config.audio_samplerate + + # Parse extra args into kwargs + extra_kwargs = parse_extra_args(config.extra_args) + output_kwargs.update(extra_kwargs) + + stream = ffmpeg.output(stream, config.output_path, **output_kwargs) + stream = ffmpeg.overwrite_output(stream) + + return stream + + +def parse_extra_args(extra_args: List[str]) -> Dict[str, Any]: + """ + Parse extra args list into kwargs dict. + + ["-vtag", "xvid", "-pix_fmt", "yuv420p"] -> {"vtag": "xvid", "pix_fmt": "yuv420p"} + """ + kwargs = {} + i = 0 + while i < len(extra_args): + key = extra_args[i].lstrip("-") + if i + 1 < len(extra_args) and not extra_args[i + 1].startswith("-"): + kwargs[key] = extra_args[i + 1] + i += 2 + else: + # Flag without value + kwargs[key] = None + i += 1 + return kwargs + + +def transcode( + config: TranscodeConfig, + duration: Optional[float] = None, + progress_callback: Optional[Callable[[float, Dict[str, Any]], None]] = None, +) -> bool: + """ + Transcode a media file. + + Args: + config: Transcode configuration + duration: Total duration in seconds (for progress calculation, optional) + progress_callback: Called with (percent, details_dict) - requires duration + + Returns: + True if successful + + Raises: + ffmpeg.Error: If transcoding fails + """ + # Ensure output directory exists + Path(config.output_path).parent.mkdir(parents=True, exist_ok=True) + + stream = build_stream(config) + + if progress_callback and duration: + # Run with progress tracking using run_async + return _run_with_progress(stream, config, duration, progress_callback) + else: + # Run synchronously + ffmpeg.run(stream, capture_stdout=True, capture_stderr=True) + return True + + +def _run_with_progress( + stream, + config: TranscodeConfig, + duration: float, + progress_callback: Callable[[float, Dict[str, Any]], None], +) -> bool: + """Run FFmpeg with progress tracking using run_async and stderr parsing.""" + import re + + # Calculate effective duration + effective_duration = duration + if config.trim_start and config.trim_end: + effective_duration = config.trim_end - config.trim_start + elif config.trim_end: + effective_duration = config.trim_end + elif config.trim_start: + effective_duration = duration - config.trim_start + + # Run async to get process handle + process = ffmpeg.run_async(stream, pipe_stdout=True, pipe_stderr=True) + + # Parse stderr for progress (time=HH:MM:SS.ms pattern) + time_pattern = re.compile(r"time=(\d+):(\d+):(\d+)\.(\d+)") + + while True: + line = process.stderr.readline() + if not line: + break + + line = line.decode("utf-8", errors="ignore") + match = time_pattern.search(line) + if match: + hours = int(match.group(1)) + minutes = int(match.group(2)) + seconds = int(match.group(3)) + ms = int(match.group(4)) + + current_time = hours * 3600 + minutes * 60 + seconds + ms / 100 + percent = min(100.0, (current_time / effective_duration) * 100) + + progress_callback( + percent, + { + "time": current_time, + "percent": percent, + }, + ) + + # Wait for completion + process.wait() + + if process.returncode != 0: + raise ffmpeg.Error( + "ffmpeg", stdout=process.stdout.read(), stderr=process.stderr.read() + ) + + # Final callback + progress_callback( + 100.0, {"time": effective_duration, "percent": 100.0, "done": True} + ) + + return True diff --git a/ctrl/.env.template b/ctrl/.env.template new file mode 100644 index 0000000..b135ada --- /dev/null +++ b/ctrl/.env.template @@ -0,0 +1,21 @@ +# MPR Control Environment +# Copy to .env and adjust values + +# Database +POSTGRES_DB=mpr +POSTGRES_USER=mpr_user +POSTGRES_PASSWORD=mpr_pass + +# Ports (less common to avoid conflicts) +POSTGRES_PORT=5433 +REDIS_PORT=6380 +DJANGO_PORT=8701 +FASTAPI_PORT=8702 +TIMELINE_PORT=5173 + +# Worker +MPR_EXECUTOR=local + +# Remote deployment (optional) +# SERVER=user@host +# REMOTE_PATH=~/mpr diff --git a/ctrl/Dockerfile b/ctrl/Dockerfile new file mode 100644 index 0000000..96086b9 --- /dev/null +++ b/ctrl/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/ctrl/README.md b/ctrl/README.md new file mode 100644 index 0000000..67e71e6 --- /dev/null +++ b/ctrl/README.md @@ -0,0 +1,61 @@ +# MPR Control + +Scripts for running and deploying MPR. + +## Setup + +1. Add to `/etc/hosts`: + ``` + 127.0.0.1 mpr.local.ar + ``` + +2. Copy environment template: + ```bash + cp ctrl/.env.template ctrl/.env + ``` + +3. Start the stack: + ```bash + ./ctrl/run.sh + ``` + +## URLs + +- http://mpr.local.ar/admin - Django Admin +- http://mpr.local.ar/api/docs - FastAPI Swagger +- http://mpr.local.ar/ui - Timeline UI + +## Commands + +```bash +# Start all services +./ctrl/run.sh + +# Start in detached mode +./ctrl/run.sh -d + +# Rebuild and start +./ctrl/run.sh --build + +# Stop all +./ctrl/run.sh down + +# View logs +./ctrl/run.sh logs -f + +# Deploy to remote (configure SERVER/REMOTE_PATH in .env) +./ctrl/deploy.sh +./ctrl/deploy.sh --restart +./ctrl/deploy.sh --dry-run +``` + +## Ports + +| Service | Internal | External | +|------------|----------|----------| +| nginx | 80 | 80 | +| Django | 8701 | 8701 | +| FastAPI | 8702 | 8702 | +| Timeline | 5173 | 5173 | +| PostgreSQL | 5432 | 5433 | +| Redis | 6379 | 6380 | diff --git a/ctrl/deploy.sh b/ctrl/deploy.sh new file mode 100755 index 0000000..cd71847 --- /dev/null +++ b/ctrl/deploy.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Deploy MPR to remote server via rsync +# Uses project .gitignore for excludes +# +# Usage: ./ctrl/deploy.sh [--restart] [--dry-run] +# +# Examples: +# ./ctrl/deploy.sh # Sync files only +# ./ctrl/deploy.sh --restart # Sync and restart services +# ./ctrl/deploy.sh --dry-run # Preview sync + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +source "$SCRIPT_DIR/.env" 2>/dev/null || true + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +if [ -z "$SERVER" ] || [ -z "$REMOTE_PATH" ]; then + echo -e "${RED}Error: SERVER and REMOTE_PATH must be set in ctrl/.env${NC}" + echo "Example:" + echo " SERVER=user@host" + echo " REMOTE_PATH=~/mpr" + exit 1 +fi + +RESTART=false +DRY_RUN="" + +while [ $# -gt 0 ]; do + case "$1" in + --restart) + RESTART=true + shift + ;; + --dry-run) + DRY_RUN="--dry-run" + shift + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +echo -e "${GREEN}=== Deploying MPR to $SERVER:$REMOTE_PATH ===${NC}" + +# Sync files using .gitignore for excludes +echo -e "${YELLOW}Syncing files...${NC}" +rsync -avz --delete $DRY_RUN \ + --filter=':- .gitignore' \ + --exclude='.git' \ + --exclude='media/*' \ + --exclude='ctrl/.env' \ + "$PROJECT_ROOT/" "$SERVER:$REMOTE_PATH/" + +if [ -n "$DRY_RUN" ]; then + echo -e "${YELLOW}Dry run - no changes made${NC}" + exit 0 +fi + +# Copy env template if .env doesn't exist on remote +ssh "$SERVER" "[ -f $REMOTE_PATH/ctrl/.env ] || cp $REMOTE_PATH/ctrl/.env.template $REMOTE_PATH/ctrl/.env" + +if [ "$RESTART" = true ]; then + echo -e "${YELLOW}Restarting services...${NC}" + ssh "$SERVER" "cd $REMOTE_PATH/ctrl && docker compose down && docker compose up -d --build" +fi + +echo -e "${GREEN}Done!${NC}" diff --git a/ctrl/docker-compose.yml b/ctrl/docker-compose.yml new file mode 100644 index 0000000..5d52a7f --- /dev/null +++ b/ctrl/docker-compose.yml @@ -0,0 +1,128 @@ +x-common-env: &common-env + DATABASE_URL: postgresql://mpr_user:mpr_pass@postgres:5432/mpr + REDIS_URL: redis://redis:6379/0 + DJANGO_SETTINGS_MODULE: mpr.settings + DEBUG: 1 + +x-healthcheck-defaults: &healthcheck-defaults + interval: 5s + timeout: 5s + retries: 5 + +services: + # ============================================================================= + # Infrastructure + # ============================================================================= + + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: mpr + POSTGRES_USER: mpr_user + POSTGRES_PASSWORD: mpr_pass + ports: + - "5433:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + <<: *healthcheck-defaults + test: ["CMD-SHELL", "pg_isready -U mpr_user -d mpr"] + + redis: + image: redis:7-alpine + ports: + - "6380:6379" + volumes: + - redis-data:/data + healthcheck: + <<: *healthcheck-defaults + test: ["CMD", "redis-cli", "ping"] + + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ../media:/app/media:ro + depends_on: + - django + - fastapi + - timeline + + # ============================================================================= + # Application Services + # ============================================================================= + + django: + build: + context: .. + dockerfile: ctrl/Dockerfile + command: > + bash -c "python manage.py migrate && + python manage.py loadbuiltins || true && + python manage.py runserver 0.0.0.0:8701" + ports: + - "8701:8701" + environment: + <<: *common-env + volumes: + - ..:/app + - ../media:/app/media + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + fastapi: + build: + context: .. + dockerfile: ctrl/Dockerfile + command: uvicorn api.main:app --host 0.0.0.0 --port 8702 --reload + ports: + - "8702:8702" + environment: + <<: *common-env + volumes: + - ..:/app + - ../media:/app/media + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + celery: + build: + context: .. + dockerfile: ctrl/Dockerfile + command: celery -A mpr worker -l info -Q default -c 2 + environment: + <<: *common-env + MPR_EXECUTOR: local + volumes: + - ..:/app + - ../media:/app/media + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + timeline: + build: + context: ../ui/timeline + dockerfile: Dockerfile + ports: + - "5173:5173" + volumes: + - ../ui/timeline/src:/app/src + +volumes: + postgres-data: + redis-data: + +networks: + default: + name: mpr diff --git a/ctrl/nginx.conf b/ctrl/nginx.conf new file mode 100644 index 0000000..1e9cfc5 --- /dev/null +++ b/ctrl/nginx.conf @@ -0,0 +1,84 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + upstream django { + server django:8701; + } + + upstream fastapi { + server fastapi:8702; + } + + upstream timeline { + server timeline:5173; + } + + server { + listen 80; + server_name mpr.local.ar; + + # Django Admin + location /admin { + proxy_pass http://django; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Django static files + location /static { + proxy_pass http://django; + proxy_set_header Host $host; + } + + # FastAPI + location /api { + proxy_pass http://fastapi; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Timeline UI + location /ui { + proxy_pass http://timeline; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Vite HMR websocket + location /@vite { + proxy_pass http://timeline; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + # Media files + location /media { + alias /app/media; + autoindex on; + } + + # Default to Timeline UI + location / { + proxy_pass http://timeline; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + } +} diff --git a/ctrl/run.sh b/ctrl/run.sh new file mode 100755 index 0000000..0b5346b --- /dev/null +++ b/ctrl/run.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Run MPR stack locally +# Usage: ./ctrl/run.sh [docker-compose args] +# +# Examples: +# ./ctrl/run.sh # Start all services +# ./ctrl/run.sh --build # Rebuild and start +# ./ctrl/run.sh -d # Detached mode +# ./ctrl/run.sh down # Stop all + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Load env +if [ -f .env ]; then + set -a + source .env + set +a +else + echo "Warning: .env not found, using defaults" + echo "Copy .env.template to .env to customize" +fi + +# Check /etc/hosts +if ! grep -q "mpr.local.ar" /etc/hosts 2>/dev/null; then + echo "Note: Add to /etc/hosts:" + echo " 127.0.0.1 mpr.local.ar" + echo "" +fi + +docker compose "$@" diff --git a/grpc/protos/worker.proto b/grpc/protos/worker.proto new file mode 100644 index 0000000..4f392dc --- /dev/null +++ b/grpc/protos/worker.proto @@ -0,0 +1,65 @@ +// MPR Worker Service - GENERATED FILE +// +// Do not edit directly. Modify schema/models/grpc.py and run: +// python schema/generate.py --proto + +syntax = "proto3"; + +package mpr.worker; + +service WorkerService { + rpc SubmitJob(JobRequest) returns (JobResponse); + rpc StreamProgress(ProgressRequest) returns (stream ProgressUpdate); + rpc CancelJob(CancelRequest) returns (CancelResponse); + rpc GetWorkerStatus(Empty) returns (WorkerStatus); +} + +message JobRequest { + string job_id = 1; + string source_path = 2; + string output_path = 3; + string preset_json = 4; + optional float trim_start = 5; + optional float trim_end = 6; +} + +message JobResponse { + string job_id = 1; + bool accepted = 2; + string message = 3; +} + +message ProgressRequest { + string job_id = 1; +} + +message ProgressUpdate { + string job_id = 1; + int32 progress = 2; + int32 current_frame = 3; + float current_time = 4; + float speed = 5; + string status = 6; + optional string error = 7; +} + +message CancelRequest { + string job_id = 1; +} + +message CancelResponse { + string job_id = 1; + bool cancelled = 2; + string message = 3; +} + +message WorkerStatus { + bool available = 1; + int32 active_jobs = 2; + repeated string supported_codecs = 3; + bool gpu_available = 4; +} + +message Empty { + // Empty +} diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..cf41205 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mpr.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/mpr/__init__.py b/mpr/__init__.py new file mode 100644 index 0000000..53f4ccb --- /dev/null +++ b/mpr/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/mpr/asgi.py b/mpr/asgi.py new file mode 100644 index 0000000..4112112 --- /dev/null +++ b/mpr/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for mpr project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mpr.settings') + +application = get_asgi_application() diff --git a/mpr/celery.py b/mpr/celery.py new file mode 100644 index 0000000..9dee041 --- /dev/null +++ b/mpr/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mpr.settings") + +app = Celery("mpr") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/mpr/media_assets/__init__.py b/mpr/media_assets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mpr/media_assets/admin.py b/mpr/media_assets/admin.py new file mode 100644 index 0000000..9d4180e --- /dev/null +++ b/mpr/media_assets/admin.py @@ -0,0 +1,174 @@ +from django.contrib import admin + +from .models import MediaAsset, TranscodeJob, TranscodePreset + + +@admin.register(MediaAsset) +class MediaAssetAdmin(admin.ModelAdmin): + list_display = [ + "filename", + "status", + "duration_display", + "resolution", + "created_at", + ] + list_filter = ["status", "video_codec", "audio_codec"] + search_fields = ["filename", "file_path", "comments"] + readonly_fields = ["id", "created_at", "updated_at", "properties"] + + fieldsets = [ + (None, {"fields": ["id", "filename", "file_path", "status", "error_message"]}), + ( + "Media Info", + { + "fields": [ + "file_size", + "duration", + "video_codec", + "audio_codec", + "width", + "height", + "framerate", + "bitrate", + ] + }, + ), + ("Annotations", {"fields": ["comments", "tags"]}), + ( + "Metadata", + { + "classes": ["collapse"], + "fields": ["properties", "created_at", "updated_at"], + }, + ), + ] + + def duration_display(self, obj): + if obj.duration: + mins, secs = divmod(int(obj.duration), 60) + hours, mins = divmod(mins, 60) + if hours: + return f"{hours}:{mins:02d}:{secs:02d}" + return f"{mins}:{secs:02d}" + return "-" + + duration_display.short_description = "Duration" + + def resolution(self, obj): + if obj.width and obj.height: + return f"{obj.width}x{obj.height}" + return "-" + + +@admin.register(TranscodePreset) +class TranscodePresetAdmin(admin.ModelAdmin): + list_display = ["name", "container", "video_codec", "audio_codec", "is_builtin"] + list_filter = ["is_builtin", "container", "video_codec"] + search_fields = ["name", "description"] + readonly_fields = ["id", "created_at", "updated_at"] + + fieldsets = [ + (None, {"fields": ["id", "name", "description", "is_builtin"]}), + ("Output", {"fields": ["container"]}), + ( + "Video", + { + "fields": [ + "video_codec", + "video_bitrate", + "video_crf", + "video_preset", + "resolution", + "framerate", + ] + }, + ), + ( + "Audio", + { + "fields": [ + "audio_codec", + "audio_bitrate", + "audio_channels", + "audio_samplerate", + ] + }, + ), + ( + "Advanced", + { + "classes": ["collapse"], + "fields": ["extra_args", "created_at", "updated_at"], + }, + ), + ] + + +@admin.register(TranscodeJob) +class TranscodeJobAdmin(admin.ModelAdmin): + list_display = [ + "id_short", + "source_asset", + "preset", + "status", + "progress_display", + "created_at", + ] + list_filter = ["status", "preset"] + search_fields = ["source_asset__filename", "output_filename"] + readonly_fields = [ + "id", + "created_at", + "started_at", + "completed_at", + "progress", + "current_frame", + "current_time", + "speed", + "celery_task_id", + "preset_snapshot", + ] + raw_id_fields = ["source_asset", "preset", "output_asset"] + + fieldsets = [ + (None, {"fields": ["id", "source_asset", "status", "error_message"]}), + ( + "Configuration", + { + "fields": [ + "preset", + "preset_snapshot", + "trim_start", + "trim_end", + "priority", + ] + }, + ), + ("Output", {"fields": ["output_filename", "output_path", "output_asset"]}), + ( + "Progress", + {"fields": ["progress", "current_frame", "current_time", "speed"]}, + ), + ( + "Worker", + { + "classes": ["collapse"], + "fields": [ + "celery_task_id", + "created_at", + "started_at", + "completed_at", + ], + }, + ), + ] + + def id_short(self, obj): + return str(obj.id)[:8] + + id_short.short_description = "ID" + + def progress_display(self, obj): + return f"{obj.progress:.1f}%" + + progress_display.short_description = "Progress" diff --git a/mpr/media_assets/apps.py b/mpr/media_assets/apps.py new file mode 100644 index 0000000..b78b388 --- /dev/null +++ b/mpr/media_assets/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class MediaAssetsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "mpr.media_assets" + verbose_name = "Media Assets" diff --git a/mpr/media_assets/management/__init__.py b/mpr/media_assets/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mpr/media_assets/management/commands/__init__.py b/mpr/media_assets/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mpr/media_assets/management/commands/loadbuiltins.py b/mpr/media_assets/management/commands/loadbuiltins.py new file mode 100644 index 0000000..26b958d --- /dev/null +++ b/mpr/media_assets/management/commands/loadbuiltins.py @@ -0,0 +1,54 @@ +# Import builtin presets from schema +import sys +from pathlib import Path + +from django.core.management.base import BaseCommand + +from mpr.media_assets.models import TranscodePreset + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent.parent)) +from schema.models import BUILTIN_PRESETS + + +class Command(BaseCommand): + help = "Load builtin transcode presets" + + def handle(self, *args, **options): + created_count = 0 + updated_count = 0 + + for preset_data in BUILTIN_PRESETS: + name = preset_data["name"] + defaults = { + "description": preset_data.get("description", ""), + "is_builtin": True, + "container": preset_data.get("container", "mp4"), + "video_codec": preset_data.get("video_codec", "libx264"), + "video_bitrate": preset_data.get("video_bitrate"), + "video_crf": preset_data.get("video_crf"), + "video_preset": preset_data.get("video_preset"), + "resolution": preset_data.get("resolution"), + "framerate": preset_data.get("framerate"), + "audio_codec": preset_data.get("audio_codec", "aac"), + "audio_bitrate": preset_data.get("audio_bitrate"), + "audio_channels": preset_data.get("audio_channels"), + "audio_samplerate": preset_data.get("audio_samplerate"), + "extra_args": preset_data.get("extra_args", []), + } + + preset, created = TranscodePreset.objects.update_or_create( + name=name, defaults=defaults + ) + + if created: + created_count += 1 + self.stdout.write(self.style.SUCCESS(f"Created: {name}")) + else: + updated_count += 1 + self.stdout.write(f"Updated: {name}") + + self.stdout.write( + self.style.SUCCESS( + f"Done: {created_count} created, {updated_count} updated" + ) + ) diff --git a/mpr/media_assets/migrations/0001_initial.py b/mpr/media_assets/migrations/0001_initial.py new file mode 100644 index 0000000..47d6ea8 --- /dev/null +++ b/mpr/media_assets/migrations/0001_initial.py @@ -0,0 +1,98 @@ +# Generated by Django 6.0.1 on 2026-02-01 15:13 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='TranscodePreset', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.TextField(blank=True, default='')), + ('is_builtin', models.BooleanField(default=False)), + ('container', models.CharField(default='mp4', max_length=20)), + ('video_codec', models.CharField(default='libx264', max_length=50)), + ('video_bitrate', models.CharField(blank=True, max_length=20, null=True)), + ('video_crf', models.IntegerField(blank=True, null=True)), + ('video_preset', models.CharField(blank=True, max_length=20, null=True)), + ('resolution', models.CharField(blank=True, max_length=20, null=True)), + ('framerate', models.FloatField(blank=True, null=True)), + ('audio_codec', models.CharField(default='aac', max_length=50)), + ('audio_bitrate', models.CharField(blank=True, max_length=20, null=True)), + ('audio_channels', models.IntegerField(blank=True, null=True)), + ('audio_samplerate', models.IntegerField(blank=True, null=True)), + ('extra_args', models.JSONField(blank=True, default=list)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='MediaAsset', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('filename', models.CharField(max_length=500)), + ('file_path', models.CharField(max_length=1000)), + ('status', models.CharField(choices=[('pending', 'Pending Probe'), ('ready', 'Ready'), ('error', 'Error')], default='pending', max_length=20)), + ('error_message', models.TextField(blank=True, null=True)), + ('file_size', models.BigIntegerField(blank=True, null=True)), + ('duration', models.FloatField(blank=True, null=True)), + ('video_codec', models.CharField(blank=True, max_length=50, null=True)), + ('audio_codec', models.CharField(blank=True, max_length=50, null=True)), + ('width', models.IntegerField(blank=True, null=True)), + ('height', models.IntegerField(blank=True, null=True)), + ('framerate', models.FloatField(blank=True, null=True)), + ('bitrate', models.BigIntegerField(blank=True, null=True)), + ('properties', models.JSONField(blank=True, default=dict)), + ('comments', models.TextField(blank=True, default='')), + ('tags', models.JSONField(blank=True, default=list)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['status'], name='media_asset_status_9ea2f2_idx'), models.Index(fields=['created_at'], name='media_asset_created_368039_idx')], + }, + ), + migrations.CreateModel( + name='TranscodeJob', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('preset_snapshot', models.JSONField(blank=True, default=dict)), + ('trim_start', models.FloatField(blank=True, null=True)), + ('trim_end', models.FloatField(blank=True, null=True)), + ('output_filename', models.CharField(max_length=500)), + ('output_path', models.CharField(blank=True, max_length=1000, null=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)), + ('progress', models.FloatField(default=0.0)), + ('current_frame', models.IntegerField(blank=True, null=True)), + ('current_time', models.FloatField(blank=True, null=True)), + ('speed', models.CharField(blank=True, max_length=20, null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('celery_task_id', models.CharField(blank=True, max_length=100, null=True)), + ('priority', models.IntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('output_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_jobs', to='media_assets.mediaasset')), + ('source_asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transcode_jobs', to='media_assets.mediaasset')), + ('preset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='jobs', to='media_assets.transcodepreset')), + ], + options={ + 'ordering': ['priority', 'created_at'], + 'indexes': [models.Index(fields=['status', 'priority'], name='media_asset_status_e6ac18_idx'), models.Index(fields=['created_at'], name='media_asset_created_ba3a46_idx'), models.Index(fields=['celery_task_id'], name='media_asset_celery__81a88e_idx')], + }, + ), + ] diff --git a/mpr/media_assets/migrations/__init__.py b/mpr/media_assets/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mpr/media_assets/models.py b/mpr/media_assets/models.py new file mode 100644 index 0000000..717ccb7 --- /dev/null +++ b/mpr/media_assets/models.py @@ -0,0 +1,110 @@ +""" +Django ORM Models - GENERATED FILE + +Do not edit directly. Modify schema/models/*.py and run: + python schema/generate.py --django +""" + +import uuid +from django.db import models + +class MediaAsset(models.Model): + """A video/audio file registered in the system.""" + + class Status(models.TextChoices): + PENDING = "pending", "Pending" + READY = "ready", "Ready" + ERROR = "error", "Error" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + filename = models.CharField(max_length=500) + file_path = models.CharField(max_length=1000) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) + error_message = models.TextField(blank=True, default='') + file_size = models.BigIntegerField(null=True, blank=True) + duration = models.FloatField(null=True, blank=True, default=None) + video_codec = models.CharField(max_length=255, null=True, blank=True) + audio_codec = models.CharField(max_length=255, null=True, blank=True) + width = models.IntegerField(null=True, blank=True, default=None) + height = models.IntegerField(null=True, blank=True, default=None) + framerate = models.FloatField(null=True, blank=True, default=None) + bitrate = models.BigIntegerField(null=True, blank=True) + properties = models.JSONField(default=dict, blank=True) + comments = models.TextField(blank=True, default='') + tags = models.JSONField(default=list, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return self.filename + + +class TranscodePreset(models.Model): + """A reusable transcoding configuration (like Handbrake presets).""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, default='') + is_builtin = models.BooleanField(default=False) + container = models.CharField(max_length=255) + video_codec = models.CharField(max_length=255) + video_bitrate = models.CharField(max_length=255, null=True, blank=True) + video_crf = models.IntegerField(null=True, blank=True, default=None) + video_preset = models.CharField(max_length=255, null=True, blank=True) + resolution = models.CharField(max_length=255, null=True, blank=True) + framerate = models.FloatField(null=True, blank=True, default=None) + audio_codec = models.CharField(max_length=255) + audio_bitrate = models.CharField(max_length=255, null=True, blank=True) + audio_channels = models.IntegerField(null=True, blank=True, default=None) + audio_samplerate = models.IntegerField(null=True, blank=True, default=None) + extra_args = models.JSONField(default=list, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return self.name + + +class TranscodeJob(models.Model): + """A transcoding or trimming job in the queue.""" + + class Status(models.TextChoices): + PENDING = "pending", "Pending" + PROCESSING = "processing", "Processing" + COMPLETED = "completed", "Completed" + FAILED = "failed", "Failed" + CANCELLED = "cancelled", "Cancelled" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + source_asset_id = models.UUIDField() + preset_id = models.UUIDField(null=True, blank=True) + preset_snapshot = models.JSONField(default=dict, blank=True) + trim_start = models.FloatField(null=True, blank=True, default=None) + trim_end = models.FloatField(null=True, blank=True, default=None) + output_filename = models.CharField(max_length=500) + output_path = models.CharField(max_length=1000, null=True, blank=True) + output_asset_id = models.UUIDField(null=True, blank=True) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) + progress = models.FloatField(default=0.0) + current_frame = models.IntegerField(null=True, blank=True, default=None) + current_time = models.FloatField(null=True, blank=True, default=None) + speed = models.CharField(max_length=255, null=True, blank=True) + error_message = models.TextField(blank=True, default='') + celery_task_id = models.CharField(max_length=255, null=True, blank=True) + priority = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return str(self.id) + diff --git a/mpr/media_assets/tests.py b/mpr/media_assets/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/mpr/media_assets/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/mpr/media_assets/views.py b/mpr/media_assets/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/mpr/media_assets/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/mpr/settings.py b/mpr/settings.py new file mode 100644 index 0000000..9525d48 --- /dev/null +++ b/mpr/settings.py @@ -0,0 +1,103 @@ +""" +Django settings for mpr project. +""" + +import os +from pathlib import Path + +import environ + +BASE_DIR = Path(__file__).resolve().parent.parent + +env = environ.Env( + DEBUG=(bool, False), + SECRET_KEY=(str, "dev-secret-key-change-in-production"), +) + +environ.Env.read_env(BASE_DIR / ".env") + +SECRET_KEY = env("SECRET_KEY") +DEBUG = env("DEBUG") +ALLOWED_HOSTS = ["*"] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "mpr.media_assets", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "mpr.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "mpr.wsgi.application" + +# Database +DATABASE_URL = env("DATABASE_URL", default="sqlite:///db.sqlite3") + +if DATABASE_URL.startswith("postgresql"): + DATABASES = {"default": env.db("DATABASE_URL")} +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } + } + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" + }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +MEDIA_URL = "media/" +MEDIA_ROOT = BASE_DIR / "media" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Celery +REDIS_URL = env("REDIS_URL", default="redis://localhost:6379/0") +CELERY_BROKER_URL = REDIS_URL +CELERY_RESULT_BACKEND = REDIS_URL +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" diff --git a/mpr/urls.py b/mpr/urls.py new file mode 100644 index 0000000..c75d2c3 --- /dev/null +++ b/mpr/urls.py @@ -0,0 +1,22 @@ +""" +URL configuration for mpr project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/6.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/mpr/wsgi.py b/mpr/wsgi.py new file mode 100644 index 0000000..3760985 --- /dev/null +++ b/mpr/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for mpr project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mpr.settings') + +application = get_wsgi_application() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a6dcb08 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,22 @@ +# Django +Django>=4.2,<5.0 +django-environ>=0.11.2 +psycopg2-binary>=2.9.9 + +# FastAPI +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +pydantic>=2.5.0 + +# Celery +celery[redis]>=5.3.0 +redis>=5.0.0 + +# FFmpeg +ffmpeg-python>=0.2.0 + +# Testing +pytest>=7.4.0 +pytest-django>=4.7.0 +pytest-asyncio>=0.23.0 +httpx>=0.26.0 diff --git a/schema/__init__.py b/schema/__init__.py index bd20351..8c15b9d 100644 --- a/schema/__init__.py +++ b/schema/__init__.py @@ -11,32 +11,38 @@ These definitions are used to generate: Run `python schema/generate.py` to regenerate all targets. """ -from .grpc import ( +from .models import ( + BUILTIN_PRESETS, + # For generator + DATACLASSES, + ENUMS, + GRPC_MESSAGES, + # gRPC GRPC_SERVICE, + # Enums + AssetStatus, CancelRequest, CancelResponse, Empty, JobRequest, JobResponse, + JobStatus, + # Models + MediaAsset, ProgressRequest, ProgressUpdate, + TranscodeJob, + TranscodePreset, WorkerStatus, ) -from .jobs import JobStatus, TranscodeJob -from .media import AssetStatus, MediaAsset -from .presets import BUILTIN_PRESETS, TranscodePreset __all__ = [ - # Media "MediaAsset", - "AssetStatus", - # Presets "TranscodePreset", - "BUILTIN_PRESETS", - # Jobs "TranscodeJob", + "AssetStatus", "JobStatus", - # gRPC + "GRPC_SERVICE", "JobRequest", "JobResponse", "ProgressRequest", @@ -45,5 +51,8 @@ __all__ = [ "CancelResponse", "WorkerStatus", "Empty", - "GRPC_SERVICE", + "DATACLASSES", + "ENUMS", + "GRPC_MESSAGES", + "BUILTIN_PRESETS", ] diff --git a/schema/generate.py b/schema/generate.py new file mode 100755 index 0000000..a663cab --- /dev/null +++ b/schema/generate.py @@ -0,0 +1,702 @@ +#!/usr/bin/env python3 +""" +MPR Model Generator + +Generates framework-specific models from schema/models/: +- Django ORM models -> mpr/media_assets/models.py +- Pydantic schemas -> api/schemas/*.py +- TypeScript types -> ui/timeline/src/types.ts +- Protobuf -> grpc/protos/worker.proto + +Usage: + python schema/generate.py [--django] [--pydantic] [--typescript] [--proto] [--all] +""" + +import argparse +import dataclasses as dc +import subprocess +import sys +from enum import Enum +from pathlib import Path +from typing import Any, Callable, Union, get_args, get_origin, get_type_hints + +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from schema.models import DATACLASSES, ENUMS, GRPC_MESSAGES, GRPC_SERVICE + +# ============================================================================= +# Type Dispatch Tables +# ============================================================================= + +DJANGO_TYPES: dict[Any, str] = { + str: "models.CharField(max_length={max_length}{opts})", + int: "models.IntegerField({opts})", + float: "models.FloatField({opts})", + bool: "models.BooleanField(default={default})", + "UUID": "models.UUIDField({opts})", + "datetime": "models.DateTimeField({opts})", + "dict": "models.JSONField(default=dict, blank=True)", + "list": "models.JSONField(default=list, blank=True)", + "text": "models.TextField(blank=True, default='')", + "bigint": "models.BigIntegerField({opts})", + "enum": "models.CharField(max_length=20, choices=Status.choices{opts})", +} + +DJANGO_SPECIAL: dict[str, str] = { + "id": "models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)", + "created_at": "models.DateTimeField(auto_now_add=True)", + "updated_at": "models.DateTimeField(auto_now=True)", +} + +PYDANTIC_RESOLVERS: dict[Any, Callable[[Any], str]] = { + str: lambda _: "str", + int: lambda _: "int", + float: lambda _: "float", + bool: lambda _: "bool", + "UUID": lambda _: "UUID", + "datetime": lambda _: "datetime", + "dict": lambda _: "Dict[str, Any]", + "list": lambda base: f"List[{get_list_inner(base)}]", + "enum": lambda base: base.__name__, +} + +TS_RESOLVERS: dict[Any, Callable[[Any], str]] = { + str: lambda _: "string", + int: lambda _: "number", + float: lambda _: "number", + bool: lambda _: "boolean", + "UUID": lambda _: "string", + "datetime": lambda _: "string", + "dict": lambda _: "Record", + "list": lambda base: f"{TS_RESOLVERS.get(get_args(base)[0], lambda _: 'string')(None)}[]" + if get_args(base) + else "string[]", + "enum": lambda base: base.__name__, +} + +PROTO_RESOLVERS: dict[Any, Callable[[Any], str]] = { + str: lambda _: "string", + int: lambda _: "int32", + float: lambda _: "float", + bool: lambda _: "bool", + "list": lambda base: f"repeated {PROTO_RESOLVERS.get(get_args(base)[0], lambda _: 'string')(None)}" + if get_args(base) + else "repeated string", +} + + +# ============================================================================= +# Type Helpers +# ============================================================================= + + +def unwrap_optional(type_hint: Any) -> tuple[Any, bool]: + """Unwrap Optional[T] -> (T, True) or (T, False) if not optional.""" + origin = get_origin(type_hint) + if origin is Union: + args = [a for a in get_args(type_hint) if a is not type(None)] + return (args[0] if args else str, True) + return (type_hint, False) + + +def get_origin_name(type_hint: Any) -> str | None: + """Get origin type name: 'dict', 'list', or None.""" + origin = get_origin(type_hint) + if origin is dict: + return "dict" + if origin is list: + return "list" + return None + + +def get_type_name(type_hint: Any) -> str | None: + """Get type name for special types like UUID, datetime.""" + if hasattr(type_hint, "__name__"): + return type_hint.__name__ + return None + + +def get_list_inner(type_hint: Any) -> str: + """Get inner type of List[T].""" + args = get_args(type_hint) + if args and args[0] in (str, int, float, bool): + return {str: "str", int: "int", float: "float", bool: "bool"}[args[0]] + return "str" + + +def get_field_default(field: dc.Field) -> Any: + """Get default value from dataclass field.""" + if field.default is not dc.MISSING: + return field.default + return dc.MISSING + + +def format_opts(optional: bool, extra: list[str] | None = None) -> str: + """Format field options string.""" + parts = [] + if optional: + parts.append("null=True, blank=True") + if extra: + parts.extend(extra) + return ", ".join(parts) + + +# ============================================================================= +# Django Generator +# ============================================================================= + + +def resolve_django_type(name: str, type_hint: Any, default: Any) -> str: + """Resolve Python type to Django field.""" + # Special fields + if name in DJANGO_SPECIAL: + return DJANGO_SPECIAL[name] + + base, optional = unwrap_optional(type_hint) + origin = get_origin_name(base) + type_name = get_type_name(base) + opts = format_opts(optional) + + # Container types + if origin == "dict": + return DJANGO_TYPES["dict"] + if origin == "list": + return DJANGO_TYPES["list"] + + # UUID / datetime + if type_name == "UUID": + return DJANGO_TYPES["UUID"].format(opts=opts) + if type_name == "datetime": + return DJANGO_TYPES["datetime"].format(opts=opts) + + # Enum + if isinstance(base, type) and issubclass(base, Enum): + extra = [] + if optional: + extra.append("null=True, blank=True") + if default is not dc.MISSING and isinstance(default, Enum): + extra.append(f"default=Status.{default.name}") + return DJANGO_TYPES["enum"].format( + opts=", " + ", ".join(extra) if extra else "" + ) + + # Text fields + if base is str and any(x in name for x in ("message", "comments", "description")): + return DJANGO_TYPES["text"] + + # BigInt fields + if base is int and name in ("file_size", "bitrate"): + return DJANGO_TYPES["bigint"].format(opts=opts) + + # Basic types + if base is str: + max_length = 1000 if "path" in name else 500 if "filename" in name else 255 + return DJANGO_TYPES[str].format( + max_length=max_length, opts=", " + opts if opts else "" + ) + + if base is int: + extra = [opts] if opts else [] + if default is not dc.MISSING and not callable(default): + extra.append(f"default={default}") + return DJANGO_TYPES[int].format(opts=", ".join(extra)) + + if base is float: + extra = [opts] if opts else [] + if default is not dc.MISSING and not callable(default): + extra.append(f"default={default}") + return DJANGO_TYPES[float].format(opts=", ".join(extra)) + + if base is bool: + default_val = default if default is not dc.MISSING else False + return DJANGO_TYPES[bool].format(default=default_val) + + # Fallback + return DJANGO_TYPES[str].format(max_length=255, opts=", " + opts if opts else "") + + +def generate_django_model(cls: type) -> list[str]: + """Generate Django model lines from dataclass.""" + lines = [ + f"class {cls.__name__}(models.Model):", + f' """{(cls.__doc__ or cls.__name__).strip().split(chr(10))[0]}"""', + "", + ] + + hints = get_type_hints(cls) + fields = {f.name: f for f in dc.fields(cls)} + + # Add Status inner class for enum fields + for type_hint in hints.values(): + base, _ = unwrap_optional(type_hint) + if isinstance(base, type) and issubclass(base, Enum): + lines.append(" class Status(models.TextChoices):") + for member in base: + label = member.name.replace("_", " ").title() + lines.append(f' {member.name} = "{member.value}", "{label}"') + lines.append("") + break + + # Fields + for name, type_hint in hints.items(): + if name.startswith("_"): + continue + field = fields.get(name) + default = get_field_default(field) if field else dc.MISSING + django_field = resolve_django_type(name, type_hint, default) + lines.append(f" {name} = {django_field}") + + # Meta and __str__ + lines.extend( + [ + "", + " class Meta:", + ' ordering = ["-created_at"]', + "", + " def __str__(self):", + ] + ) + + if "filename" in hints: + lines.append(" return self.filename") + elif "name" in hints: + lines.append(" return self.name") + else: + lines.append(" return str(self.id)") + + return lines + + +def generate_django() -> str: + """Generate complete Django models file.""" + header = [ + '"""', + "Django ORM Models - GENERATED FILE", + "", + "Do not edit directly. Modify schema/models/*.py and run:", + " python schema/generate.py --django", + '"""', + "", + "import uuid", + "from django.db import models", + "", + ] + + body = [] + for cls in DATACLASSES: + body.extend(generate_django_model(cls)) + body.extend(["", ""]) + + return "\n".join(header + body) + + +# ============================================================================= +# Pydantic Generator +# ============================================================================= + + +def resolve_pydantic_type(type_hint: Any) -> str: + """Resolve Python type to Pydantic type string.""" + base, optional = unwrap_optional(type_hint) + origin = get_origin_name(base) + type_name = get_type_name(base) + + # Look up resolver by origin, type name, base type, or enum + resolver = ( + PYDANTIC_RESOLVERS.get(origin) + or PYDANTIC_RESOLVERS.get(type_name) + or PYDANTIC_RESOLVERS.get(base) + or ( + PYDANTIC_RESOLVERS["enum"] + if isinstance(base, type) and issubclass(base, Enum) + else None + ) + ) + + result = resolver(base) if resolver else "str" + return f"Optional[{result}]" if optional else result + + +def generate_pydantic_schema(cls: type, suffix: str) -> list[str]: + """Generate Pydantic schema lines from dataclass.""" + name = cls.__name__.replace("Transcode", "").replace("Media", "") + class_name = f"{name}{suffix}" + + skip_fields = { + "Create": {"id", "created_at", "updated_at", "status", "error_message"}, + "Update": {"id", "created_at", "updated_at"}, + "Response": set(), + } + + lines = [ + f"class {class_name}(BaseSchema):", + f' """{class_name} schema."""', + ] + + hints = get_type_hints(cls) + fields = {f.name: f for f in dc.fields(cls)} + + for name, type_hint in hints.items(): + if name.startswith("_") or name in skip_fields.get(suffix, set()): + continue + + py_type = resolve_pydantic_type(type_hint) + + # Update schemas: all fields optional + if suffix == "Update" and "Optional" not in py_type: + py_type = f"Optional[{py_type}]" + + field = fields.get(name) + default = get_field_default(field) if field else dc.MISSING + + if "Optional" in py_type: + lines.append(f" {name}: {py_type} = None") + elif default is not dc.MISSING and not callable(default): + if isinstance(default, str): + lines.append(f' {name}: {py_type} = "{default}"') + elif isinstance(default, Enum): + lines.append( + f" {name}: {py_type} = {default.__class__.__name__}.{default.name}" + ) + else: + lines.append(f" {name}: {py_type} = {default!r}") + else: + lines.append(f" {name}: {py_type}") + + return lines + + +def generate_pydantic() -> dict[str, str]: + """Generate all Pydantic schema files.""" + files = {} + + # base.py + files["base.py"] = "\n".join( + [ + '"""Pydantic Base Schema - GENERATED FILE"""', + "", + "from pydantic import BaseModel, ConfigDict", + "", + "", + "class BaseSchema(BaseModel):", + ' """Base schema with ORM mode."""', + " model_config = ConfigDict(from_attributes=True)", + "", + ] + ) + + # Schema files per model + for cls in DATACLASSES: + module_name = cls.__name__.replace("Transcode", "").replace("Media", "").lower() + + lines = [ + f'"""{cls.__name__} Schemas - GENERATED FILE"""', + "", + "from datetime import datetime", + "from enum import Enum", + "from typing import Any, Dict, List, Optional", + "from uuid import UUID", + "", + "from .base import BaseSchema", + "", + ] + + # Add enum if present + hints = get_type_hints(cls) + for type_hint in hints.values(): + base, _ = unwrap_optional(type_hint) + if isinstance(base, type) and issubclass(base, Enum): + lines.extend( + [ + "", + f"class {base.__name__}(str, Enum):", + ] + ) + for m in base: + lines.append(f' {m.name} = "{m.value}"') + lines.append("") + break + + # Schemas + for suffix in ["Create", "Update", "Response"]: + lines.append("") + lines.extend(generate_pydantic_schema(cls, suffix)) + + lines.append("") + files[f"{module_name}.py"] = "\n".join(lines) + + # __init__.py + imports = ["from .base import BaseSchema"] + all_exports = ['"BaseSchema"'] + + for cls in DATACLASSES: + name = cls.__name__.replace("Transcode", "").replace("Media", "") + module = name.lower() + imports.append( + f"from .{module} import {name}Create, {name}Update, {name}Response" + ) + all_exports.extend([f'"{name}Create"', f'"{name}Update"', f'"{name}Response"']) + + # Add enum export + hints = get_type_hints(cls) + for type_hint in hints.values(): + base, _ = unwrap_optional(type_hint) + if isinstance(base, type) and issubclass(base, Enum): + imports.append(f"from .{module} import {base.__name__}") + all_exports.append(f'"{base.__name__}"') + break + + files["__init__.py"] = "\n".join( + [ + '"""API Schemas - GENERATED FILE"""', + "", + *imports, + "", + f"__all__ = [{', '.join(all_exports)}]", + "", + ] + ) + + return files + + +# ============================================================================= +# TypeScript Generator +# ============================================================================= + + +def resolve_ts_type(type_hint: Any) -> str: + """Resolve Python type to TypeScript type string.""" + base, optional = unwrap_optional(type_hint) + origin = get_origin_name(base) + type_name = get_type_name(base) + + # Look up resolver by origin, type name, base type, or enum + resolver = ( + TS_RESOLVERS.get(origin) + or TS_RESOLVERS.get(type_name) + or TS_RESOLVERS.get(base) + or ( + TS_RESOLVERS["enum"] + if isinstance(base, type) and issubclass(base, Enum) + else None + ) + ) + + result = resolver(base) if resolver else "string" + return f"{result} | null" if optional else result + + +def generate_ts_interface(cls: type) -> list[str]: + """Generate TypeScript interface lines from dataclass.""" + lines = [f"export interface {cls.__name__} {{"] + + for name, type_hint in get_type_hints(cls).items(): + if name.startswith("_"): + continue + ts_type = resolve_ts_type(type_hint) + lines.append(f" {name}: {ts_type};") + + lines.append("}") + return lines + + +def generate_typescript() -> str: + """Generate complete TypeScript file.""" + lines = [ + "/**", + " * MPR TypeScript Types - GENERATED FILE", + " *", + " * Do not edit directly. Modify schema/models/*.py and run:", + " * python schema/generate.py --typescript", + " */", + "", + ] + + # Enums as union types + for enum in ENUMS: + values = " | ".join(f'"{m.value}"' for m in enum) + lines.append(f"export type {enum.__name__} = {values};") + lines.append("") + + # Interfaces + for cls in DATACLASSES: + lines.extend(generate_ts_interface(cls)) + lines.append("") + + return "\n".join(lines) + + +# ============================================================================= +# Proto Generator +# ============================================================================= + + +def resolve_proto_type(type_hint: Any) -> tuple[str, bool]: + """Resolve Python type to proto type. Returns (type, is_optional).""" + base, optional = unwrap_optional(type_hint) + origin = get_origin_name(base) + + # Look up resolver by origin or base type + resolver = PROTO_RESOLVERS.get(origin) or PROTO_RESOLVERS.get(base) + + if resolver: + result = resolver(base) + is_repeated = result.startswith("repeated") + return result, optional and not is_repeated + + return "string", optional + + +def generate_proto_message(cls: type) -> list[str]: + """Generate proto message lines from dataclass.""" + lines = [f"message {cls.__name__} {{"] + + hints = get_type_hints(cls) + if not hints: + lines.append(" // Empty") + else: + for i, (name, type_hint) in enumerate(hints.items(), 1): + proto_type, optional = resolve_proto_type(type_hint) + prefix = ( + "optional " + if optional and not proto_type.startswith("repeated") + else "" + ) + lines.append(f" {prefix}{proto_type} {name} = {i};") + + lines.append("}") + return lines + + +def generate_proto() -> str: + """Generate complete proto file.""" + lines = [ + "// MPR Worker Service - GENERATED FILE", + "//", + "// Do not edit directly. Modify schema/models/grpc.py and run:", + "// python schema/generate.py --proto", + "", + 'syntax = "proto3";', + "", + f"package {GRPC_SERVICE['package']};", + "", + f"service {GRPC_SERVICE['name']} {{", + ] + + # Methods + for m in GRPC_SERVICE["methods"]: + req = m["request"].__name__ + resp = m["response"].__name__ + returns = f"stream {resp}" if m["stream_response"] else resp + lines.append(f" rpc {m['name']}({req}) returns ({returns});") + + lines.extend(["}", ""]) + + # Messages + for cls in GRPC_MESSAGES: + lines.extend(generate_proto_message(cls)) + lines.append("") + + return "\n".join(lines) + + +# ============================================================================= +# Writers +# ============================================================================= + + +def write_file(path: Path, content: str) -> None: + """Write content to file, creating directories as needed.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + print(f" {path}") + + +def write_django(output_dir: Path) -> None: + """Write Django models.""" + write_file(output_dir / "mpr" / "media_assets" / "models.py", generate_django()) + + +def write_pydantic(output_dir: Path) -> None: + """Write Pydantic schemas.""" + schemas_dir = output_dir / "api" / "schemas" + for filename, content in generate_pydantic().items(): + write_file(schemas_dir / filename, content) + + +def write_typescript(output_dir: Path) -> None: + """Write TypeScript types.""" + write_file( + output_dir / "ui" / "timeline" / "src" / "types.ts", generate_typescript() + ) + + +def write_proto(output_dir: Path) -> None: + """Write proto and generate stubs.""" + proto_dir = output_dir / "grpc" / "protos" + proto_path = proto_dir / "worker.proto" + write_file(proto_path, generate_proto()) + + # Generate Python stubs + grpc_dir = output_dir / "grpc" + result = subprocess.run( + [ + sys.executable, + "-m", + "grpc_tools.protoc", + f"-I{proto_dir}", + f"--python_out={grpc_dir}", + f"--grpc_python_out={grpc_dir}", + str(proto_path), + ], + capture_output=True, + text=True, + ) + + if result.returncode == 0: + print(f" {grpc_dir}/worker_pb2.py") + print(f" {grpc_dir}/worker_pb2_grpc.py") + else: + print(" Warning: grpc_tools failed - pip install grpcio-tools") + + +# ============================================================================= +# Main +# ============================================================================= + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate from schema") + parser.add_argument("--django", action="store_true") + parser.add_argument("--pydantic", action="store_true") + parser.add_argument("--typescript", action="store_true") + parser.add_argument("--proto", action="store_true") + parser.add_argument("--all", action="store_true") + parser.add_argument("--output", type=Path, default=PROJECT_ROOT) + args = parser.parse_args() + + if not any([args.django, args.pydantic, args.typescript, args.proto, args.all]): + args.all = True + + print(f"Generating to {args.output}\n") + + targets: list[tuple[bool, str, Callable]] = [ + (args.django or args.all, "Django", write_django), + (args.pydantic or args.all, "Pydantic", write_pydantic), + (args.typescript or args.all, "TypeScript", write_typescript), + (args.proto or args.all, "Proto", write_proto), + ] + + for enabled, name, writer in targets: + if enabled: + print(f"{name}:") + writer(args.output) + print() + + print("Done!") + + +if __name__ == "__main__": + main() diff --git a/schema/models/__init__.py b/schema/models/__init__.py new file mode 100644 index 0000000..f6dc7e6 --- /dev/null +++ b/schema/models/__init__.py @@ -0,0 +1,64 @@ +""" +MPR Schema Models + +This module exports all dataclasses, enums, and constants that the generator +should process. Add new models here to have them included in generation. +""" + +from .grpc import ( + GRPC_SERVICE, + CancelRequest, + CancelResponse, + Empty, + JobRequest, + JobResponse, + ProgressRequest, + ProgressUpdate, + WorkerStatus, +) +from .jobs import JobStatus, TranscodeJob +from .media import AssetStatus, MediaAsset +from .presets import BUILTIN_PRESETS, TranscodePreset + +# Core domain models - generates Django, Pydantic, TypeScript +DATACLASSES = [MediaAsset, TranscodePreset, TranscodeJob] + +# Status enums - included in generated code +ENUMS = [AssetStatus, JobStatus] + +# gRPC messages - generates Proto +GRPC_MESSAGES = [ + JobRequest, + JobResponse, + ProgressRequest, + ProgressUpdate, + CancelRequest, + CancelResponse, + WorkerStatus, + Empty, +] + +__all__ = [ + # Models + "MediaAsset", + "TranscodePreset", + "TranscodeJob", + # Enums + "AssetStatus", + "JobStatus", + # gRPC + "GRPC_SERVICE", + "JobRequest", + "JobResponse", + "ProgressRequest", + "ProgressUpdate", + "CancelRequest", + "CancelResponse", + "WorkerStatus", + "Empty", + # For generator + "DATACLASSES", + "ENUMS", + "GRPC_MESSAGES", + "BUILTIN_PRESETS", +] diff --git a/schema/grpc.py b/schema/models/grpc.py similarity index 100% rename from schema/grpc.py rename to schema/models/grpc.py diff --git a/schema/jobs.py b/schema/models/jobs.py similarity index 100% rename from schema/jobs.py rename to schema/models/jobs.py diff --git a/schema/media.py b/schema/models/media.py similarity index 100% rename from schema/media.py rename to schema/models/media.py diff --git a/schema/presets.py b/schema/models/presets.py similarity index 100% rename from schema/presets.py rename to schema/models/presets.py diff --git a/ui/timeline/src/types.ts b/ui/timeline/src/types.ts new file mode 100644 index 0000000..41719a0 --- /dev/null +++ b/ui/timeline/src/types.ts @@ -0,0 +1,74 @@ +/** + * MPR TypeScript Types - GENERATED FILE + * + * Do not edit directly. Modify schema/models/*.py and run: + * python schema/generate.py --typescript + */ + +export type AssetStatus = "pending" | "ready" | "error"; +export type JobStatus = "pending" | "processing" | "completed" | "failed" | "cancelled"; + +export interface MediaAsset { + id: string; + filename: string; + file_path: string; + status: AssetStatus; + error_message: string | null; + file_size: number | null; + duration: number | null; + video_codec: string | null; + audio_codec: string | null; + width: number | null; + height: number | null; + framerate: number | null; + bitrate: number | null; + properties: Record; + comments: string; + tags: string[]; + created_at: string | null; + updated_at: string | null; +} + +export interface TranscodePreset { + id: string; + name: string; + description: string; + is_builtin: boolean; + container: string; + video_codec: string; + video_bitrate: string | null; + video_crf: number | null; + video_preset: string | null; + resolution: string | null; + framerate: number | null; + audio_codec: string; + audio_bitrate: string | null; + audio_channels: number | null; + audio_samplerate: number | null; + extra_args: string[]; + created_at: string | null; + updated_at: string | null; +} + +export interface TranscodeJob { + id: string; + source_asset_id: string; + preset_id: string | null; + preset_snapshot: Record; + trim_start: number | null; + trim_end: number | null; + output_filename: string; + output_path: string | null; + output_asset_id: string | null; + status: JobStatus; + progress: number; + current_frame: number | null; + current_time: number | null; + speed: string | null; + error_message: string | null; + celery_task_id: string | null; + priority: number; + created_at: string | null; + started_at: string | null; + completed_at: string | null; +}