Initial commit

This commit is contained in:
2026-06-02 18:59:31 +02:00
commit 8fca56e20e
29 changed files with 3898 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
.dist
dist
.vscode
+16
View File
@@ -0,0 +1,16 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+11
View File
@@ -0,0 +1,11 @@
# Frontend
React + MUI client for the Video Cutter backend.
## Local dev
- Install Node.js 20+
- npm install
- npm run dev
## API
- Uses /api proxy during dev
+12
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>Video Cutter Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
client_max_body_size 2g;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri /index.html;
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "video-cutter-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^5.15.21",
"@mui/material": "^5.15.21",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.2"
}
}
+1620
View File
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
import { Job, Project, SegmentEdit, Video } from "./types";
const rawBase = import.meta.env.VITE_API_BASE ?? "";
const apiBase = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
if (!(options.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(`${apiBase}${path}`, {
...options,
headers,
});
if (!response.ok) {
let message = `Request failed (${response.status})`;
try {
const data = await response.json();
if (data?.detail) {
message = data.detail;
} else if (data?.message) {
message = data.message;
}
} catch {
// ignore parse errors
}
throw new ApiError(response.status, message);
}
if (response.status === 204) {
return {} as T;
}
return (await response.json()) as T;
}
export async function getCurrentProject(): Promise<Project> {
return request("/api/projects/current");
}
export async function createProject(payload: {
name: string;
segments_count: number;
intro_seconds: number;
outro_seconds: number;
reencode_enabled?: boolean;
ffmpeg_pass1_template?: string | null;
ffmpeg_pass2_template?: string | null;
}): Promise<Project> {
return request("/api/projects", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function updateProject(payload: {
name?: string;
segments_count?: number;
intro_seconds?: number;
outro_seconds?: number;
reencode_enabled?: boolean;
ffmpeg_pass1_template?: string | null;
ffmpeg_pass2_template?: string | null;
}): Promise<Project> {
return request("/api/projects/current", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export async function uploadVideo(file: File): Promise<Video> {
const formData = new FormData();
formData.append("file", file);
return request("/api/videos/upload", {
method: "POST",
body: formData,
});
}
export async function listVideos(): Promise<Video[]> {
return request("/api/videos");
}
export async function deleteVideo(videoId: string): Promise<void> {
await request(`/api/videos/${videoId}`, {
method: "DELETE",
});
}
export async function getVideoMetadata(videoId: string): Promise<{ fps: number | null }> {
return request(`/api/videos/${videoId}/metadata`);
}
export async function listMarkers(videoId: string): Promise<{ markers: number[] }> {
return request(`/api/videos/${videoId}/markers`);
}
export async function replaceMarkers(
videoId: string,
markers: number[]
): Promise<{ markers: number[] }> {
return request(`/api/videos/${videoId}/markers`, {
method: "PUT",
body: JSON.stringify({ markers }),
});
}
export async function uploadVideos(files: File[]): Promise<Video[]> {
const uploaded: Video[] = [];
for (const file of files) {
uploaded.push(await uploadVideo(file));
}
return uploaded;
}
export async function replaceSegmentEdits(
videoId: string,
segments: { segment_key: string; start_seconds: number; end_seconds: number }[]
): Promise<{ segments: SegmentEdit[] }> {
return request(`/api/videos/${videoId}/segment-edits`, {
method: "PUT",
body: JSON.stringify({ segments }),
});
}
export async function autoCutVideo(videoId: string, windowSeconds?: number): Promise<Job> {
return request(`/api/videos/${videoId}/autocut`, {
method: "POST",
body: JSON.stringify({ window_seconds: windowSeconds }),
});
}
export async function splitVideo(
videoId: string,
markers: number[],
outputPrefix?: string
): Promise<Job> {
return request(`/api/videos/${videoId}/split`, {
method: "POST",
body: JSON.stringify({ markers, output_prefix: outputPrefix }),
});
}
export async function splitAllVideos(videoIds?: string[]): Promise<Job> {
return request("/api/videos/split-all", {
method: "POST",
body: JSON.stringify({ video_ids: videoIds }),
});
}
export async function getJob(jobId: string): Promise<Job> {
return request(`/api/jobs/${jobId}`);
}
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+37
View File
@@ -0,0 +1,37 @@
import { createTheme } from "@mui/material/styles";
export type ColorMode = "light" | "dark";
export function buildTheme(mode: ColorMode) {
return createTheme({
palette: {
mode,
primary: {
main: "#1976d2",
},
secondary: {
main: "#dc004e",
},
background: {
default: mode === "dark" ? "#121212" : "#f5f5f5",
paper: mode === "dark" ? "#1e1e1e" : "#ffffff",
},
},
shape: {
borderRadius: 8,
},
components: {
MuiCard: {
styleOverrides: {
root: {
boxShadow:
mode === "dark"
? "0px 2px 4px rgba(0, 0, 0, 0.4)"
: "0px 2px 4px rgba(0, 0, 0, 0.1)",
},
},
},
},
});
}
+47
View File
@@ -0,0 +1,47 @@
export type Project = {
id: string;
name: string;
segments_count: number;
intro_seconds: number;
outro_seconds: number;
reencode_enabled: boolean;
ffmpeg_pass1_template: string | null;
ffmpeg_pass2_template: string | null;
created_at: string;
updated_at: string;
};
export type Video = {
id: string;
project_id: string;
filename: string;
file_path: string;
duration_seconds: number;
created_at: string;
};
export type Job = {
id: string;
kind: string;
status: "queued" | "running" | "completed" | "failed";
progress: number;
message: string | null;
details: {
stage?: string;
stage_percent?: number;
stage_eta?: string | null;
total_eta?: string | null;
label?: string;
} | null;
logs: { at: string; message: string }[];
result: Record<string, unknown> | null;
created_at: string;
updated_at: string;
};
export type SegmentEdit = {
segment_key: string;
start_seconds: number;
end_seconds: number;
modified_at: string;
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"useDefineForClassFields": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:8000",
},
},
});