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
+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" />