Initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.dist
|
||||
dist
|
||||
.vscode
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
@@ -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>
|
||||
);
|
||||
@@ -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)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user