Skip to content

Instantly share code, notes, and snippets.

@funmaker
Last active July 25, 2022 13:29
Show Gist options
  • Save funmaker/00793ba842b12538d95e0fc3d8c626cd to your computer and use it in GitHub Desktop.
Save funmaker/00793ba842b12538d95e0fc3d8c626cd to your computer and use it in GitHub Desktop.
Resize images before post to meet 4chan's limits
// ==UserScript==
// @name 4chan image resizer
// @version 0.6
// @description Resize images before post to meet 4chan's limits
// @author Fun Maker
// @namespace https://gist.github.com/00793ba842b12538d95e0fc3d8c626cd
// @match http*://boards.4chan.org/*
// @match http*://boards.4channel.org/*
// @icon https://4chan.org/favicon.ico
// @updateURL https://gist.github.com/funmaker/00793ba842b12538d95e0fc3d8c626cd/raw/4chan_image_resizer.user.js
// @downloadURL https://gist.github.com/funmaker/00793ba842b12538d95e0fc3d8c626cd/raw/4chan_image_resizer.user.js
// @grant none
// ==/UserScript==
const QUALITY = 95; // JPEG quality
const MAX_DIM = 10000; // Maximum image resolution, constant across the page
const SIZE_MARGIN = 0.1; // How close final image size has to be to maximum file size, (smaller = more tries)
const MAX_TRIES = 30; // Infinity loop prevention
const PRESERVE_MIME = false; // Preserve format if possible (unless browser fallbacks to png or whatever)
const PRESERVE_ALPHA = true; // If converting/scaling, use png if the image uses alpha channel, otherwise jpeg. May cause some lag. Ignored if PRESERVE_MIME = true
const CONVERT_MIME = [
"image/webp",
];
(function() {
'use strict';
let onInterject = null;
// Interject Quick Reply submit
const oldSubmit = window.QR.submit;
window.QR.submit = (...args) => {
const oldXHR = window.XMLHttpRequest;
window.XMLHttpRequest = class FakeXMLHttpRequest extends window.XMLHttpRequest {
async send(data) {
try {
if(onInterject) await onInterject(this, data);
} catch(e) {
if(this.aborted) return;
else throw e;
}
if(this.aborted) return;
super.send(data);
}
abort(...args) {
this.aborted = true;
super.abort(...args);
}
};
try {
oldSubmit(...args);
} finally {
window.XMLHttpRequest = oldXHR;
}
}
// Watch quick reply form for file input and submit button
function registerForm(node) {
let fileInput = node.querySelector('input[name="upfile"]');
const submitButton = node.querySelector('input[type="submit"]');
submitButton.style.width = "auto";
submitButton.style.minWidth = "75px";
const updateButton = (text, callback = null) => {
submitButton.value = text;
onInterject = callback;
}
const image = new Image();
let mime = null;
// Resize and replace image before submit
async function resizeSubmit(xhr, formData) {
let max = 1,
min = 0;
if(max * image.width > MAX_DIM) {
max = MAX_DIM / image.width;
}
if(max * image.height > MAX_DIM) {
max = MAX_DIM / image.height;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if(PRESERVE_MIME && !CONVERT_MIME.includes(mime)) {
// Preserve
} else if(PRESERVE_ALPHA && mime != "image/jpeg") {
updateButton(`Checking alpha...`);
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
let data = ctx.getImageData(0, 0, canvas.width, canvas.height).data,
hasAlpha = false;
for(let i = 3; i < data.length; i+=4) {
if(data[i] < 255) {
hasAlpha = true;
break;
}
}
if(hasAlpha) mime = "image/png";
else mime = "image/jpeg";
} else {
mime = "image/jpeg";
}
const tryScale = async scale => {
const width = canvas.width = Math.floor(image.width * scale) || 1;
const height = canvas.height = Math.floor(image.height * scale) || 1;
if(xhr.aborted) throw new Error("Aborted");
updateButton(`${width}x${height}`);
ctx.drawImage(image, 0, 0, width, height);
const blob = await new Promise(res => canvas.toBlob(res, mime, QUALITY));
return blob;
}
const submit = blob => {
let filename = fileInput.files[0].name || "image.jpg";
formData.set("upfile", blob, filename);
return;
}
// Skip full size png reencode
if(!(mime === "image/png" && max === 1)) {
let blob = await tryScale(max);
if(blob.size <= window.maxFilesize) return submit(blob);
}
for(let i = 0; i < MAX_TRIES; i++) {
const middle = (min + max) / 2;
let blob = await tryScale(middle);
if(blob.size <= window.maxFilesize && blob.size >= window.maxFilesize * (1 - SIZE_MARGIN)) {
return submit(blob);
} else if(blob.size > window.maxFilesize) {
max = middle;
} else {
min = middle;
}
}
submit(await tryScale(min));
}
// Load attached image
function onFileChange() {
if(!fileInput.files[0]) return updateButton("Post");
updateButton("Loading...");
mime = fileInput.files[0].type;
image.onload = () => {
console.log(mime)
if(image.width > MAX_DIM || image.height > MAX_DIM || fileInput.files[0].size > window.maxFilesize) {
updateButton("Resize & Post", resizeSubmit);
} else if(CONVERT_MIME.includes(mime)) {
updateButton("Convert & Post", resizeSubmit);
} else {
updateButton("Post");
}
};
image.onerror = () => updateButton("Post");
if(image.src) URL.revokeObjectURL(image.src);
image.src = URL.createObjectURL(fileInput.files[0]);
}
fileInput.addEventListener("change", onFileChange);
if(fileInput.files[0]) onFileChange();
// File Input gets replaced when reset in quick reply for some reason
const removeObserver = new MutationObserver(mutations => {
for(const mutation of mutations) {
if(mutation.type !== "childList") continue;
for(const addedNode of mutation.addedNodes) {
if(addedNode.tagName === "INPUT" && addedNode.type === "file") {
fileInput = addedNode;
fileInput.addEventListener("change", onFileChange);
};
}
}
});
removeObserver.observe(fileInput.parentNode, { childList: true })
}
// Watch for quick reply open
const observer = new MutationObserver(mutations => {
for(const mutation of mutations) {
if(mutation.type !== "childList") continue;
for(const node of mutation.addedNodes) {
if(node.id === "quickReply") registerForm(node.querySelector("form"));
}
}
});
observer.observe(document.body, { childList: true });
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment