Backend MP4 conversion (#23)

* ADD conversion queue

* ADD RemuxService for MP4 conversion

* REMOVE unused conversion queue

* REORGANISE Job-related classes

* ADD Job stages

* REVERT to old commit, using Spring Async instead

* ADD asynchronous processing for video tasks

* PATCH and streamline progress tracking

* ADD asynchronous video processing and job restructuring

* REFACTOR job service method

* ADD job remux functionality

* ADD remuxing endpoint

* PATCH complete flag not updating in API response

* ADD progress type in frontend

* ADD reset functionality for job status

* PATCH missing progress bar for subsequent exports

* REDESIGN settings box

* ADD tracking video file conversion in frontend

* PATCH extension bug

* REMOVE autowired decorator
This commit is contained in:
Dylan De Faoite
2025-07-31 20:48:34 +02:00
committed by GitHub
parent 338eb605fd
commit 20f7ec8db4
24 changed files with 324 additions and 185 deletions

View File

@@ -9,7 +9,7 @@ type prop = {
export default function ConfigBox({setMetadata, className}: prop) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
var vals = e.target.value.split(",");
const vals = e.target.value.split(",");
setMetadata((prevState: VideoMetadata) => ({
...prevState,
width: parseInt(vals[0]),
@@ -33,14 +33,14 @@ export default function ConfigBox({setMetadata, className}: prop) {
return (
<div className={clsx("flex flex-col gap-2 p-10", className)}>
<h2 className={"text-3xl font-bold mb-4"}>Export Settings</h2>
<h2 className={"text-xl font-bold"}>Export</h2>
<Selector label={"Resolution"}>
<select id="resolution"
name="resolution"
defaultValue="1280,720"
onChange={updateRes}
className={"border-black bg-gray-200 rounded-md w-full"}>
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
<option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option>
<option value="1920,1080">1080p</option>
@@ -55,7 +55,7 @@ export default function ConfigBox({setMetadata, className}: prop) {
name="fps"
defaultValue="30"
onChange={updateFps}
className={"border-black bg-gray-200 rounded-md w-full"}>
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
<option value="60">60</option>
<option value="30">30</option>
<option value="15">15</option>
@@ -67,7 +67,7 @@ export default function ConfigBox({setMetadata, className}: prop) {
min="1"
defaultValue="10"
onChange={updateFileSize}
className={"border-black bg-gray-200 rounded-md w-full"}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
/>
</Selector>

View File

@@ -1,6 +1,5 @@
import clsx from "clsx";
import type {VideoMetadata} from "../../utils/types.ts";
import Selector from "../Selector.tsx";
type MetadataBoxProps = {
setMetadata: Function
@@ -10,30 +9,18 @@ type MetadataBoxProps = {
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
return (
<div className={clsx("flex flex-col content-between p-10 gap-2", className)}>
<h2 className={"text-3xl font-bold mb-4 col-span-2"}>Metadata Settings</h2>
{/*<h2 className={"text-2xl font-bold col-span-2"}>Metadata</h2>*/}
<Selector label={"Title"}>
<input
type="text"
placeholder="Enter title"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
...prevState,
title: e.target.value
}))}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
/>
</Selector>
<Selector label={"Description"}>
<textarea
placeholder="Enter description"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
...prevState,
description: e.target.value
}))}
className={"border-black bg-gray-200 rounded-md w-full p-2 pb-10 resize-none max-h"}
/>
</Selector>
<p className={"w-full font-bold text-xl "}>Title</p>
<input
type="text"
placeholder="Enter title"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
...prevState,
title: e.target.value
}))}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
/>
</div>
)
}

View File

