timeline and readme

This commit is contained in:
2026-02-03 14:00:20 -03:00
parent a5057ba412
commit 3db8c0c453
16 changed files with 682 additions and 2 deletions

136
README.md Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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",

32
schema/models/api.py Normal file
View File

@@ -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

12
ui/timeline/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

12
ui/timeline/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MPR Timeline</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

23
ui/timeline/package.json Normal file
View File

@@ -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"
}
}

188
ui/timeline/src/App.css Normal file
View File

@@ -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;
}

107
ui/timeline/src/App.tsx Normal file
View File

@@ -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<MediaAsset[]>([])
const [status, setStatus] = useState<SystemStatus | null>(null)
const [selectedAsset, setSelectedAsset] = useState<MediaAsset | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 <div className="loading">Loading...</div>
}
if (error) {
return <div className="error">Error: {error}</div>
}
return (
<div className="app">
<header className="header">
<h1>MPR Timeline</h1>
{status && (
<span className="status">
{status.status} v{status.version}
</span>
)}
</header>
<div className="layout">
<aside className="sidebar">
<h2>Assets</h2>
<ul className="asset-list">
{assets.map((asset) => (
<li
key={asset.id}
className={selectedAsset?.id === asset.id ? 'selected' : ''}
onClick={() => setSelectedAsset(asset)}
>
<span className="filename">{asset.filename}</span>
<span className={`status-badge ${asset.status}`}>
{asset.status}
</span>
</li>
))}
</ul>
</aside>
<main className="main">
{selectedAsset ? (
<div className="editor">
<div className="video-container">
<video
controls
src={`/media/${selectedAsset.file_path}`}
/>
</div>
<div className="timeline-container">
{/* Timeline component will go here */}
<div className="timeline-placeholder">
Timeline: {selectedAsset.duration?.toFixed(1)}s
</div>
</div>
<div className="info">
<h3>{selectedAsset.filename}</h3>
<dl>
<dt>Duration</dt>
<dd>{selectedAsset.duration?.toFixed(2)}s</dd>
<dt>Resolution</dt>
<dd>{selectedAsset.width}x{selectedAsset.height}</dd>
<dt>Video</dt>
<dd>{selectedAsset.video_codec}</dd>
<dt>Audio</dt>
<dd>{selectedAsset.audio_codec}</dd>
</dl>
</div>
</div>
) : (
<div className="empty">Select an asset to begin</div>
)}
</main>
</div>
</div>
)
}
export default App

75
ui/timeline/src/api.ts Normal file
View File

@@ -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<T>(path: string, options?: RequestInit): Promise<T> {
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<MediaAsset[]> {
return request("/assets/");
}
export async function getAsset(id: string): Promise<MediaAsset> {
return request(`/assets/${id}`);
}
// Presets
export async function getPresets(): Promise<TranscodePreset[]> {
return request("/presets/");
}
// Jobs
export async function getJobs(): Promise<TranscodeJob[]> {
return request("/jobs/");
}
export async function getJob(id: string): Promise<TranscodeJob> {
return request(`/jobs/${id}`);
}
export async function createJob(data: CreateJobRequest): Promise<TranscodeJob> {
return request("/jobs/", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function cancelJob(id: string): Promise<TranscodeJob> {
return request(`/jobs/${id}/cancel`, {
method: "POST",
});
}
// System
export async function getSystemStatus(): Promise<SystemStatus> {
return request("/system/status");
}
export async function getWorkerStatus(): Promise<WorkerStatus> {
return request("/system/worker");
}

9
ui/timeline/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('app')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -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;
}

1
ui/timeline/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
ui/timeline/tsconfig.json Normal file
View File

@@ -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" }],
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

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