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

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