@@ -49,11 +49,13 @@ const ClipEdit = () => {
const pollProgress = async (id: string, intervalId: number) => {
getProgress(id)
.then((progress) => {
setProgress(progress);
setProgress(progress.process.progress);
if (progress >= 1) {
if (progress.process.complete) {
clearInterval(intervalId);
setDownloadable(true);
} else {
setDownloadable(false)
}
})
.catch((err: Error) => {

View File

@@ -1,25 +1,75 @@
import {useState} from "react";
import {useNavigate} from "react-router-dom";
import { uploadFile } from "../utils/endpoints"
import {uploadFile, convertFile, getProgress} from "../utils/endpoints"
import BlueButton from "../components/buttons/BlueButton.tsx";
import Box from "../components/Box.tsx";
const clipUpload = () => {
const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null);
const navigate = useNavigate();
const [error, setError] = useState<null | string>(null);
const [progress, setProgress] = useState<number>(0);
const isVideoFileSupported = (file: File): boolean => {
const video = document.createElement("video");
if (file.type && video.canPlayType(file.type) !== "") {
return true;
}
const extension = file.name.split(".").pop()?.toLowerCase();
const extensionToMime: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
ogg: "video/ogg",
mov: "video/quicktime",
};
if (extension && extensionToMime[extension]) {
return video.canPlayType(extensionToMime[extension]) !== "";
}
return false;
};
const press = (() => {
if (!file) {
setError("Please choose a file");
return;
}
uploadFile(file)
.then(uuid => navigate(`/create/${uuid}`))
.then(uuid => {
if (isVideoFileSupported(file)) {
navigate(`/create/${uuid}`)
} else {
convertFile(uuid);
const interval = setInterval(async() => await pollProgress(uuid, interval), 500);
}
})
.catch((e: Error) => setError(`Failed to upload file: ${e.message}`));
});
const pollProgress = async (id: string, intervalId: number) => {
getProgress(id)
.then((progress) => {
setProgress(progress.conversion.progress);
if (progress.conversion.complete) {
clearInterval(intervalId);
navigate(`/create/${id}`)
}
})
.catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId);
});
}
return (
<Box className={"flex flex-col justify-between gap-3 p-5"}>
<input
@@ -37,8 +87,12 @@ const clipUpload = () => {
</BlueButton>
<label className={"text-center text-red-500"}>{error}</label>
<progress
value={progress}
className={"bg-gray-300 rounded-lg h-1"}>
</progress>
</Box>
)
};
export default clipUpload;
export default ClipUpload;

View File

@@ -1,4 +1,4 @@
import type {VideoMetadata, APIResponse, User, Clip} from "./types.ts";
import type {VideoMetadata, APIResponse, User, Clip, ProgressResult } from "./types.ts";
/**
* Uploads a file to the backend.
@@ -74,11 +74,25 @@ const processFile = async (uuid: string) => {
}
};
const convertFile = async (uuid: string) => {
const response = await fetch(`/api/v1/convert/${uuid}`);
if (!response.ok) {
throw new Error(`Failed to convert file: ${response.status}`);
}
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error("Failed to convert file: " + result.message);
}
};
/**
* Fetches the processing progress percentage.
* @param uuid - The UUID of the video file.
*/
const getProgress = async (uuid: string): Promise<number> => {
const getProgress = async (uuid: string): Promise<ProgressResult> => {
const response = await fetch(`/api/v1/progress/${uuid}`);
if (!response.ok) {
@@ -91,11 +105,11 @@ const getProgress = async (uuid: string): Promise<number> => {
throw new Error(`Failed to fetch progress: ${result.message}`);
}
if (!result.data || typeof result.data.progress !== 'number') {
if (!result.data) {
throw new Error('Invalid progress data received');
}
return result.data.progress;
return result.data;
};
/**
@@ -189,6 +203,7 @@ export {
uploadFile,
editFile,
processFile,
convertFile,
getProgress,
getMetadata,
getUser,

View File

@@ -30,9 +30,21 @@ type Clip = {
createdAt: string,
}
type ProgressResult = {
process: {
progress: number,
complete: boolean
};
conversion: {
progress: number,
complete: boolean
};
};
export type {
APIResponse,
VideoMetadata,
User,
Clip
Clip,
ProgressResult
}