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,
+ },
+ },
+ },
+});