timeline and readme
This commit is contained in:
136
README.md
Normal file
136
README.md
Normal 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
|
||||||
@@ -23,7 +23,7 @@ from typing import Any, Callable, Union, get_args, get_origin, get_type_hints
|
|||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
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
|
# Type Dispatch Tables
|
||||||
@@ -520,11 +520,18 @@ def generate_typescript() -> str:
|
|||||||
lines.append(f"export type {enum.__name__} = {values};")
|
lines.append(f"export type {enum.__name__} = {values};")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Interfaces
|
# Interfaces - domain models
|
||||||
for cls in DATACLASSES:
|
for cls in DATACLASSES:
|
||||||
lines.extend(generate_ts_interface(cls))
|
lines.extend(generate_ts_interface(cls))
|
||||||
lines.append("")
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
should process. Add new models here to have them included in generation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .api import CreateJobRequest, SystemStatus
|
||||||
from .grpc import (
|
from .grpc import (
|
||||||
GRPC_SERVICE,
|
GRPC_SERVICE,
|
||||||
CancelRequest,
|
CancelRequest,
|
||||||
@@ -23,6 +24,10 @@ from .presets import BUILTIN_PRESETS, TranscodePreset
|
|||||||
# Core domain models - generates Django, Pydantic, TypeScript
|
# Core domain models - generates Django, Pydantic, TypeScript
|
||||||
DATACLASSES = [MediaAsset, TranscodePreset, TranscodeJob]
|
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
|
# Status enums - included in generated code
|
||||||
ENUMS = [AssetStatus, JobStatus]
|
ENUMS = [AssetStatus, JobStatus]
|
||||||
|
|
||||||
@@ -43,6 +48,9 @@ __all__ = [
|
|||||||
"MediaAsset",
|
"MediaAsset",
|
||||||
"TranscodePreset",
|
"TranscodePreset",
|
||||||
"TranscodeJob",
|
"TranscodeJob",
|
||||||
|
# API Models
|
||||||
|
"CreateJobRequest",
|
||||||
|
"SystemStatus",
|
||||||
# Enums
|
# Enums
|
||||||
"AssetStatus",
|
"AssetStatus",
|
||||||
"JobStatus",
|
"JobStatus",
|
||||||
@@ -58,6 +66,7 @@ __all__ = [
|
|||||||
"Empty",
|
"Empty",
|
||||||
# For generator
|
# For generator
|
||||||
"DATACLASSES",
|
"DATACLASSES",
|
||||||
|
"API_MODELS",
|
||||||
"ENUMS",
|
"ENUMS",
|
||||||
"GRPC_MESSAGES",
|
"GRPC_MESSAGES",
|
||||||
"BUILTIN_PRESETS",
|
"BUILTIN_PRESETS",
|
||||||
|
|||||||
32
schema/models/api.py
Normal file
32
schema/models/api.py
Normal 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
12
ui/timeline/Dockerfile
Normal 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
12
ui/timeline/index.html
Normal 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
23
ui/timeline/package.json
Normal 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
188
ui/timeline/src/App.css
Normal 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
107
ui/timeline/src/App.tsx
Normal 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
75
ui/timeline/src/api.ts
Normal 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
9
ui/timeline/src/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
@@ -72,3 +72,25 @@ export interface TranscodeJob {
|
|||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
completed_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
1
ui/timeline/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
21
ui/timeline/tsconfig.json
Normal file
21
ui/timeline/tsconfig.json
Normal 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" }],
|
||||||
|
}
|
||||||
10
ui/timeline/tsconfig.node.json
Normal file
10
ui/timeline/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
16
ui/timeline/vite.config.ts
Normal file
16
ui/timeline/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user