您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
allows you to upload multiple T-Shirts/Decals easily with AnnaUploader; now supports image resizing
// ==UserScript== // @name AnnaUploader (Roblox Multi-File Uploader) // @namespace https://github.com/AnnaRoblox // @version 7.1 // @description allows you to upload multiple T-Shirts/Decals easily with AnnaUploader; now supports image resizing // @match https://create.roblox.com/* // @match https://www.roblox.com/users/*/profile* // @match https://www.roblox.com/communities/* // @match https://www.roblox.com/home/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js // @license MIT // ==/UserScript== (function() { 'use strict'; // Constants for Roblox API and asset types const ROBLOX_UPLOAD_URL = "https://apis.roblox.com/assets/user-auth/v1/assets"; const ASSET_TYPE_TSHIRT = 11; const ASSET_TYPE_DECAL = 13; const FORCED_NAME = "Uploaded Using AnnaUploader"; // Default name for assets // Storage keys and scan interval for asset logging const STORAGE_KEY = 'annaUploaderAssetLog'; const SCAN_INTERVAL_MS = 10_000; // Script configuration variables, managed with Tampermonkey's GM_getValue/GM_setValue let USER_ID = GM_getValue('userId', null); let IS_GROUP = GM_getValue('isGroup', false); let useForcedName = GM_getValue('useForcedName', false); // Persist this setting let useMakeUnique = GM_getValue('useMakeUnique', false); // Persist this setting let uniqueCopies = GM_getValue('uniqueCopies', 1); // Persist this setting let useDownload = GM_getValue('useDownload', false); // Persist this setting let useForceCanvasUpload = GM_getValue('useForceCanvasUpload', false); // Persist this setting // NEW SETTING: Slip Mode Pixel Method - 'all_pixels', '1-3_random', '1-4_random_single_pixel', or 'random_single_pixel_full_random_color' let slipModePixelMethod = GM_getValue('slipModePixelMethod', '1-3_random'); // NEW: Image resizing settings let enableResize = GM_getValue('enableResize', false); let resizeWidth = GM_getValue('resizeWidth', 300); let resizeHeight = GM_getValue('resizeHeight', 300); // Mass upload mode variables let massMode = false; // True if mass upload mode is active let massQueue = []; // Array to hold files/metadata for mass upload let batchTotal = 0; // Total items to process in current batch/queue let completed = 0; // Number of items completed in current batch/queue let csrfToken = null; // Roblox CSRF token for authenticated requests let statusEl, toggleBtn, startBtn, copiesInput, downloadBtn; // UI elements (removed forceUploadBtn from here) let uiContainer; // Reference to the main UI container element let settingsModal; // Reference to the settings modal element function baseName(filename) { return filename.replace(/\.[^/.]+$/, ''); } function loadLog() { const raw = GM_getValue(STORAGE_KEY, '{}'); try { return JSON.parse(raw); } catch { return {}; } } function saveLog(log) { GM_setValue(STORAGE_KEY, JSON.stringify(log)); } function logAsset(id, imageURL, name) { const log = loadLog(); log[id] = { date: new Date().toISOString(), image: imageURL || log[id]?.image || null, name: name || log[id]?.name || '(unknown)' }; saveLog(log); console.log(`[AssetLogger] logged asset ${id} at ${log[id].date}, name: ${log[id].name}, image: ${log[id].image || "none"}`); } function scanForAssets() { console.log('[AssetLogger] scanning for assets…'); document.querySelectorAll('[href]').forEach(el => { let m = el.href.match(/(?:https?:\/\/create\.roblox\.com)?\/store\/asset\/(\d+)/) || el.href.match(/\/dashboard\/creations\/store\/(\d+)\/configure/); if (m) { const id = m[1]; let image = null; const container = el.closest('*'); const img = container?.querySelector('img'); if (img?.src) image = img.src; let name = null; const nameEl = container?.querySelector('span.MuiTypography-root'); if (nameEl) name = nameEl.textContent.trim(); logAsset(id, image, name); } }); } setInterval(scanForAssets, SCAN_INTERVAL_MS); async function fetchCSRFToken() { const resp = await fetch(ROBLOX_UPLOAD_URL, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); if (resp.status === 403) { const tok = resp.headers.get('x-csrf-token'); if (tok) { csrfToken = tok; console.log('[CSRF] token fetched'); return tok; } } throw new Error('Cannot fetch CSRF token'); } function updateStatus() { if (!statusEl) return; if (massMode) { statusEl.textContent = `${massQueue.length} queued`; } else if (batchTotal > 0) { statusEl.textContent = `${completed} of ${batchTotal} processed`; } else { statusEl.textContent = ''; } } async function uploadFile(file, assetType, retries = 0, forceName = false) { if (!csrfToken) { try { await fetchCSRFToken(); } catch (e) { console.error("[Upload] Failed to fetch initial CSRF token:", e); completed++; updateStatus(); return; } } const displayName = forceName ? FORCED_NAME : baseName(file.name); const creator = IS_GROUP ? { groupId: USER_ID } : { userId: USER_ID }; const fd = new FormData(); fd.append('fileContent', file, file.name); fd.append('request', JSON.stringify({ displayName, description: FORCED_NAME, assetType: assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal", creationContext: { creator, expectedPrice: 0 } })); try { const resp = await fetch(ROBLOX_UPLOAD_URL, { method: 'POST', credentials: 'include', headers: { 'x-csrf-token': csrfToken }, body: fd }); const txt = await resp.text(); let json; try { json = JSON.parse(txt); } catch (e) { console.error('[Upload] Failed to parse response JSON:', e, txt); } if (json?.message && typeof json.message === 'string' && json.message.toLowerCase().includes('banned')) { displayMessage('Upload failed: Your account appears to be banned. Cannot complete upload.', 'error'); console.error(`[Upload] Account banned for "${file.name}":`, txt); completed++; updateStatus(); return; } if (resp.ok && json?.assetId) { logAsset(json.assetId, null, displayName); completed++; updateStatus(); return; } if (json?.message === 'Asset name length is invalid.' && !forceName && retries < 5) { console.warn(`[Upload] "${file.name}" name too long, retrying with default name. Retry ${retries + 1}.`); return uploadFile(file, assetType, retries + 1, true); } if (resp.status === 400 && json?.message?.includes('moderated') && retries < 5) { console.warn(`[Upload] "${file.name}" content moderated, retrying with default name. Retry ${retries + 1}.`); return uploadFile(file, assetType, retries + 1, true); } if (resp.status === 403 && retries < 5) { console.warn(`[Upload] "${file.name}" 403 Forbidden, fetching new CSRF and retrying. Retry ${retries + 1}.`); csrfToken = null; await fetchCSRFToken(); return uploadFile(file, assetType, retries + 1, forceName); } console.error(`[Upload] failed "${file.name}" [${resp.status}]`, txt); completed++; updateStatus(); } catch (e) { console.error(`[Upload] error during fetch for "${file.name}":`, e); completed++; updateStatus(); } } function convertWebPToPng(webpFile) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); canvas.toBlob(blob => { if (blob) { const newFileName = webpFile.name.replace(/\.webp$/, '.png'); resolve(new File([blob], newFileName, { type: 'image/png' })); } else { reject(new Error('Failed to convert WebP to PNG blob.')); } }, 'image/png'); }; img.onerror = (e) => { reject(new Error(`Failed to load image for conversion: ${e.message}`)); }; img.src = URL.createObjectURL(webpFile); }); } // Resize image as a File to width x height (returns a new File) function resizeImageFile(file, width, height) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); canvas.toBlob(blob => { if (blob) { // Preserve base name, but new extension png const newFileName = baseName(file.name) + '.png'; resolve(new File([blob], newFileName, { type: 'image/png' })); } else { reject(new Error('Failed to resize image.')); } }, 'image/png'); }; img.onerror = (e) => { reject(new Error(`Failed to load image for resizing: ${e.message}`)); }; img.src = URL.createObjectURL(file); }); } function processImageThroughCanvas(file, targetType = 'image/png', width = null, height = null) { // Optionally resize if width/height provided return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = width || img.width; canvas.height = height || img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); canvas.toBlob(blob => { if (blob) { const newFileName = baseName(file.name) + (targetType === 'image/png' ? '.png' : '.jpeg'); resolve(new File([blob], newFileName, { type: targetType })); } else { reject(new Error('Failed to process image through canvas.')); } }, targetType); }; img.onerror = (e) => { reject(new Error(`Failed to load image for canvas processing: ${e.message}`)); }; img.src = URL.createObjectURL(file); }); } function makeUniqueFile(file, origBase, copyIndex, resizeW = null, resizeH = null) { return new Promise(resolve => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = resizeW || img.width; canvas.height = resizeH || img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; if (slipModePixelMethod === '1-4_random_single_pixel') { const pixelIndex = Math.floor(Math.random() * (data.length / 4)) * 4; if (data[pixelIndex + 3] !== 0) { const delta = (Math.random() < 0.5 ? -1 : 1) * (Math.floor(Math.random() * 4) + 1); data[pixelIndex] = Math.min(255, Math.max(0, data[pixelIndex] + delta)); data[pixelIndex+1] = Math.min(255, Math.max(0, data[pixelIndex+1] + delta)); data[pixelIndex+2] = Math.min(255, Math.max(0, data[pixelIndex+2] + delta)); } } else if (slipModePixelMethod === 'random_single_pixel_full_random_color') { const pixelIndex = Math.floor(Math.random() * (data.length / 4)) * 4; if (data[pixelIndex + 3] !== 0) { data[pixelIndex] = Math.floor(Math.random() * 256); data[pixelIndex + 1] = Math.floor(Math.random() * 256); data[pixelIndex + 2] = Math.floor(Math.random() * 256); } } else { for (let i = 0; i < data.length; i += 4) { if (data[i + 3] !== 0) { let delta; if (slipModePixelMethod === 'all_pixels') { delta = (Math.random() < 0.5 ? -1 : 1); data[i] = Math.min(255, Math.max(0, data[i] + delta)); data[i+1] = Math.min(255, Math.max(0, data[i+1] + delta)); data[i+2] = Math.min(255, Math.max(0, data[i+2] + delta)); } else if (slipModePixelMethod === '1-3_random') { delta = (Math.random() < 0.5 ? -1 : 1) * (Math.floor(Math.random() * 3) + 1); data[i] = Math.min(255, Math.max(0, data[i] + delta)); data[i+1] = Math.min(255, Math.max(0, data[i+1] + delta)); data[i+2] = Math.min(255, Math.max(0, data[i+2] + delta)); } } } } ctx.putImageData(imageData, 0, 0); canvas.toBlob(blob => { const ext = 'png'; const newName = `${origBase}_${copyIndex}.${ext}`; resolve(new File([blob], newName, { type: 'image/png' })); }, 'image/png'); }; img.src = URL.createObjectURL(file); }); } async function handleFileSelect(files, assetType, both = false) { if (!files?.length) return; const downloadsMap = {}; const copies = useMakeUnique ? uniqueCopies : 1; const resizeActive = enableResize && Number(resizeWidth) > 0 && Number(resizeHeight) > 0; if (massMode) { displayMessage('Processing files to add to queue...', 'info'); const processingTasks = []; for (const original of files) { let fileToProcess = original; // 1. WebP Conversion if (original.type === 'image/webp') { displayMessage(`Converting ${original.name} from WebP to PNG...`, 'info'); try { fileToProcess = await convertWebPToPng(original); displayMessage(`${original.name} converted to PNG.`, 'success'); } catch (error) { displayMessage(`Failed to convert ${original.name}: ${error.message}`, 'error'); console.error(`[Conversion] Failed to convert ${original.name}:`, error); continue; } } // 2. Optional resizing if (resizeActive) { displayMessage(`Resizing ${fileToProcess.name} to ${resizeWidth}x${resizeHeight}...`, 'info'); try { fileToProcess = await resizeImageFile(fileToProcess, Number(resizeWidth), Number(resizeHeight)); displayMessage(`${fileToProcess.name} resized.`, 'success'); } catch (error) { displayMessage(`Failed to resize ${fileToProcess.name}: ${error.message}`, 'error'); console.error(`[Resize] Failed to resize ${fileToProcess.name}:`, error); continue; } } // 3. Force Canvas Upload let fileAfterCanvasProcessing = fileToProcess; if (useForceCanvasUpload && !useMakeUnique) { displayMessage(`Processing ${fileToProcess.name} through canvas...`, 'info'); try { fileAfterCanvasProcessing = await processImageThroughCanvas( fileToProcess, 'image/png', resizeActive ? Number(resizeWidth) : null, resizeActive ? Number(resizeHeight) : null ); displayMessage(`${fileToProcess.name} processed through canvas.`, 'success'); } catch (error) { displayMessage(`Failed to process ${fileToProcess.name} through canvas: ${error.message}`, 'error'); console.error(`[Canvas Process] Failed to process ${fileToProcess.name}:`, error); continue; } } const origBase = baseName(fileAfterCanvasProcessing.name); for (let i = 1; i <= copies; i++) { processingTasks.push( (async () => { const fileForQueue = useMakeUnique ? await makeUniqueFile( fileAfterCanvasProcessing, origBase, i, resizeActive ? Number(resizeWidth) : null, resizeActive ? Number(resizeHeight) : null ) : fileAfterCanvasProcessing; if (both) { massQueue.push({ f: fileForQueue, type: ASSET_TYPE_TSHIRT, forceName: useForcedName }); massQueue.push({ f: fileForQueue, type: ASSET_TYPE_DECAL, forceName: useForcedName }); } else { massQueue.push({ f: fileForQueue, type: assetType, forceName: useForcedName }); } })() ); } } await Promise.all(processingTasks); displayMessage(`${processingTasks.length} files added to queue!`, 'success'); updateStatus(); } else { const totalFilesToUpload = files.length * (both ? 2 : 1) * copies; batchTotal = totalFilesToUpload; completed = 0; updateStatus(); displayMessage(`Starting upload of ${batchTotal} files...`, 'info'); const uploadPromises = []; for (const original of files) { let fileToProcess = original; // 1. WebP Conversion if (original.type === 'image/webp') { displayMessage(`Converting ${original.name} from WebP to PNG...`, 'info'); try { fileToProcess = await convertWebPToPng(original); displayMessage(`${original.name} converted to PNG.`, 'success'); } catch (error) { displayMessage(`Failed to convert ${original.name}: ${error.message}`, 'error'); console.error(`[Conversion] Failed to convert ${original.name}:`, error); continue; } } // 2. Optional resizing if (resizeActive) { displayMessage(`Resizing ${fileToProcess.name} to ${resizeWidth}x${resizeHeight}...`, 'info'); try { fileToProcess = await resizeImageFile(fileToProcess, Number(resizeWidth), Number(resizeHeight)); displayMessage(`${fileToProcess.name} resized.`, 'success'); } catch (error) { displayMessage(`Failed to resize ${fileToProcess.name}: ${error.message}`, 'error'); console.error(`[Resize] Failed to resize ${fileToProcess.name}:`, error); continue; } } // 3. Force Canvas Upload let fileAfterCanvasProcessing = fileToProcess; if (useForceCanvasUpload && !useMakeUnique) { displayMessage(`Processing ${fileToProcess.name} through canvas...`, 'info'); try { fileAfterCanvasProcessing = await processImageThroughCanvas( fileToProcess, 'image/png', resizeActive ? Number(resizeWidth) : null, resizeActive ? Number(resizeHeight) : null ); displayMessage(`${fileToProcess.name} processed through canvas.`, 'success'); } catch (error) { displayMessage(`Failed to process ${fileToProcess.name} through canvas: ${error.message}`, 'error'); console.error(`[Canvas Process] Failed to process ${fileToProcess.name}:`, error); continue; } } const origBase = baseName(fileAfterCanvasProcessing.name); downloadsMap[origBase] = []; for (let i = 1; i <= copies; i++) { const fileToUpload = useMakeUnique ? await makeUniqueFile( fileAfterCanvasProcessing, origBase, i, resizeActive ? Number(resizeWidth) : null, resizeActive ? Number(resizeHeight) : null ) : fileAfterCanvasProcessing; if (useMakeUnique && useDownload) downloadsMap[origBase].push(fileToUpload); if (both) { uploadPromises.push(uploadFile(fileToUpload, ASSET_TYPE_TSHIRT, 0, useForcedName)); uploadPromises.push(uploadFile(fileToUpload, ASSET_TYPE_DECAL, 0, useForcedName)); } else { uploadPromises.push(uploadFile(fileToUpload, assetType, 0, useForcedName)); } } } Promise.all(uploadPromises).then(() => { console.log('[Uploader] batch done'); scanForAssets(); displayMessage('Immediate upload batch complete!', 'success'); if (useMakeUnique && useDownload) { for (const [origBase, fileList] of Object.entries(downloadsMap)) { if (!fileList.length) continue; const zip = new JSZip(); fileList.forEach(f => zip.file(f.name, f)); zip.generateAsync({ type: 'blob' }).then(blob => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${origBase}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }); } } }).catch(error => { console.error("Immediate upload batch encountered an error:", error); displayMessage('Immediate upload batch finished with errors. Check console.', 'error'); }); } } function startMassUpload() { if (!massQueue.length) { displayMessage('Nothing queued for mass upload!', 'info'); return; } batchTotal = massQueue.length; completed = 0; updateStatus(); displayMessage(`Starting mass upload of ${batchTotal} files...`, 'info'); const tasks = massQueue.map(item => uploadFile(item.f, item.type, 0, item.forceName)); massQueue = []; Promise.all(tasks).then(() => { displayMessage('Mass upload complete!', 'success'); massMode = false; toggleBtn.textContent = 'Enable Mass Upload'; startBtn.style.display = 'none'; scanForAssets(); batchTotal = completed = 0; updateStatus(); }).catch(error => { console.error("Mass upload encountered an error:", error); displayMessage('Mass upload finished with errors. Check console.', 'error'); massMode = false; toggleBtn.textContent = 'Enable Mass Upload'; startBtn.style.display = 'none'; batchTotal = completed = 0; updateStatus(); }); } function displayMessage(message, type = 'info') { const modal = document.createElement('div'); Object.assign(modal.style, { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', padding: '20px', background: '#333', color: '#fff', borderRadius: '8px', boxShadow: '0 4px 10px rgba(0,0,0,0.5)', zIndex: '10001', fontFamily: 'Inter, Arial, sans-serif', textAlign: 'center', minWidth: '250px', transition: 'opacity 0.3s ease-in-out', opacity: '0' }); if (type === 'success') { modal.style.background = '#4CAF50'; } else if (type === 'error') { modal.style.background = '#f44336'; } modal.textContent = message; document.body.appendChild(modal); setTimeout(() => modal.style.opacity = '1', 10); setTimeout(() => { modal.style.opacity = '0'; modal.addEventListener('transitionend', () => modal.remove()); }, 3000); } function customPrompt(message, defaultValue = '') { return new Promise(resolve => { const modal = document.createElement('div'); Object.assign(modal.style, { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', padding: '20px', background: '#222', color: '#fff', borderRadius: '8px', boxShadow: '0 6px 15px rgba(0,0,0,0.4)', zIndex: '10002', fontFamily: 'Inter, Arial, sans-serif', textAlign: 'center', minWidth: '300px', display: 'flex', flexDirection: 'column', gap: '15px', transition: 'opacity 0.3s ease-in-out', opacity: '0' }); const textDiv = document.createElement('div'); textDiv.textContent = message; textDiv.style.fontSize = '16px'; modal.appendChild(textDiv); const input = document.createElement('input'); input.type = 'text'; input.value = defaultValue; Object.assign(input.style, { padding: '10px', borderRadius: '5px', border: '1px solid #555', background: '#333', color: '#fff', fontSize: '14px', outline: 'none' }); modal.appendChild(input); const buttonContainer = document.createElement('div'); Object.assign(buttonContainer.style, { display: 'flex', justifyContent: 'space-around', gap: '10px', marginTop: '10px' }); const okBtn = document.createElement('button'); okBtn.textContent = 'OK'; Object.assign(okBtn.style, { padding: '10px 20px', cursor: 'pointer', color: '#fff', background: '#007bff', border: 'none', borderRadius: '5px', fontSize: '14px', flexGrow: '1' }); okBtn.onmouseover = () => okBtn.style.background = '#0056b3'; okBtn.onmouseout = () => okBtn.style.background = '#007bff'; okBtn.onclick = () => { modal.style.opacity = '0'; modal.addEventListener('transitionend', () => modal.remove()); resolve(input.value); }; buttonContainer.appendChild(okBtn); const cancelBtn = document.createElement('button'); cancelBtn.textContent = 'Cancel'; Object.assign(cancelBtn.style, { padding: '10px 20px', cursor: 'pointer', color: '#fff', background: '#6c757d', border: 'none', borderRadius: '5px', fontSize: '14px', flexGrow: '1' }); cancelBtn.onmouseover = () => cancelBtn.style.background = '#5a6268'; cancelBtn.onmouseout = () => cancelBtn.style.background = '#6c757d'; cancelBtn.onclick = () => { modal.style.opacity = '0'; modal.addEventListener('transitionend', () => modal.remove()); resolve(null); }; buttonContainer.appendChild(cancelBtn); modal.appendChild(buttonContainer); document.body.appendChild(modal); setTimeout(() => modal.style.opacity = '1', 10); input.focus(); input.addEventListener('keypress', (e) => { if (e.key === 'Enter') { okBtn.click(); } }); }); } function createStyledButton(text, fn) { const b = document.createElement('button'); b.textContent = text; Object.assign(b.style, { padding: '10px', cursor: 'pointer', color: '#fff', background: '#3a3a3a', border: '1px solid #555', borderRadius: '5px', transition: 'background 0.2s ease-in-out', fontSize: '14px' }); b.onmouseover = () => b.style.background = '#505050'; b.onmouseout = () => b.style.background = '#3a3a3a'; b.onclick = fn; return b; } function createUI() { uiContainer = document.createElement('div'); Object.assign(uiContainer.style, { position: 'fixed', top: '10px', right: '10px', width: '280px', // Adjusted width background: '#1a1a1a', border: '2px solid #333', color: '#e0e0e0', padding: '15px 15px 15px 15px', // Adjusted padding zIndex: 10000, borderRadius: '8px', boxShadow: '0 4px 12px rgba(0,0,0,0.4)', display: 'flex', flexDirection: 'column', gap: '10px', fontFamily: 'Inter, Arial, sans-serif', transition: 'top 0.3s ease-in-out' }); // Close button const close = createStyledButton('×', () => uiContainer.remove()); Object.assign(close.style, { position: 'absolute', top: '5px', right: '8px', background: 'transparent', border: 'none', fontSize: '18px', color: '#e0e0e0', fontWeight: 'bold', transition: 'color 0.2s', padding: '5px 8px' }); close.onmouseover = () => close.style.color = '#fff'; close.onmouseout = () => close.style.color = '#e0e0e0'; close.title = 'Close AnnaUploader'; uiContainer.appendChild(close); // Gear icon for settings const settingsGear = createStyledButton('⚙️', () => { createSettingsUI(); }); Object.assign(settingsGear.style, { position: 'absolute', top: '5px', left: '8px', background: 'transparent', border: 'none', fontSize: '18px', color: '#e0e0e0', fontWeight: 'bold', transition: 'color 0.2s', padding: '5px 8px', }); settingsGear.onmouseover = () => settingsGear.style.color = '#fff'; settingsGear.onmouseout = () => settingsGear.style.color = '#e0e0e0'; settingsGear.title = 'Settings'; uiContainer.appendChild(settingsGear); const title = document.createElement('h3'); title.textContent = 'AnnaUploader'; title.style.margin = '0 0 10px 0'; title.style.color = '#4af'; title.style.textAlign = 'center'; uiContainer.appendChild(title); uiContainer.appendChild(createStyledButton('Upload T-Shirts', () => { const i = document.createElement('input'); i.type = 'file'; i.accept = 'image/*'; i.multiple = true; i.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT); i.click(); })); uiContainer.appendChild(createStyledButton('Upload Decals', () => { const i = document.createElement('input'); i.type = 'file'; i.accept = 'image/*'; i.multiple = true; i.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_DECAL); i.click(); })); uiContainer.appendChild(createStyledButton('Upload Both', () => { const i = document.createElement('input'); i.type = 'file'; i.accept = 'image/*'; i.multiple = true; i.onchange = e => handleFileSelect(e.target.files, null, true); i.click(); })); toggleBtn = createStyledButton('Enable Mass Upload', () => { massMode = !massMode; toggleBtn.textContent = massMode ? 'Disable Mass Upload' : 'Enable Mass Upload'; startBtn.style.display = massMode ? 'block' : 'none'; massQueue = []; batchTotal = completed = 0; updateStatus(); displayMessage(`Mass Upload Mode: ${massMode ? 'Enabled' : 'Disabled'}`, 'info'); }); uiContainer.appendChild(toggleBtn); startBtn = createStyledButton('Start Mass Upload', startMassUpload); startBtn.style.display = 'none'; Object.assign(startBtn.style, { background: '#28a745', border: '1px solid #218838' }); startBtn.onmouseover = () => startBtn.style.background = '#218838'; startBtn.onmouseout = () => startBtn.style.background = '#28a745'; uiContainer.appendChild(startBtn); const slipBtn = createStyledButton(`Slip Mode: ${useMakeUnique ? 'On' : 'Off'}`, () => { useMakeUnique = !useMakeUnique; GM_setValue('useMakeUnique', useMakeUnique); slipBtn.textContent = `Slip Mode: ${useMakeUnique ? 'On' : 'Off'}`; copiesInput.style.display = useMakeUnique ? 'block' : 'none'; downloadBtn.style.display = useMakeUnique ? 'block' : 'none'; if (!useMakeUnique) { useDownload = false; GM_setValue('useDownload', useDownload); downloadBtn.textContent = 'Download Images: Off'; } }); uiContainer.appendChild(slipBtn); copiesInput = document.createElement('input'); copiesInput.type = 'number'; copiesInput.min = '1'; copiesInput.value = uniqueCopies; Object.assign(copiesInput.style, { width: '100%', boxSizing: 'border-box', display: useMakeUnique ? 'block' : 'none', padding: '8px', borderRadius: '4px', border: '1px solid #555', background: '#333', color: '#fff', textAlign: 'center' }); copiesInput.onchange = e => { const v = parseInt(e.target.value, 10); if (v > 0) { uniqueCopies = v; GM_setValue('uniqueCopies', uniqueCopies); } else e.target.value = uniqueCopies; }; uiContainer.appendChild(copiesInput); downloadBtn = createStyledButton(`Download Images: ${useDownload ? 'On' : 'Off'}`, () => { useDownload = !useDownload; GM_setValue('useDownload', useDownload); downloadBtn.textContent = `Download Images: ${useDownload ? 'On' : 'Off'}`; }); downloadBtn.style.display = useMakeUnique ? 'block' : 'none'; uiContainer.appendChild(downloadBtn); uiContainer.appendChild(createStyledButton('Change ID', async () => { const inp = await customPrompt("Enter your Roblox User ID/URL or Group URL:", USER_ID || ''); if (inp === null) return; let id, isGrp = false; const um = inp.match(/users\/(\d+)/); const gm = inp.match(/communities\/(\d+)/); if (um) { id = um[1]; } else if (gm) { id = gm[1]; isGrp = true; } else { id = inp.trim(); if (isNaN(id) || id === '') { displayMessage('Invalid input. Please enter a number or a valid URL.', 'error'); return; } } USER_ID = Number(id); IS_GROUP = isGrp; GM_setValue('userId', USER_ID); GM_setValue('isGroup', IS_GROUP); displayMessage(`Set to ${isGrp ? 'Group' : 'User'} ID: ${USER_ID}`, 'success'); })); const pm = window.location.pathname.match(/^\/users\/(\d+)\/profile/); if (pm) { uiContainer.appendChild(createStyledButton('Use This Profile as ID', () => { USER_ID = Number(pm[1]); IS_GROUP = false; GM_setValue('userId', USER_ID); GM_setValue('isGroup', IS_GROUP); displayMessage(`User ID set to ${USER_ID}`, 'success'); })); } const gm = window.location.pathname.match(/^\/communities\/(\d+)/); if (gm) { uiContainer.appendChild(createStyledButton('Use This Group as ID', () => { USER_ID = Number(gm[1]); IS_GROUP = true; GM_setValue('userId', USER_ID); GM_setValue('isGroup', IS_GROUP); displayMessage(`Group ID set to ${USER_ID}`, 'success'); })); } // Removed the old 'Settings' button here. const hint = document.createElement('div'); hint.textContent = 'Paste images (Ctrl+V) to queue/upload'; hint.style.fontSize = '12px'; hint.style.color = '#aaa'; hint.style.textAlign = 'center'; hint.style.marginTop = '5px'; uiContainer.appendChild(hint); statusEl = document.createElement('div'); statusEl.style.fontSize = '13px'; statusEl.style.color = '#fff'; statusEl.style.textAlign = 'center'; statusEl.style.paddingTop = '5px'; statusEl.style.borderTop = '1px solid #333'; uiContainer.appendChild(statusEl); document.body.appendChild(uiContainer); } function createSettingsUI() { if (settingsModal) { settingsModal.style.display = 'flex'; return; } settingsModal = document.createElement('div'); Object.assign(settingsModal.style, { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: '300px', background: '#1a1a1a', border: '2px solid #333', color: '#e0e0e0', padding: '20px', zIndex: 10005, borderRadius: '10px', boxShadow: '0 6px 20px rgba(0,0,0,0.6)', display: 'flex', flexDirection: 'column', gap: '15px', fontFamily: 'Inter, Arial, sans-serif', }); const closeSettings = createStyledButton('×', () => { settingsModal.style.display = 'none'; }); Object.assign(closeSettings.style, { position: 'absolute', top: '8px', right: '10px', background: 'transparent', border: 'none', fontSize: '20px', color: '#e0e0e0', fontWeight: 'bold', transition: 'color 0.2s', padding: '5px 10px' }); closeSettings.onmouseover = () => closeSettings.style.color = '#fff'; closeSettings.onmouseout = () => closeSettings.style.color = '#e0e0e0'; closeSettings.title = 'Close Settings'; settingsModal.appendChild(closeSettings); const title = document.createElement('h3'); title.textContent = 'AnnaUploader Settings'; title.style.margin = '0 0 15px 0'; title.style.color = '#4af'; title.style.textAlign = 'center'; settingsModal.appendChild(title); const nameBtn = createStyledButton(`Use default Name: ${useForcedName ? 'On' : 'Off'}`, () => { useForcedName = !useForcedName; GM_setValue('useForcedName', useForcedName); nameBtn.textContent = `Use default Name: ${useForcedName ? 'On' : 'Off'}`; }); settingsModal.appendChild(nameBtn); // Slip Mode Pixel Method setting const slipModePixelMethodLabel = document.createElement('label'); slipModePixelMethodLabel.textContent = 'Slip Mode Pixel Method:'; Object.assign(slipModePixelMethodLabel.style, { display: 'block', marginBottom: '5px', fontSize: '14px', color: '#bbb' }); settingsModal.appendChild(slipModePixelMethodLabel); const slipModePixelMethodSelect = document.createElement('select'); Object.assign(slipModePixelMethodSelect.style, { width: '100%', padding: '10px', borderRadius: '5px', border: '1px solid #555', background: '#333', color: '#fff', fontSize: '14px', outline: 'none', marginBottom: '10px' }); const optionAll = document.createElement('option'); optionAll.value = 'all_pixels'; optionAll.textContent = 'All Pixels (±1)'; slipModePixelMethodSelect.appendChild(optionAll); const optionRandom = document.createElement('option'); optionRandom.value = '1-3_random'; optionRandom.textContent = 'Random Pixels (±1-3)'; slipModePixelMethodSelect.appendChild(optionRandom); const optionSingleRandom = document.createElement('option'); optionSingleRandom.value = '1-4_random_single_pixel'; optionSingleRandom.textContent = 'Single Random Pixel (±1-4)'; slipModePixelMethodSelect.appendChild(optionSingleRandom); const optionFullRandomSinglePixel = document.createElement('option'); optionFullRandomSinglePixel.value = 'random_single_pixel_full_random_color'; optionFullRandomSinglePixel.textContent = 'Single Random Pixel (Full Random Color)'; slipModePixelMethodSelect.appendChild(optionFullRandomSinglePixel); slipModePixelMethodSelect.value = slipModePixelMethod; slipModePixelMethodSelect.onchange = (e) => { slipModePixelMethod = e.target.value; GM_setValue('slipModePixelMethod', slipModePixelMethod); displayMessage(`Slip Mode Pixel Method set to: ${e.target.options[e.target.selectedIndex].text}`, 'success'); }; settingsModal.appendChild(slipModePixelMethodSelect); // Force Upload (through Canvas) toggle const forceUploadBtn = createStyledButton(`Force Upload: ${useForceCanvasUpload ? 'On' : 'Off'}`, () => { useForceCanvasUpload = !useForceCanvasUpload; GM_setValue('useForceCanvasUpload', useForceCanvasUpload); forceUploadBtn.textContent = `Force Upload: ${useForceCanvasUpload ? 'On' : 'Off'}`; displayMessage(`Force Upload Mode: ${useForceCanvasUpload ? 'Enabled' : 'Disabled'}`, 'info'); }); settingsModal.appendChild(forceUploadBtn); // IMAGE RESIZE FEATURE const resizeContainer = document.createElement('div'); resizeContainer.style.display = 'flex'; resizeContainer.style.flexDirection = 'column'; resizeContainer.style.gap = '5px'; resizeContainer.style.margin = '10px 0'; const resizeToggleBtn = createStyledButton(`Resize Images: ${enableResize ? 'On' : 'Off'}`, () => { enableResize = !enableResize; GM_setValue('enableResize', enableResize); resizeToggleBtn.textContent = `Resize Images: ${enableResize ? 'On' : 'Off'}`; widthInput.disabled = heightInput.disabled = !enableResize; }); resizeContainer.appendChild(resizeToggleBtn); // Input fields for width/height const inputRow = document.createElement('div'); inputRow.style.display = 'flex'; inputRow.style.gap = '7px'; inputRow.style.alignItems = 'center'; const widthInput = document.createElement('input'); widthInput.type = 'number'; widthInput.min = '1'; widthInput.value = resizeWidth; widthInput.placeholder = 'Width'; widthInput.style.width = '60px'; widthInput.style.padding = '6px'; widthInput.style.borderRadius = '4px'; widthInput.style.border = '1px solid #555'; widthInput.style.background = '#333'; widthInput.style.color = '#fff'; widthInput.disabled = !enableResize; widthInput.onchange = () => { let val = Math.max(1, parseInt(widthInput.value, 10) || 512); widthInput.value = val; resizeWidth = val; GM_setValue('resizeWidth', resizeWidth); }; inputRow.appendChild(widthInput); const xLabel = document.createElement('span'); xLabel.textContent = '×'; xLabel.style.color = '#ccc'; inputRow.appendChild(xLabel); const heightInput = document.createElement('input'); heightInput.type = 'number'; heightInput.min = '1'; heightInput.value = resizeHeight; heightInput.placeholder = 'Height'; heightInput.style.width = '60px'; heightInput.style.padding = '6px'; heightInput.style.borderRadius = '4px'; heightInput.style.border = '1px solid #555'; heightInput.style.background = '#333'; heightInput.style.color = '#fff'; heightInput.disabled = !enableResize; heightInput.onchange = () => { let val = Math.max(1, parseInt(heightInput.value, 10) || 512); heightInput.value = val; resizeHeight = val; GM_setValue('resizeHeight', resizeHeight); }; inputRow.appendChild(heightInput); const pxLabel = document.createElement('span'); pxLabel.textContent = 'px'; pxLabel.style.color = '#bbb'; inputRow.appendChild(pxLabel); resizeContainer.appendChild(inputRow); const resizeDesc = document.createElement('div'); resizeDesc.textContent = "If enabled, images will be resized before upload. Applies to Slip Mode too."; resizeDesc.style.fontSize = '12px'; resizeDesc.style.color = '#aaa'; resizeDesc.style.marginTop = '3px'; resizeContainer.appendChild(resizeDesc); settingsModal.appendChild(resizeContainer); settingsModal.appendChild(createStyledButton('Show Logged Assets', () => { const log = loadLog(); const entries = Object.entries(log); const w = window.open('', '_blank'); w.document.write(`<!DOCTYPE html> <html><head><meta charset="utf-8"><title>Logged Assets</title> <style> body { font-family:Arial; padding:20px; background:#121212; color:#f0f0f0; } h1 { margin-bottom:15px; color:#4af; } ul { list-style:none; padding:0; } li { margin-bottom:15px; padding:10px; background:#1e1e1e; border-radius:8px; display:flex; flex-direction:column; gap:8px;} img { max-height:60px; border:1px solid #444; border-radius:4px; object-fit:contain; background:#333; } .asset-info { display:flex;align-items:center;gap:15px; } a { color:#7cf; text-decoration:none; font-weight:bold; } a:hover { text-decoration:underline; } .asset-name { font-size:0.9em; color:#bbb; margin-left: auto; text-align: right; } button { margin-bottom:20px; color:#fff; background:#3a3a3a; border:1px solid #555; padding:8px 15px; border-radius:5px; cursor:pointer; } button:hover { background:#505050; } </style></head><body> <button onclick="document.body.style.background=(document.body.style.background==='#121212'?'#f0f0f0':'#121212');document.body.style.color=(document.body.style.color==='#f0f0f0'?'#121212':'#f0f0f0');d[...] <h1>Logged Assets</h1> ${ entries.length ? `<ul>${entries.map(([id,entry])=> `<li> <div class="asset-info"> ${ entry.image ? `<img src="${entry.image}" alt="Asset thumbnail">` : `<span style="color:#888;">(no image)</span>` } <a href="https://create.roblox.com/store/asset/${id}" target="_blank">${id}</a> <span style="font-size:0.85em; color:#999;">${new Date(entry.date).toLocaleString()}</span> </div> <div class="asset-name">${entry.name}</div> </li>`).join('') }</ul>` : `<p style="color:#888;"><em>No assets logged yet.</em></p>`} </body></html>`); w.document.close(); })); document.body.appendChild(settingsModal); } async function handlePaste(e) { const items = e.clipboardData?.items; if (!items) return; const resizeActive = enableResize && Number(resizeWidth) > 0 && Number(resizeHeight) > 0; for (const it of items) { if (it.type.startsWith('image')) { e.preventDefault(); const blob = it.getAsFile(); const ts = new Date().toISOString().replace(/[^a-z0-9]/gi,'_'); const pastedName = await customPrompt('Enter a name for the image (no extension):', `pasted_${ts}`); if (pastedName === null) return; let name = pastedName.trim() || `pasted_${ts}`; let filename = name.endsWith('.png') ? name : `${name}.png`; let fileToProcess = new File([blob], filename, {type: blob.type}); if (blob.type === 'image/webp') { displayMessage(`Converting pasted WebP image to PNG...`, 'info'); try { fileToProcess = await convertWebPToPng(fileToProcess); name = baseName(fileToProcess.name); filename = fileToProcess.name; displayMessage(`Pasted WebP converted to PNG.`, 'success'); } catch (error) { displayMessage(`Failed to convert pasted WebP: ${error.message}`, 'error'); console.error(`[Conversion] Failed to convert pasted WebP:`, error); return; } } // Resize if enabled if (resizeActive) { displayMessage(`Resizing pasted image to ${resizeWidth}x${resizeHeight}...`, 'info'); try { fileToProcess = await resizeImageFile(fileToProcess, Number(resizeWidth), Number(resizeHeight)); name = baseName(fileToProcess.name); filename = fileToProcess.name; displayMessage(`Pasted image resized.`, 'success'); } catch (error) { displayMessage(`Failed to resize pasted image: ${error.message}`, 'error'); console.error(`[Resize] Failed to resize pasted image:`, error); return; } } if (useForceCanvasUpload) { displayMessage(`Processing pasted image through canvas...`, 'info'); try { fileToProcess = await processImageThroughCanvas( fileToProcess, 'image/png', resizeActive ? Number(resizeWidth) : null, resizeActive ? Number(resizeHeight) : null ); name = baseName(fileToProcess.name); filename = fileToProcess.name; displayMessage(`Pasted image processed through canvas.`, 'success'); } catch (error) { displayMessage(`Failed to process pasted image through canvas: ${error.message}`, 'error'); console.error(`[Canvas Process] Failed to process pasted image:`, error); return; } } const typeChoice = await customPrompt('Upload as T=T-Shirt, D=Decal, B=Both, or C=Cancel?', 'D'); if (!typeChoice) return; const t = typeChoice.trim().toUpperCase(); let uploadAsBoth = false; let type = null; if (t === 'T') { type = ASSET_TYPE_TSHIRT; } else if (t === 'D') { type = ASSET_TYPE_DECAL; } else if (t === 'B') { uploadAsBoth = true; } else { displayMessage('Invalid asset type selected. Please choose T, D, or B.', 'error'); return; } handleFileSelect([fileToProcess], type, uploadAsBoth); break; } } } window.addEventListener('load', () => { createUI(); document.addEventListener('paste', handlePaste); scanForAssets(); console.log('[AnnaUploader] initialized; asset scan every ' + (SCAN_INTERVAL_MS/1000) + 's'); }); })();