diff --git a/schema/__init__.py b/schema/__init__.py new file mode 100644 index 0000000..bd20351 --- /dev/null +++ b/schema/__init__.py @@ -0,0 +1,49 @@ +""" +MPR Schema Definitions - Source of Truth + +This package defines the core data models as Python dataclasses. +These definitions are used to generate: +- Django ORM models (mpr/media_assets/models.py) +- Pydantic schemas (api/schemas/*.py) +- TypeScript types (ui/timeline/src/types.ts) +- Protobuf definitions (grpc/protos/worker.proto) + +Run `python schema/generate.py` to regenerate all targets. +""" + +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 + +__all__ = [ + # Media + "MediaAsset", + "AssetStatus", + # Presets + "TranscodePreset", + "BUILTIN_PRESETS", + # Jobs + "TranscodeJob", + "JobStatus", + # gRPC + "JobRequest", + "JobResponse", + "ProgressRequest", + "ProgressUpdate", + "CancelRequest", + "CancelResponse", + "WorkerStatus", + "Empty", + "GRPC_SERVICE", +] diff --git a/schema/grpc.py b/schema/grpc.py new file mode 100644 index 0000000..b42a913 --- /dev/null +++ b/schema/grpc.py @@ -0,0 +1,130 @@ +""" +gRPC message definitions for MPR worker communication. + +This is the source of truth for gRPC messages. The generator creates: +- grpc/protos/worker.proto (protobuf definition) +- grpc/worker_pb2.py (generated Python classes) +- grpc/worker_pb2_grpc.py (generated gRPC stubs) +""" + +from dataclasses import dataclass +from typing import Optional + +# ----------------------------------------------------------------------------- +# Request Messages +# ----------------------------------------------------------------------------- + + +@dataclass +class JobRequest: + """Request to submit a transcode/trim job.""" + + job_id: str + source_path: str + output_path: str + preset_json: str # Serialized TranscodePreset + trim_start: Optional[float] = None + trim_end: Optional[float] = None + + +@dataclass +class ProgressRequest: + """Request to stream progress updates for a job.""" + + job_id: str + + +@dataclass +class CancelRequest: + """Request to cancel a running job.""" + + job_id: str + + +@dataclass +class Empty: + """Empty message for requests with no parameters.""" + + pass + + +# ----------------------------------------------------------------------------- +# Response Messages +# ----------------------------------------------------------------------------- + + +@dataclass +class JobResponse: + """Response after submitting a job.""" + + job_id: str + accepted: bool + message: str + + +@dataclass +class ProgressUpdate: + """Streaming progress update from worker.""" + + job_id: str + progress: int # 0-100 + current_frame: int + current_time: float + speed: float # e.g., 2.5x + status: str # pending, processing, completed, failed, cancelled + error: Optional[str] = None + + +@dataclass +class CancelResponse: + """Response after cancel request.""" + + job_id: str + cancelled: bool + message: str + + +@dataclass +class WorkerStatus: + """Worker health and capabilities.""" + + available: bool + active_jobs: int + supported_codecs: list[str] + gpu_available: bool + + +# ----------------------------------------------------------------------------- +# Service Definition (for documentation, generator uses this) +# ----------------------------------------------------------------------------- + +GRPC_SERVICE = { + "name": "WorkerService", + "package": "mpr.worker", + "methods": [ + { + "name": "SubmitJob", + "request": JobRequest, + "response": JobResponse, + "stream_response": False, + }, + { + "name": "StreamProgress", + "request": ProgressRequest, + "response": ProgressUpdate, + "stream_response": True, # Server streaming + }, + { + "name": "CancelJob", + "request": CancelRequest, + "response": CancelResponse, + "stream_response": False, + }, + { + "name": "GetWorkerStatus", + "request": Empty, + "response": WorkerStatus, + "stream_response": False, + }, + ], +} diff --git a/schema/jobs.py b/schema/jobs.py new file mode 100644 index 0000000..ba76b0a --- /dev/null +++ b/schema/jobs.py @@ -0,0 +1,78 @@ +""" +TranscodeJob Schema Definition + +Source of truth for job data model. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any, Dict, Optional +from uuid import UUID + + +class JobStatus(str, Enum): + """Status of a transcode/trim job.""" + + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +@dataclass +class TranscodeJob: + """ + A transcoding or trimming job in the queue. + + Jobs can either: + - Transcode using a preset (full re-encode) + - Trim only (stream copy with -c:v copy -c:a copy) + + A trim-only job has no preset and uses stream copy. + """ + + id: UUID + + # Input + source_asset_id: UUID + + # Configuration + preset_id: Optional[UUID] = None + preset_snapshot: Dict[str, Any] = field( + default_factory=dict + ) # Copy at creation time + + # Trimming (optional) + trim_start: Optional[float] = None # seconds + trim_end: Optional[float] = None # seconds + + # Output + output_filename: str = "" + output_path: Optional[str] = None + output_asset_id: Optional[UUID] = None + + # Status & Progress + status: JobStatus = JobStatus.PENDING + progress: float = 0.0 # 0.0 to 100.0 + current_frame: Optional[int] = None + current_time: Optional[float] = None # seconds processed + speed: Optional[str] = None # "2.5x" + error_message: Optional[str] = None + + # Worker tracking + celery_task_id: Optional[str] = None + priority: int = 0 # Lower = higher priority + + # Timestamps + created_at: Optional[datetime] = None + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + + @property + def is_trim_only(self) -> bool: + """Check if this is a trim-only job (stream copy, no transcode).""" + return self.preset_id is None and ( + self.trim_start is not None or self.trim_end is not None + ) diff --git a/schema/media.py b/schema/media.py new file mode 100644 index 0000000..2a29be5 --- /dev/null +++ b/schema/media.py @@ -0,0 +1,59 @@ +""" +MediaAsset Schema Definition + +Source of truth for media asset data model. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional +from uuid import UUID + + +class AssetStatus(str, Enum): + """Status of a media asset after probing.""" + + PENDING = "pending" + READY = "ready" + ERROR = "error" + + +@dataclass +class MediaAsset: + """ + A video/audio file registered in the system. + + Metadata is populated asynchronously via ffprobe after registration. + """ + + id: UUID + filename: str + file_path: str + + # Status + status: AssetStatus = AssetStatus.PENDING + error_message: Optional[str] = None + + # File info + file_size: Optional[int] = None + + # Media metadata (populated by ffprobe) + duration: Optional[float] = None # seconds + 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 # bits per second + + # Full ffprobe output and custom metadata + properties: Dict[str, Any] = field(default_factory=dict) + + # User annotations + comments: str = "" + tags: List[str] = field(default_factory=list) + + # Timestamps + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None diff --git a/schema/presets.py b/schema/presets.py new file mode 100644 index 0000000..3a20bcd --- /dev/null +++ b/schema/presets.py @@ -0,0 +1,128 @@ +""" +TranscodePreset Schema Definition + +Source of truth for preset data model. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Optional +from uuid import UUID + + +@dataclass +class TranscodePreset: + """ + A reusable transcoding configuration (like Handbrake presets). + + Presets can be builtin (shipped with the app) or user-created. + """ + + id: UUID + name: str + description: str = "" + is_builtin: bool = False + + # Output container + container: str = "mp4" # mp4, mkv, webm, mov, avi + + # Video settings + video_codec: str = "libx264" + video_bitrate: Optional[str] = None # "2M", "5000k" + video_crf: Optional[int] = None # Quality-based (0-51 for x264) + video_preset: Optional[str] = None # ultrafast...veryslow + resolution: Optional[str] = None # "1920x1080", "1280x720" + framerate: Optional[float] = None + + # Audio settings + audio_codec: str = "aac" + audio_bitrate: Optional[str] = None # "128k", "320k" + audio_channels: Optional[int] = None # 2 for stereo + audio_samplerate: Optional[int] = None # 44100, 48000 + + # Advanced: extra FFmpeg arguments + extra_args: List[str] = field(default_factory=list) + + # Timestamps + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +BUILTIN_PRESETS = [ + { + "name": "DaVinci Resolve", + "description": "MPEG-4 (xvid) + MP3 - Compatible with DaVinci Resolve Free", + "container": "avi", + "video_codec": "mpeg4", + "video_crf": 5, + "audio_codec": "libmp3lame", + "audio_bitrate": "320k", + "audio_samplerate": 48000, + "extra_args": ["-vtag", "xvid", "-pix_fmt", "yuv420p"], + }, + { + "name": "Copy (Trim Only)", + "description": "Stream copy - No transcoding, fast trimming only", + "container": "mp4", + "video_codec": "copy", + "audio_codec": "copy", + }, + { + "name": "Web H.264", + "description": "H.264 + AAC - General web playback", + "container": "mp4", + "video_codec": "libx264", + "video_crf": 23, + "video_preset": "medium", + "audio_codec": "aac", + "audio_bitrate": "128k", + }, + { + "name": "Web H.265", + "description": "HEVC + AAC - Smaller files, modern browsers", + "container": "mp4", + "video_codec": "libx265", + "video_crf": 28, + "video_preset": "medium", + "audio_codec": "aac", + "audio_bitrate": "128k", + }, + { + "name": "DNxHR HQ", + "description": "DNxHR High Quality - Professional editing", + "container": "mov", + "video_codec": "dnxhd", + "audio_codec": "pcm_s16le", + "audio_samplerate": 48000, + "extra_args": ["-profile:v", "dnxhr_hq", "-pix_fmt", "yuv422p"], + }, + { + "name": "H.264 NVENC", + "description": "NVIDIA GPU encoding - Fast H.264", + "container": "mp4", + "video_codec": "h264_nvenc", + "video_bitrate": "10M", + "audio_codec": "aac", + "audio_bitrate": "192k", + "extra_args": ["-preset", "p4", "-rc", "vbr", "-cq", "19"], + }, + { + "name": "HEVC NVENC", + "description": "NVIDIA GPU encoding - HEVC/H.265", + "container": "mp4", + "video_codec": "hevc_nvenc", + "video_bitrate": "8M", + "audio_codec": "aac", + "audio_bitrate": "192k", + "extra_args": ["-preset", "p4", "-rc", "vbr", "-cq", "23"], + }, + { + "name": "Archive ProRes", + "description": "Apple ProRes 422 HQ - Archival quality", + "container": "mov", + "video_codec": "prores_ks", + "audio_codec": "pcm_s16le", + "audio_samplerate": 48000, + "extra_args": ["-profile:v", "3"], # ProRes 422 HQ + }, +]