Initial commit
This commit is contained in:
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" />
|
||||
Reference in New Issue
Block a user