// ==UserScript==
// @name Holotower ImgOps Links
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description Add "imgops" link after file information on Holotower boards (uploads to litterbox first)
// @author slopffian
// @match https://boards.holotower.org/*
// @match http://boards.holotower.org/*
// @match https://holotower.org/*
// @match http://holotower.org/*
// @grant GM_xmlhttpRequest
// @connect litterbox.catbox.moe
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ==================== Configuration ====================
const CONFIG = {
VARIANCE_THRESHOLD: 100, // For detecting blank video frames
SEEK_INCREMENT: 0.1, // Seconds between frame checks
MAX_SEEK_TIME: 5, // Maximum seconds to search for non-blank frame
JPEG_QUALITY: 0.95, // Quality for extracted video frames
LITTERBOX_EXPIRY: '1h', // Litterbox link expiry time
LITTERBOX_API: 'https://litterbox.catbox.moe/resources/internals/api.php',
IMGOPS_URL: 'https://imgops.com/'
};
// ==================== State ====================
const litterboxCache = new WeakMap(); // Cache for storing litterbox URLs by link element
// ==================== Utility Functions ====================
/**
* Check if a litterbox URL is still valid
*/
async function isLitterboxUrlValid(url) {
try {
const response = await fetch(url, { method: 'HEAD' });
return response.ok;
} catch (error) {
return false;
}
}
/**
* Update the visual state of an imgops link
*/
function updateLinkState(link, text, cursor = 'pointer', color = null) {
link.textContent = text;
link.style.cursor = cursor;
if (color) link.style.color = color;
}
/**
* Extract filename from URL and optionally replace extension
*/
function getFilenameFromUrl(url, newExtension = null) {
let filename = url.split('/').pop().split('?')[0];
if (newExtension) {
filename = filename.replace(/\.(webm|mp4)$/i, newExtension);
}
return filename;
}
// ==================== Video Processing ====================
/**
* Check if a video frame is blank (uniform color or very low variation)
*/
function isFrameBlank(canvas, ctx) {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
let sumR = 0, sumG = 0, sumB = 0;
let count = 0;
// Sample every 10th pixel for performance
for (let i = 0; i < data.length; i += 40) { // 40 = 10 pixels * 4 channels (RGBA)
sumR += data[i];
sumG += data[i + 1];
sumB += data[i + 2];
count++;
}
// Calculate average color
const avgR = sumR / count;
const avgG = sumG / count;
const avgB = sumB / count;
// Calculate variance (how much pixels differ from average)
let varianceSum = 0;
for (let i = 0; i < data.length; i += 40) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const diffR = r - avgR;
const diffG = g - avgG;
const diffB = b - avgB;
varianceSum += (diffR * diffR + diffG * diffG + diffB * diffB);
}
const variance = varianceSum / count;
return variance < CONFIG.VARIANCE_THRESHOLD;
}
/**
* Extract first non-blank frame from video
*/
async function extractFirstFrameFromVideo(videoUrl) {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.preload = 'metadata';
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let currentSeekTime = 0;
video.onloadedmetadata = () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
video.currentTime = currentSeekTime;
};
video.onseeked = () => {
try {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Check if we found a non-blank frame or reached limits
const reachedEnd = currentSeekTime > CONFIG.MAX_SEEK_TIME || currentSeekTime > video.duration;
if (!isFrameBlank(canvas, ctx) || reachedEnd) {
// Convert to blob and resolve
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to create blob from canvas'));
}
}, 'image/jpeg', CONFIG.JPEG_QUALITY);
} else {
// Try next frame
currentSeekTime += CONFIG.SEEK_INCREMENT;
video.currentTime = currentSeekTime;
}
} catch (error) {
reject(error);
}
};
video.onerror = () => reject(new Error('Failed to load video'));
video.src = videoUrl;
});
}
/**
* Get thumbnail image blob from post
*/
async function getThumbnailBlob(fileInfo) {
const thumbnailImg = fileInfo.closest('.file').querySelector('img.post-image');
if (!thumbnailImg || !thumbnailImg.src) {
throw new Error('No thumbnail found');
}
const response = await fetch(thumbnailImg.src);
return await response.blob();
}
// ==================== Litterbox Upload ====================
/**
* Upload blob to litterbox and return URL
*/
async function uploadToLitterbox(blob, filename) {
const formData = new FormData();
formData.append('reqtype', 'fileupload');
formData.append('time', CONFIG.LITTERBOX_EXPIRY);
formData.append('fileToUpload', blob, filename);
const uploadResponse = await fetch(CONFIG.LITTERBOX_API, {
method: 'POST',
body: formData
});
const litterboxUrl = await uploadResponse.text();
if (!litterboxUrl || !litterboxUrl.startsWith('http')) {
throw new Error('Invalid response from litterbox');
}
return litterboxUrl;
}
/**
* Get blob for image or video file
*/
async function getImageBlob(fileUrl, useVideoThumbnail, fileInfo) {
const isVideo = /\.(webm|mp4)$/i.test(fileUrl);
if (isVideo) {
if (useVideoThumbnail) {
// Use Holotower's pre-generated thumbnail
return await getThumbnailBlob(fileInfo);
} else {
// Extract first non-blank frame from video
return await extractFirstFrameFromVideo(fileUrl);
}
} else {
// Regular image - fetch directly
const response = await fetch(fileUrl);
return await response.blob();
}
}
/**
* Get appropriate filename for the blob
*/
function getFilename(fileUrl, blob, useVideoThumbnail) {
const isVideo = /\.(webm|mp4)$/i.test(fileUrl);
if (isVideo) {
const suffix = useVideoThumbnail ? '_thumb.jpg' : '.jpg';
return getFilenameFromUrl(fileUrl, suffix);
}
// For images
let filename = getFilenameFromUrl(fileUrl);
if (!filename) {
const extension = blob.type ? blob.type.split('/')[1] : 'jpg';
filename = `image.${extension}`;
}
return filename;
}
/**
* Main upload and imgops handler with caching
*/
async function handleImgOpsClick(fileUrl, imgopsLink, fileInfo, useVideoThumbnail = false) {
const linkType = useVideoThumbnail ? 'thumb' : 'frame';
const prefix = useVideoThumbnail ? 'imgops (thumb' : 'imgops (';
try {
// Check cache first
const cachedUrl = litterboxCache.get(imgopsLink);
if (cachedUrl) {
updateLinkState(imgopsLink, `${prefix}checking...)`, 'wait');
const isValid = await isLitterboxUrlValid(cachedUrl);
if (isValid) {
window.open(`${CONFIG.IMGOPS_URL}${cachedUrl}`, '_blank');
const successText = useVideoThumbnail ? 'imgops (thumb) ✓' : 'imgops ✓';
updateLinkState(imgopsLink, successText, 'pointer', 'green');
return;
}
}
// Upload new file
updateLinkState(imgopsLink, `${prefix}loading...)`, 'wait');
const blob = await getImageBlob(fileUrl, useVideoThumbnail, fileInfo);
const filename = getFilename(fileUrl, blob, useVideoThumbnail);
const litterboxUrl = await uploadToLitterbox(blob, filename);
// Cache and open
litterboxCache.set(imgopsLink, litterboxUrl);
window.open(`${CONFIG.IMGOPS_URL}${litterboxUrl}`, '_blank');
const successText = useVideoThumbnail ? 'imgops (thumb) ✓' : 'imgops ✓';
updateLinkState(imgopsLink, successText, 'pointer', 'green');
} catch (error) {
console.error(`Error uploading ${linkType} to litterbox:`, error);
const errorText = useVideoThumbnail ? 'imgops (thumb error)' : 'imgops (error)';
updateLinkState(imgopsLink, errorText, 'pointer', 'red');
if (!useVideoThumbnail) {
alert('Failed to upload image to litterbox. Please try again.');
}
}
}
// ==================== DOM Manipulation ====================
/**
* Create an imgops link element
*/
function createImgOpsLink(text, fileUrl, fileInfo, useVideoThumbnail = false) {
const link = document.createElement('a');
link.href = 'javascript:void(0)';
link.textContent = text;
link.className = useVideoThumbnail ? 'imgops-link imgops-thumb-link' : 'imgops-link';
link.style.cursor = 'pointer';
link.addEventListener('click', (e) => {
e.preventDefault();
handleImgOpsClick(fileUrl, link, fileInfo, useVideoThumbnail);
});
return link;
}
/**
* Check if imgops links already exist for this span
*/
function hasImgOpsLinks(span) {
let sibling = span.nextSibling;
while (sibling) {
if (sibling.nodeType === Node.ELEMENT_NODE &&
sibling.classList &&
sibling.classList.contains('imgops-link')) {
return true;
}
// Only check immediate siblings
if (sibling.nodeType === Node.ELEMENT_NODE &&
!sibling.classList.contains('imgops-link')) {
break;
}
sibling = sibling.nextSibling;
}
return false;
}
/**
* Add imgops links to a file info element
*/
function addImgOpsLinksToFile(span) {
// Skip if already processed
if (hasImgOpsLinks(span)) return;
// Get file info
const fileInfo = span.closest('.fileinfo');
if (!fileInfo) return;
const fileLink = fileInfo.querySelector('a[href*="/src/"]');
if (!fileLink) return;
const fileUrl = fileLink.href;
const isVideo = /\.(webm|mp4)$/i.test(fileUrl);
// Create main imgops link
const imgopsLink = createImgOpsLink('imgops', fileUrl, fileInfo, false);
// Insert links with brackets
span.parentNode.insertBefore(document.createTextNode(' ['), span.nextSibling);
span.parentNode.insertBefore(imgopsLink, span.nextSibling.nextSibling);
// For videos, add thumbnail link
if (isVideo) {
span.parentNode.insertBefore(document.createTextNode(' | '), span.nextSibling.nextSibling.nextSibling);
const thumbLink = createImgOpsLink('imgops (thumb)', fileUrl, fileInfo, true);
span.parentNode.insertBefore(thumbLink, span.nextSibling.nextSibling.nextSibling.nextSibling);
span.parentNode.insertBefore(document.createTextNode(']'), span.nextSibling.nextSibling.nextSibling.nextSibling.nextSibling);
} else {
span.parentNode.insertBefore(document.createTextNode(']'), span.nextSibling.nextSibling.nextSibling);
}
}
/**
* Process all file info elements on the page
*/
function addImgOpsLinks() {
const fileInfoSpans = document.querySelectorAll('.fileinfo span.unimportant');
fileInfoSpans.forEach(addImgOpsLinksToFile);
}
// ==================== Mutation Observer ====================
/**
* Check if mutation contains file info elements
*/
function mutationHasFileInfo(mutations) {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
if (node.classList && node.classList.contains('fileinfo')) {
return true;
}
if (node.querySelector && node.querySelector('.fileinfo')) {
return true;
}
}
}
return false;
}
// ==================== Initialization ====================
// Add links to existing posts
addImgOpsLinks();
// Watch for new posts
const observer = new MutationObserver((mutations) => {
if (mutationHasFileInfo(mutations)) {
observer.disconnect();
addImgOpsLinks();
observer.observe(document.body, { childList: true, subtree: true });
}
});
observer.observe(document.body, { childList: true, subtree: true });
})();