Refactor into classes, ask user when leaving page

This commit is contained in:
Reimar 2026-01-06 10:36:18 +01:00
parent 4feab31ef0
commit ef6fbd3e04
Signed by: Reimar
GPG Key ID: 93549FA07F0AE268
5 changed files with 136 additions and 80 deletions

View File

@ -1,4 +1,4 @@
class Notifier { export class Notifier {
constructor() { constructor() {
this.checkbox = document.getElementById("notify-checkbox"); this.checkbox = document.getElementById("notify-checkbox");

View File

@ -0,0 +1,13 @@
export class ProgressBar {
constructor() {
this.percentage = document.getElementById("progress-percentage");
this.indicator = document.getElementById("progress-indicator");
}
setProgress(progress) {
const percent = (progress * 100).toFixed(1) + "%";
this.percentage.innerText = percent;
this.indicator.style.clipPath = `rect(0 ${percent} 100% 0)`;
}
}

View File

@ -0,0 +1,85 @@
import { ERROR_TERMINATED } from "./ffmpeg/package/dist/esm/errors.js";
export class VideoCompressor {
constructor(progressBar) {
this.progressBar = progressBar;
this.ffmpeg = new FFmpegWASM.FFmpeg();
this.inProgress = false;
}
async compress(file, filesizeBytes, duration) {
this.progressBar.setProgress(0);
this.inProgress = true;
const progressCallback = event => this.progressBar.setProgress(event.progress);
this.ffmpeg.on("progress", progressCallback);
const filesizeKbit = filesizeBytes * 0.008;
// Calculate target bitrate to create a video file with desired file size (https://trac.ffmpeg.org/wiki/Encode/H.264#twopass)
let bitrate = filesizeKbit / duration;
bitrate -= 128; // Subtract audio bitrate
bitrate -= (bitrate / 100) * 5; // Subtract 5% to compensate for overhead
bitrate = Math.floor(bitrate);
if (bitrate <= 0) {
this.inProgress = false;
throw new Error("Selected file size is too low for this video");
}
console.debug("Target bitrate:", bitrate, "Video length:", duration);
// Run compression
try {
await this.ffmpeg.load({ coreURL: "/assets/scripts/core/package/dist/umd/ffmpeg-core.js" });
await this.ffmpeg.writeFile(file.name, await this.readFromBlob(file));
await this.ffmpeg.exec(["-i", file.name, "-preset", "ultrafast", "-c:v", "libx264", "-b:v", bitrate + "k", "-c:a", "copy", "-b:a", "128k", "compressed.mp4"]);
} catch (e) {
// Ignore termination error
if (e.message === ERROR_TERMINATED.message) {
this.inProgress = false;
return null;
}
throw e;
}
const video = await this.ffmpeg.readFile("compressed.mp4");
const url = URL.createObjectURL(new Blob([video.buffer], { type: "video/mp4" }));
// Clean up
this.ffmpeg.off("progress", progressCallback);
this.inProgress = false;
return url;
}
stop() {
this.ffmpeg.terminate();
this.inProgress = false;
}
// https://github.com/ffmpegwasm/ffmpeg.wasm/blob/main/packages/util/src/index.ts
readFromBlob(blob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => {
const { result } = fileReader;
if (result instanceof ArrayBuffer) {
resolve(new Uint8Array(result));
} else {
resolve(new Uint8Array());
}
};
fileReader.onerror = (event) => {
reject(Error(`File could not be read! Code=${event?.target?.error?.code || -1}`));
};
fileReader.readAsArrayBuffer(blob);
});
}
}

View File

@ -1,8 +1,12 @@
const ffmpeg = new FFmpegWASM.FFmpeg(); import { Notifier } from "./Notifier.js";
import { ProgressBar } from "./ProgressBar.js";
import { VideoCompressor } from "./VideoCompressor.js";
const notifier = new Notifier(); const notifier = new Notifier();
const progressBar = new ProgressBar();
const videoCompressor = new VideoCompressor(progressBar);
async function compress() { document.getElementById("compress").onclick = async () => {
document.getElementById("uploaded-video").pause(); document.getElementById("uploaded-video").pause();
showSection("loading"); showSection("loading");
@ -11,67 +15,42 @@ async function compress() {
const file = document.getElementById("file-input").files[0]; const file = document.getElementById("file-input").files[0];
const videoLength = document.getElementById("uploaded-video").duration; const videoLength = document.getElementById("uploaded-video").duration;
let targetFilesize; // Stored in kBit
let targetFilesize;
switch (filesizeUnit) { switch (filesizeUnit) {
case "K": targetFilesize = filesize * 8; break; case "kb": targetFilesize = filesize * 1000; break;
case "M": targetFilesize = filesize * 8000; break; case "mb": targetFilesize = filesize * 1000000; break;
} }
ffmpeg.on("log", event => { try {
console.log("[ffmpeg]", event.type, event.message); const url = await videoCompressor.compress(file, targetFilesize, videoLength);
});
updateProgress(0); if (!url) return;
ffmpeg.on("progress", event => {
updateProgress(event.progress);
});
await ffmpeg.load({ coreURL: "/assets/scripts/core/package/dist/umd/ffmpeg-core.js" }); notifier.notifyFinished();
await ffmpeg.writeFile(file.name, await readFromBlob(file)); location.href = url;
} catch (e) {
alert(e.message);
// Calculate target bitrate to create a video file with desired file size (https://trac.ffmpeg.org/wiki/Encode/H.264#twopass)
let bitrate = targetFilesize / videoLength;
bitrate -= 128; // Subtract audio bitrate
bitrate -= (bitrate / 100) * 5; // Subtract 5% to compensate for overhead
bitrate = Math.floor(bitrate);
if (bitrate <= 0) {
showSection("file-picker"); showSection("file-picker");
alert("Selected file size is too low for this video");
return;
} }
};
console.debug("Target bitrate:", bitrate, "Video length:", videoLength); document.getElementById("cancel").onclick = () => {
videoCompressor.stop();
await ffmpeg.exec(["-i", file.name, "-preset", "ultrafast", "-c:v", "libx264", "-b:v", bitrate + "k", "-c:a", "copy", "-b:a", "128k", "compressed.mp4"]);
const video = await ffmpeg.readFile("compressed.mp4");
notifier.notifyFinished();
location.href = URL.createObjectURL(new Blob([video.buffer], { type: "video/mp4" }));
}
function cancel() {
ffmpeg.terminate();
showSection("file-picker"); showSection("file-picker");
} };
function updateProgress(progress) {
const percent = (progress * 100).toFixed(1) + "%";
document.getElementById("progress-percentage").innerText = percent;
document.getElementById("progress-indicator").style.clipPath = `rect(0 ${percent} 100% 0)`;
}
function openFileSelector() { function openFileSelector() {
document.getElementById("file-input").click(); document.getElementById("file-input").click();
} }
function selectFile() { document.getElementById("file-drop-area").onclick = openFileSelector;
document.getElementById("change-file").onclick = openFileSelector;
document.getElementById("file-input").oninput = () => {
setTimeout(() => { setTimeout(() => {
const file = document.getElementById("file-input").files[0]; const file = document.getElementById("file-input").files[0];
@ -81,28 +60,8 @@ function selectFile() {
hideElements("#file-drop-area", "#file-input-spacing"); hideElements("#file-drop-area", "#file-input-spacing");
showElements("#uploaded-video", "#change-file"); showElements("#uploaded-video", "#change-file");
}, 200); }, 200);
} };
// https://github.com/ffmpegwasm/ffmpeg.wasm/blob/main/packages/util/src/index.ts
function readFromBlob(blob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => {
const { result } = fileReader;
if (result instanceof ArrayBuffer) {
resolve(new Uint8Array(result));
} else {
resolve(new Uint8Array());
}
};
fileReader.onerror = (event) => {
reject(
Error(
`File could not be read! Code=${event?.target?.error?.code || -1}`
)
);
};
fileReader.readAsArrayBuffer(blob);
});
}
window.onbeforeunload = event => {
if (videoCompressor.inProgress) event.preventDefault();
};

View File

@ -4,9 +4,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Video Compressor</title> <title>Video Compressor</title>
<script defer src="/assets/scripts/ffmpeg/package/dist/umd/ffmpeg.js"></script> <script defer src="/assets/scripts/ffmpeg/package/dist/umd/ffmpeg.js"></script>
<script defer src="/assets/scripts/Notifier.js"></script>
<script defer src="/assets/scripts/ui.js"></script> <script defer src="/assets/scripts/ui.js"></script>
<script defer src="/assets/scripts/main.js"></script> <script defer type="module" src="/assets/scripts/main.js"></script>
<link rel="stylesheet" href="/assets/style/main.css"> <link rel="stylesheet" href="/assets/style/main.css">
</head> </head>
<body> <body>
@ -14,25 +13,25 @@
<p>Compress video files to a specific file size, so you can upload them to your favorite social media and messaging apps!</p> <p>Compress video files to a specific file size, so you can upload them to your favorite social media and messaging apps!</p>
<section id="file-picker-section"> <section id="file-picker-section">
<div id="file-drop-area" onclick="openFileSelector()" onkeydown="['Enter', 'Space'].includes(event.code) && openFileSelector()" tabindex="0"> <div id="file-drop-area" onkeydown="['Enter', 'Space'].includes(event.code) && openFileSelector()" tabindex="0">
<span>Drag and drop a file here!</span> <span>Drag and drop a file here!</span>
</div> </div>
<input id="file-input" oninput="selectFile()" type="file" accept="video/*" tabindex="-1"> <input id="file-input" type="file" accept="video/*" tabindex="-1">
<div id="file-input-spacing" style="margin-top: -1rem"> <div id="file-input-spacing" style="margin-top: -1rem">
<button class="simple">.</button> <button class="simple">.</button>
</div> </div>
<video id="uploaded-video" controls autoplay></video> <video id="uploaded-video" controls autoplay></video>
<button id="change-file" onclick="openFileSelector()" class="simple">Change video</button> <button id="change-file" class="simple">Change video</button>
<input id="filesize" type="number" value="10" size="3" placeholder="#" style="margin-top: 1rem"><!-- <input id="filesize" type="number" value="10" size="3" placeholder="#" style="margin-top: 1rem"><!--
--><select id="filesize-unit"> --><select id="filesize-unit">
<option value="K">KB</option> <option value="kb">KB</option>
<option value="M" selected>MB</option> <option value="mb" selected>MB</option>
</select> </select>
<button id="compress" onclick="compress()" class="primary">Go!</button> <button id="compress" class="primary">Go!</button>
</section> </section>
<section id="loading-section" style="opacity: 0; display: none;"> <section id="loading-section" style="opacity: 0; display: none;">
@ -47,7 +46,7 @@
<p><label><input id="notify-checkbox" type="checkbox"> Notify on finish</label></p> <p><label><input id="notify-checkbox" type="checkbox"> Notify on finish</label></p>
<button id="cancel" onclick="cancel()" class="simple">Cancel</button> <button id="cancel" class="simple">Cancel</button>
</section> </section>
</body> </body>
</html> </html>