diff --git a/README.md b/README.md new file mode 100644 index 0000000..40c630f --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# MPR - Media Processor + +A web-based media transcoding tool with Django admin, FastAPI backend, and React timeline UI. + +## Architecture + +``` +Browser (mpr.local.ar) + │ + nginx:80 + │ + ┌────┴────┐ + │ │ + /admin /api, /ui + │ │ + Django FastAPI ◄── Timeline UI + │ │ + │ ┌────┘ + │ │ + └───►│ (job operations) + │ + gRPC Server + │ + Celery Worker +``` + +- **Django** (`/admin`): Admin interface for data management +- **FastAPI** (`/api`): REST API and gRPC client +- **Timeline UI** (`/ui`): React app for video editing +- **gRPC Server**: Worker communication with progress streaming +- **Celery**: Job execution via FFmpeg + +## Prerequisites + +- Docker & Docker Compose + +## Quick Start + +```bash +# Add to /etc/hosts +echo "127.0.0.1 mpr.local.ar" | sudo tee -a /etc/hosts + +# Start all services +cd ctrl +cp .env.template .env +docker compose up -d +``` + +## Access Points + +| URL | Description | +|-----|-------------| +| http://mpr.local.ar/admin | Django Admin | +| http://mpr.local.ar/api/docs | FastAPI Swagger | +| http://mpr.local.ar/ui | Timeline UI | + +## Commands + +```bash +cd ctrl + +# Start/stop +docker compose up -d +docker compose down + +# Rebuild after code changes +docker compose up -d --build + +# View logs +docker compose logs -f +docker compose logs -f celery + +# Create admin user +docker compose exec django python manage.py createsuperuser +``` + +## Code Generation + +Models are defined in `schema/models/` and generate: +- Django ORM models +- Pydantic schemas +- TypeScript types +- Protobuf definitions + +```bash +# Regenerate all +python schema/generate.py --all + +# Or specific targets +python schema/generate.py --django +python schema/generate.py --pydantic +python schema/generate.py --typescript +python schema/generate.py --proto +``` + +## Project Structure + +``` +mpr/ +├── api/ # FastAPI application +│ ├── routes/ # API endpoints +│ └── schemas/ # Pydantic models (generated) +├── core/ # Core utilities +│ └── ffmpeg/ # FFmpeg wrappers +├── ctrl/ # Docker & deployment +│ ├── docker-compose.yml +│ └── nginx.conf +├── docs/ # Architecture diagrams +├── grpc/ # gRPC server & client +│ └── protos/ # Protobuf definitions (generated) +├── mpr/ # Django project +│ └── media_assets/ # Django app +├── schema/ # Source of truth +│ └── models/ # Dataclass definitions +├── ui/ # Frontend +│ └── timeline/ # React app +└── worker/ # Job execution + ├── executor.py # Executor abstraction + └── tasks.py # Celery tasks +``` + +## Environment Variables + +See `ctrl/.env.template` for all configuration options. + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | sqlite | PostgreSQL connection string | +| `REDIS_URL` | redis://localhost:6379 | Redis for Celery | +| `GRPC_HOST` | grpc | gRPC server hostname | +| `GRPC_PORT` | 50051 | gRPC server port | +| `MPR_EXECUTOR` | local | Executor type (local/lambda) | + +## License + +MIT diff --git a/schema/generate.py b/schema/generate.py index a663cab..dee2ce1 100755 --- a/schema/generate.py +++ b/schema/generate.py @@ -23,7 +23,7 @@ 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 +from schema.models import API_MODELS, DATACLASSES, ENUMS, GRPC_MESSAGES, GRPC_SERVICE # ============================================================================= # Type Dispatch Tables @@ -520,11 +520,18 @@ def generate_typescript() -> str: lines.append(f"export type {enum.__name__} = {values};") lines.append("") - # Interfaces + # Interfaces - domain models for cls in DATACLASSES: lines.extend(generate_ts_interface(cls)) lines.append("") + # Interfaces - API request/response models + lines.append("// API Request/Response Types") + lines.append("") + for cls in API_MODELS: + lines.extend(generate_ts_interface(cls)) + lines.append("") + return "\n".join(lines) diff --git a/schema/models/__init__.py b/schema/models/__init__.py index f6dc7e6..6caf364 100644 --- a/schema/models/__init__.py +++ b/schema/models/__init__.py @@ -5,6 +5,7 @@ This module exports all dataclasses, enums, and constants that the generator should process. Add new models here to have them included in generation. """ +from .api import CreateJobRequest, SystemStatus from .grpc import ( GRPC_SERVICE, CancelRequest, @@ -23,6 +24,10 @@ from .presets import BUILTIN_PRESETS, TranscodePreset # Core domain models - generates Django, Pydantic, TypeScript DATACLASSES = [MediaAsset, TranscodePreset, TranscodeJob] +# API request/response models - generates TypeScript only (no Django) +# WorkerStatus from grpc.py is reused here +API_MODELS = [CreateJobRequest, SystemStatus, WorkerStatus] + # Status enums - included in generated code ENUMS = [AssetStatus, JobStatus] @@ -43,6 +48,9 @@ __all__ = [ "MediaAsset", "TranscodePreset", "TranscodeJob", + # API Models + "CreateJobRequest", + "SystemStatus", # Enums "AssetStatus", "JobStatus", @@ -58,6 +66,7 @@ __all__ = [ "Empty", # For generator "DATACLASSES", + "API_MODELS", "ENUMS", "GRPC_MESSAGES", "BUILTIN_PRESETS", diff --git a/schema/models/api.py b/schema/models/api.py new file mode 100644 index 0000000..1bf3e1f --- /dev/null +++ b/schema/models/api.py @@ -0,0 +1,32 @@ +""" +API Request/Response Schema Definitions + +These are separate from the main domain models and represent +the shape of data sent to/from the API endpoints. +""" + +from dataclasses import dataclass +from typing import Optional +from uuid import UUID + + +@dataclass +class CreateJobRequest: + """Request body for creating a transcode/trim job.""" + + source_asset_id: UUID + preset_id: Optional[UUID] = None + trim_start: Optional[float] = None # seconds + trim_end: Optional[float] = None # seconds + output_filename: Optional[str] = None + + +@dataclass +class SystemStatus: + """System status response.""" + + status: str + version: str + + +# Note: WorkerStatus is defined in grpc.py and reused here diff --git a/ui/timeline/Dockerfile b/ui/timeline/Dockerfile new file mode 100644 index 0000000..41cda69 --- /dev/null +++ b/ui/timeline/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev"] diff --git a/ui/timeline/index.html b/ui/timeline/index.html new file mode 100644 index 0000000..86291e0 --- /dev/null +++ b/ui/timeline/index.html @@ -0,0 +1,12 @@ + + + + + + MPR Timeline + + +
+ + + diff --git a/ui/timeline/package.json b/ui/timeline/package.json new file mode 100644 index 0000000..7693ea0 --- /dev/null +++ b/ui/timeline/package.json @@ -0,0 +1,23 @@ +{ + "name": "mpr-timeline", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "hls.js": "^1.5.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.3.0", + "vite": "^5.0.0" + } +} diff --git a/ui/timeline/src/App.css b/ui/timeline/src/App.css new file mode 100644 index 0000000..754af87 --- /dev/null +++ b/ui/timeline/src/App.css @@ -0,0 +1,188 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #1a1a1a; + color: #e0e0e0; +} + +.app { + display: flex; + flex-direction: column; + height: 100vh; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: #252525; + border-bottom: 1px solid #333; +} + +.header h1 { + font-size: 1.25rem; + font-weight: 600; +} + +.status { + font-size: 0.875rem; + color: #888; +} + +.layout { + display: flex; + flex: 1; + overflow: hidden; +} + +.sidebar { + width: 280px; + background: #202020; + border-right: 1px solid #333; + overflow-y: auto; +} + +.sidebar h2 { + padding: 1rem; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #888; +} + +.asset-list { + list-style: none; +} + +.asset-list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + cursor: pointer; + border-bottom: 1px solid #2a2a2a; +} + +.asset-list li:hover { + background: #2a2a2a; +} + +.asset-list li.selected { + background: #333; +} + +.filename { + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-badge { + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + border-radius: 4px; + text-transform: uppercase; +} + +.status-badge.pending { + background: #f59e0b; + color: #000; +} + +.status-badge.ready { + background: #10b981; + color: #000; +} + +.status-badge.error { + background: #ef4444; + color: #fff; +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.empty, +.loading, +.error { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #666; +} + +.error { + color: #ef4444; +} + +.editor { + display: flex; + flex-direction: column; + height: 100%; +} + +.video-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #000; + min-height: 0; +} + +.video-container video { + max-width: 100%; + max-height: 100%; +} + +.timeline-container { + height: 120px; + background: #252525; + border-top: 1px solid #333; +} + +.timeline-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #666; +} + +.info { + padding: 1rem; + background: #202020; + border-top: 1px solid #333; +} + +.info h3 { + margin-bottom: 0.5rem; + font-size: 1rem; +} + +.info dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 1rem; + font-size: 0.875rem; +} + +.info dt { + color: #888; +} + +.info dd { + color: #e0e0e0; +} diff --git a/ui/timeline/src/App.tsx b/ui/timeline/src/App.tsx new file mode 100644 index 0000000..ebf9944 --- /dev/null +++ b/ui/timeline/src/App.tsx @@ -0,0 +1,107 @@ +import { useState, useEffect } from 'react' +import { getAssets, getSystemStatus } from './api' +import type { MediaAsset, SystemStatus } from './types' +import './App.css' + +function App() { + const [assets, setAssets] = useState([]) + const [status, setStatus] = useState(null) + const [selectedAsset, setSelectedAsset] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function load() { + try { + const [assetsData, statusData] = await Promise.all([ + getAssets(), + getSystemStatus(), + ]) + setAssets(assetsData) + setStatus(statusData) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load') + } finally { + setLoading(false) + } + } + load() + }, []) + + if (loading) { + return
Loading...
+ } + + if (error) { + return
Error: {error}
+ } + + return ( +
+
+

MPR Timeline

+ {status && ( + + {status.status} v{status.version} + + )} +
+ +
+ + +
+ {selectedAsset ? ( +
+
+
+
+ {/* Timeline component will go here */} +
+ Timeline: {selectedAsset.duration?.toFixed(1)}s +
+
+
+

{selectedAsset.filename}

+
+
Duration
+
{selectedAsset.duration?.toFixed(2)}s
+
Resolution
+
{selectedAsset.width}x{selectedAsset.height}
+
Video
+
{selectedAsset.video_codec}
+
Audio
+
{selectedAsset.audio_codec}
+
+
+
+ ) : ( +
Select an asset to begin
+ )} +
+
+
+ ) +} + +export default App diff --git a/ui/timeline/src/api.ts b/ui/timeline/src/api.ts new file mode 100644 index 0000000..dbf6280 --- /dev/null +++ b/ui/timeline/src/api.ts @@ -0,0 +1,75 @@ +/** + * API client for FastAPI backend + */ + +import type { + MediaAsset, + TranscodePreset, + TranscodeJob, + CreateJobRequest, + SystemStatus, + WorkerStatus, +} from "./types"; + +const API_BASE = "/api"; + +async function request(path: string, options?: RequestInit): Promise { + const response = await fetch(`${API_BASE}${path}`, { + headers: { + "Content-Type": "application/json", + }, + ...options, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`API error: ${response.status} - ${error}`); + } + + return response.json(); +} + +// Assets +export async function getAssets(): Promise { + return request("/assets/"); +} + +export async function getAsset(id: string): Promise { + return request(`/assets/${id}`); +} + +// Presets +export async function getPresets(): Promise { + return request("/presets/"); +} + +// Jobs +export async function getJobs(): Promise { + return request("/jobs/"); +} + +export async function getJob(id: string): Promise { + return request(`/jobs/${id}`); +} + +export async function createJob(data: CreateJobRequest): Promise { + return request("/jobs/", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function cancelJob(id: string): Promise { + return request(`/jobs/${id}/cancel`, { + method: "POST", + }); +} + +// System +export async function getSystemStatus(): Promise { + return request("/system/status"); +} + +export async function getWorkerStatus(): Promise { + return request("/system/worker"); +} diff --git a/ui/timeline/src/main.tsx b/ui/timeline/src/main.tsx new file mode 100644 index 0000000..cf91b93 --- /dev/null +++ b/ui/timeline/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('app')!).render( + + + +) diff --git a/ui/timeline/src/types.ts b/ui/timeline/src/types.ts index 41719a0..91d7f3f 100644 --- a/ui/timeline/src/types.ts +++ b/ui/timeline/src/types.ts @@ -72,3 +72,25 @@ export interface TranscodeJob { started_at: string | null; completed_at: string | null; } + +// API Request/Response Types + +export interface CreateJobRequest { + source_asset_id: string; + preset_id: string | null; + trim_start: number | null; + trim_end: number | null; + output_filename: string | null; +} + +export interface SystemStatus { + status: string; + version: string; +} + +export interface WorkerStatus { + available: boolean; + active_jobs: number; + supported_codecs: string[]; + gpu_available: boolean; +} diff --git a/ui/timeline/src/vite-env.d.ts b/ui/timeline/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/ui/timeline/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/timeline/tsconfig.json b/ui/timeline/tsconfig.json new file mode 100644 index 0000000..5b742c8 --- /dev/null +++ b/ui/timeline/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "references": [{ "path": "./tsconfig.node.json" }], +} diff --git a/ui/timeline/tsconfig.node.json b/ui/timeline/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/ui/timeline/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/ui/timeline/vite.config.ts b/ui/timeline/vite.config.ts new file mode 100644 index 0000000..300f1ab --- /dev/null +++ b/ui/timeline/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + host: "0.0.0.0", + port: 5173, + proxy: { + "/api": { + target: "http://fastapi:8702", + changeOrigin: true, + }, + }, + }, +});