gif conversion prototype; Toast directory cleanup

This commit is contained in:
Pogodaanton 2020-06-18 13:42:18 +02:00
parent d5e4c2f808
commit aaf2bc9859
19 changed files with 460 additions and 36 deletions

View File

@ -29,6 +29,7 @@
"copy-webpack-plugin": "^5.1.1",
"focus-visible": "^4.1.5",
"framer-motion": "2.0.0-beta.75",
"gif.js": "^0.2.0",
"i18next": "^19.4.4",
"i18next-browser-languagedetector": "^4.1.1",
"i18next-xhr-backend": "^3.2.2",

View File

@ -2,6 +2,7 @@
require_once "../protected/config.php";
require_once "../protected/db.inc.php";
require_once "../protected/output.inc.php";
header("Content-type: application/json");
session_start();
// Request can only be GET
@ -18,23 +19,93 @@ if (!isset($_SESSION["u_id"])) {
$type = $_POST["type"];
if (!isset($type)) error("Missing argument \"type\".", 401);
header("Content-type: application/json");
switch ($type) {
case "video-thumbnail":
$file_id = $_POST["id"];
if (!isset($file_id)) error("Missing argument \"id\".", 401);
if ($type === "video-thumbnail") {
$file_id = $_POST["id"];
if (!isset($file_id)) error("Missing argument \"id\".", 401);
// Uploaded file size must not exceed 2gb
$file = $_FILES["data"];
if ($file["size"] > 1.342e+8 || !isset($file["size"])) {
error("Either no file was provided or the size exceeded the predefined limit of the server.");
}
// Uploaded file size must not exceed 2gb
$file = $_FILES["data"];
if ($file["size"] > 1.342e+8 || !isset($file["size"])) {
error("Either no file was provided or the size exceeded the predefined limit of the server.");
}
generate_thumbnail($GLOBALS["upload_directory"] . $file_id . ".thumb.jpg", $file["tmp_name"], $file["type"]);
generate_thumbnail($GLOBALS["upload_directory"] . $file_id . ".thumb.jpg", $file["tmp_name"], $file["type"]);
$sql = "UPDATE `" . $GLOBALS["table_prefix"] . "file_tasks` SET thumbnail=0 WHERE id=?";
$db->request($sql, "s", $file_id);
break;
$sql = "UPDATE `" . $GLOBALS["table_prefix"] . "file_tasks` SET thumbnail=0 WHERE id=?";
$db->request($sql, "s", $file_id);
die();
case "video-gif-upload":
case "video-gif-stitch":
$file_id = $_POST["id"];
$chunk_number = $_POST["chunkNum"];
if (!isset($file_id)) error("Missing argument \"id\".", 401);
if (!isset($chunk_number)) error("Missing argument \"chunkNum\".", 401);
$temp_dir = "../.temp";
$chunk_path = $temp_dir . "/" . $file_id . "-";
mkdir($temp_dir);
if ($type === "video-gif-upload") {
// Uploaded file size must not exceed 2gb
$file = $_FILES["data"];
if ($file["size"] > 1.342e+8 || !isset($file["size"])) {
error("Either no file was provided or the size exceeded the predefined limit of the server.");
}
// Appending $chunk_number to keep chunk order
$chunk_path = $chunk_path . $chunk_number;
// Check if temporary file exists in order to avoid upload collisions
if (file_exists($chunk_path)) {
error("File already exists; Uploading is not needed.", 423);
}
if (!move_uploaded_file($file['tmp_name'], $chunk_path)) {
error("File is either too big or no file was sent.", 500);
}
return;
}
// Stitching procedure
// We use $chunk_number in this context as the highest chunk number
$stitch_path = $chunk_path . "stitched";
if (file_exists($stitch_path)) {
error("Stitched file already exists", 423);
}
for ($i = 0; $i < $chunk_number; $i++) {
try {
$chunk_i_path = $chunk_path . $i;
$file = fopen($chunk_i_path, 'rb');
$buff = fread($file, 1024 * 1024);
fclose($file);
$final = fopen($stitch_path, 'ab');
$write = fwrite($final, $buff);
fclose($final);
unlink($chunk_i_path);
} catch (Exception $e) {
error("Error while stitching: " . $e);
}
}
// Generate optimized gif from upload and delete temporary image
// MySQL update entry happens with the next switch case, since there is no `break`
generate_gif($GLOBALS["upload_directory"] . $file_id . ".gif", $stitch_path);
unlink($stitch_path);
case "video-gif-too-big":
$file_id = $_POST["id"];
$sql = "UPDATE `" . $GLOBALS["table_prefix"] . "file_tasks` SET gif=0 WHERE id=?";
$db->request($sql, "s", $file_id);
break;
default:
error("Invalid argument value for \"type\".");
break;
}
error("Invalid argument value for \"type\".");

View File

@ -20,12 +20,28 @@ if (!isset($type)) {
error("Missing argument \"type\".", 401);
}
if ($type === "video-thumbnail") {
$sql = "SELECT id FROM `" . $GLOBALS["table_prefix"] . "file_tasks` WHERE thumbnail=1";
$result = mysqli_query($db->con, $sql);
header("Content-type: application/json");
echo json_encode($result->fetch_all(MYSQLI_ASSOC));
die();
// A valid column in the MySQL table
$tableColumn = "";
switch ($type) {
case "video-thumbnail":
$tableColumn = "thumbnail";
break;
case "video-gif":
$tableColumn = "gif";
break;
default:
error("Invalid argument value for \"type\".");
break;
}
error("Invalid argument value for \"type\".");
// Due to switch, this will only be executed if a valid $reqType was assigned.
$sql = "SELECT id FROM `" . $GLOBALS["table_prefix"] . "file_tasks` WHERE " . $tableColumn . "=1";
$result = mysqli_query($db->con, $sql);
if ($result === false) error("Unexpected response from server.");
header("Content-type: application/json");
echo json_encode($result->fetch_all(MYSQLI_ASSOC));

View File

@ -81,3 +81,30 @@ function generate_thumbnail(string $destination, string $image_path, string $mim
// Setting the thumbnail_height according to the newly generated image
return exec($GLOBALS["imagick_path"] . " identify -ping -format '%h' " . $destination);
}
/**
* Generates an optimized GIF from an input image.
* @param string $destination Destination path of the thumbnail.
* @param string $image_path A path to the image in question.
* @return string Width and height of the thumbnail; the width is always 200.
*/
function generate_gif(string $destination, string $image_path)
{
// Using an array to make this part more readable
$exec_array = array(
$GLOBALS["imagick_path"],
"convert",
$image_path,
"-colorspace RGB",
"-ordered-dither o8x8,8,8,4",
"+map",
$destination,
"2>&1",
);
// Combining arguments into exec string
exec(implode(" ", $exec_array));
// Setting the thumbnail_height according to the newly generated image
return exec($GLOBALS["imagick_path"] . " identify -ping -format '%wx%h' " . $destination);
}

View File

@ -82,14 +82,16 @@ class VideoPreprocessor
/**
* Since we cannot access any single frame of a video with
* plain PHP, we let the client generate the thumbnails for us.
*
* For that, we compile the ids of each video that needs a proper
* thumbnail and let the client handle the rest.
*
* We also let the client know that the video is not available in
* GIF form, as it still needs to be generated.
*/
private function add_thumbnail_generation_task()
private function add_additional_generation_tasks()
{
$sql = "INSERT INTO `" . $GLOBALS["table_prefix"] . "file_tasks` (id, thumbnail) VALUEs (?,?)";
$GLOBALS["db"]->request($sql, "si", $this->file_id, 1);
$sql = "INSERT INTO `" . $GLOBALS["table_prefix"] . "file_tasks` (id, thumbnail, gif) VALUEs (?,?,?)";
$GLOBALS["db"]->request($sql, "sii", $this->file_id, 1, 1);
}
/**
@ -102,7 +104,7 @@ class VideoPreprocessor
throw new ErrorException("Generating temporary video thumbnail did not succeed.");
}
$this->add_thumbnail_generation_task();
$this->add_additional_generation_tasks();
return array(
"file_width" => $this->file_width,

View File

@ -74,7 +74,7 @@ module.exports = {
/**
* Force copy everything to build directory
* and include getID3
* and include getID3 & gif.js
*/
try {
config.plugins.push(
@ -140,6 +140,12 @@ module.exports = {
to: "protected/getID3",
flatten: true,
},
{
from: "submodules/gif.js/",
to: "static/js",
ignore: ["*.old.*"],
force: true,
},
])
);
} catch (err) {

View File

@ -1,4 +1,5 @@
// We need to declare userData as part of the window object
interface Window {
/**
* The backend sets a global "userData" object
@ -34,3 +35,4 @@ interface Window {
}
declare module "react-resize-aware";
declare module "gif.js";

View File

@ -14,6 +14,12 @@ const VideoThumbnailGenerator: LoadableComponent<{}> = loadable(() =>
)
);
const VideoGifGenerator: LoadableComponent<{}> = loadable(() =>
import(
/* webpackChunkName: "VideoGifGenerator" */ "../_Workers/VideoGifGenerator/VideoGifGenerator"
)
);
const styles: ComponentStyles<AppContainerClassNameContract, DesignSystem> = {
container: {
display: "flex",
@ -34,6 +40,7 @@ const AppContainer: React.ComponentType<AppContainerProps> = ({ managedClasses }
</Suspense>
</Router>
{isLoggedIn && <VideoThumbnailGenerator />}
{/*isLoggedIn && <VideoGifGenerator />*/}
</Background>
);

View File

@ -17,4 +17,5 @@ export interface ToastProps
extends ManagedClasses<ToastClassNameContract>,
OriginalToastProps {
title?: string;
progressPercentage?: number;
}

View File

@ -73,7 +73,12 @@ const BaseToast = (props: ToastProps) => {
<div className={props.managedClasses.toast_element}>
<DefaultToast {...defaultToastProps}>
<span className={props.managedClasses.toast_title}>{props.title || title()}</span>
<span className={props.managedClasses.toast_content}>{props.children}</span>
<span
className={props.managedClasses.toast_content}
data-content={typeof props.children === "string" ? props.children : ""}
>
{props.children}
</span>
</DefaultToast>
</div>
);

View File

@ -0,0 +1,267 @@
import React, { useEffect, useCallback, useRef, useState } from "react";
import { useToasts } from "../../_DesignSystem";
import gifJS from "gif.js";
import axios from "../../_interceptedAxios";
import { Progress } from "@microsoft/fast-components-react-msft";
/**
* gif.js can use a faster quantization through WASM
*/
const canWasm = window["WebAssembly"] !== null;
/**
* Maximal size of a single GIF chunk when uploading
*/
const chunkSize = 1024 * 1024;
/**
* Framerate of the output GIF
*/
const desiredFramerate = 8;
/**
* Percentage to compare current percentage to
*/
let lastText = "";
/**
* Gif.js instance
*/
const gif = new gifJS({
workerScript:
window.location.origin +
"/static/js/" +
(canWasm ? "gif.worker-wasm.js" : "gif.worker.js"),
workers: 4,
quality: 8,
// globalPalette: true,
width: 0,
height: 0,
// dither: "Atkinson",
// dither: false,
repeat: 0,
});
const VideoGifGenerator: React.ComponentType<{}> = props => {
const { addToast, updateToast, removeToast } = useToasts();
/**
* An array containing video IDs that need GIF equivalents.
*/
const taskList = useRef<{ id: string }[]>([]);
/**
* Current progress with the conversion of a video
*/
type progressPercentage = number;
const [progressPercentage, setProgress] = useState(null);
/**
* ID of the toast that is used to display the progress
* of generating and uploading a GIF
*/
type toastId = string;
const [toastId, setToastId] = useState<string>(null);
const [curFileId, setCurFileId] = useState(null);
/**
* Generates a gif from an already uploaded video via gif.js
* @param fileID Already existing id for a video file.
*/
const generateGif = useCallback((fileID: string[8]) => {
if (taskList.current.length <= 0) return;
setCurFileId(fileID);
const videoEl = document.createElement("video");
videoEl.style.display = "none";
videoEl.preload = "metadata";
videoEl.autoplay = false;
/**
* Seeks video to given time, adds the frame to gif.js and progresses to
* the next GIF frame by calling itself again.
*
* @param time Video time to seek to in seconds
* @param seekSummand Number in seconds by which `time` should progress after successful seeking
*/
const seekVideo = (time: number, seekSummand: number) => {
const seekHandler = () => {
console.log("Loaded Data at " + videoEl.currentTime + "!");
videoEl.removeEventListener("seeked", seekHandler);
// Setting progress for toast
setProgress((videoEl.currentTime * 33) / videoEl.duration);
// Start GIF rendering if there is no frame to add anymore
if (videoEl.currentTime >= videoEl.duration) {
console.log("Finished at " + videoEl.currentTime + ", now rendering!");
gif.render();
document.body.removeChild(videoEl);
return;
}
gif.addFrame(videoEl, { copy: true, delay: seekSummand * 1000 });
seekVideo(time + seekSummand, seekSummand);
};
videoEl.addEventListener("seeked", seekHandler);
videoEl.currentTime = time;
};
videoEl.addEventListener("loadedmetadata", () => {
console.log("Loaded Metadata!", videoEl.duration);
const seekSummand = 1 / desiredFramerate;
gif.abort();
gif.frames = [];
gif.setOptions({
width: videoEl.videoWidth,
height: videoEl.videoHeight,
});
seekVideo(0, seekSummand);
});
// NOTE: MP4 is hardcoded!
videoEl.src = `${window.location.origin}/${fileID}.mp4`;
document.body.appendChild(videoEl);
}, []);
/**
* Removes first object from the task list and
* requests a GIF generation for the next item in the list.
*/
const shiftToNextGif = useCallback(() => {
taskList.current.shift();
if (taskList.current.length <= 0) return;
generateGif(taskList.current[0].id);
}, [generateGif]);
/**
* Starts uploading procedure.
*
* The GIF is split into small chunks and uploaded into a
* temporary folder. Finally, a stitching request will tell
* the server to combine the chunks and create an optimized
* version of the full GIF.
*/
const uploadGif = useCallback(
(gifBlob: Blob) => {
const uploadGifChunk = async (chunkNum: number) => {
const curID = taskList.current[0].id;
const postData = new FormData();
const offset = chunkNum * chunkSize;
const blobChunk = gifBlob.slice(offset, offset + chunkSize);
/**
* After uploading all chunks, we need to tell the server
* to start stitching them together.
*/
const isStitchRequest = blobChunk.size === 0;
postData.append(
"type",
!isStitchRequest ? "video-gif-upload" : "video-gif-stitch"
);
postData.append("id", curID);
postData.append("chunkNum", chunkNum.toString());
postData.append("data", !isStitchRequest ? blobChunk : null);
try {
await axios.post(window.location.origin + "/api/finishAdminTask.php", postData);
if (!isStitchRequest) uploadGifChunk(chunkNum + 1);
else shiftToNextGif();
} catch (err) {
// Skip current GIF if another client is already uploading
if (typeof err.code !== "undefined" && err.code === 423) {
shiftToNextGif();
return;
}
addToast("error.gifUpload", { appearance: "error" });
console.log("error.gifUpload", "\n", err.message, err, typeof err);
}
};
uploadGifChunk(0);
},
[addToast, shiftToNextGif]
);
useEffect(() => {
(async () => {
try {
const res = await axios.get(window.location.origin + "/api/getAdminTasks.php", {
params: { type: "video-gif" },
});
if (res.data) {
taskList.current = res.data;
generateGif(taskList.current[0].id);
}
} catch (err) {
addToast("error.requestTaskList", { appearance: "error" });
console.log("error.requestTaskList", "\n", err.message);
}
})();
return () => {};
}, [addToast, generateGif]);
useEffect(() => {
const gifFinishHandler = uploadGif;
const handleGifProgress = (progress: number) => {
setProgress((progress * 100 * 33) / 100 + 33);
};
gif.addListener("finished", gifFinishHandler);
gif.addListener("progress", handleGifProgress);
return () => {
gif.removeListener("finished", gifFinishHandler);
gif.removeListener("progress", handleGifProgress);
};
}, [uploadGif]);
useEffect(() => {
let toastId: string = null;
addToast(
<Progress minValue={0} maxValue={100} />,
{
title: `Spinning up GIF generator...`,
appearance: "info",
autoDismiss: false,
},
(id: string) => {
setToastId(id);
toastId = id;
}
);
return () => {
if (toastId) removeToast(toastId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [removeToast, addToast]);
useEffect(() => {
if (!toastId || !curFileId) return;
let title = "";
if (progressPercentage < 33)
title = `Preparing ${curFileId}.mp4 to convert to GIF...`;
if (progressPercentage >= 33) title = `Generating ${curFileId}.gif...`;
if (progressPercentage >= 66) title = `Uploading ${curFileId}.gif...`;
if (title !== lastText) {
lastText = title;
updateToast(toastId, { title } as any);
}
console.log(`${title} - ${progressPercentage}%`);
}, [curFileId, progressPercentage, toastId, updateToast]);
return null;
};
export default VideoGifGenerator;

View File

@ -5,14 +5,18 @@ import "./index.scss";
import "./i18n";
import PogodaDesignToolkitProvider from "./_DesignSystem/Toolkit/DesignSystem";
import { ToastProvider } from "react-toast-notifications";
import loadable from "@loadable/component";
import Toast from "./_DesignSystem/Toast/StaticToast/Toast";
import ToastContainer from "./_DesignSystem/Toast/ToastContainer/ToastContainer";
// import loadable from "@loadable/component";
const Toast = loadable(() =>
import(/* webpackChunkName: "Toast" */ "./_DesignSystem/Toast/Toast")
);
const ToastContainer = loadable(() =>
import(/* webpackChunkName: "Toast" */ "./_DesignSystem/Toast/ToastContainer")
);
//const Toast = loadable(() =>
// import(/* webpackChunkName: "Toast" */ "./_DesignSystem/Toast/StaticToast/Toast")
//);
//const ToastContainer = loadable(() =>
// import(
// /* webpackChunkName: "Toast" */ "./_DesignSystem/Toast/ToastContainer/ToastContainer"
// )
//);*/
ReactDOM.render(
<PogodaDesignToolkitProvider>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5314,6 +5314,11 @@ getpass@^0.1.1:
dependencies:
assert-plus "^1.0.0"
gif.js@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/gif.js/-/gif.js-0.2.0.tgz#615e6e3788850cd3a20c85fe9f09539e784903e8"
integrity sha1-YV5uN4iFDNOiDIX+nwlTnnhJA+g=
glob-parent@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"