From c051a6caa6b8c7e7e0a429405d77fc0f5fa81bfc Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Sun, 11 Jan 2026 09:38:33 -0800 Subject: [PATCH] Add automatic thumbnail generation to file uploads in GUI (#2261) Introduces thumbnail generation for image files during upload operations. Thumbnails are generated client-side if enabled via the `generateThumbnails` option, and are included in file metadata and operation payloads. This enhances file previews and user experience in the file system. --- src/gui/src/helpers.js | 6 +- .../modules/FileSystem/operations/upload.js | 128 +++++++++++++++++- 2 files changed, 128 insertions(+), 6 deletions(-) diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index f74ece492..ab436121a 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -877,6 +877,7 @@ window.create_file = async (options) => { // create file try { puter.fs.upload(new File(content, filename), dirname, { + generateThumbnails: true, success: async function (data) { const created_file = $(appendto_element).find(`.item[data-path="${html_encode(dirname)}/${html_encode(data.name)}"]`); if ( created_file.length > 0 ) { @@ -1834,6 +1835,7 @@ window.upload_items = async function (items, dest_path) { dest_path, // options { + generateThumbnails: true, // init init: async (operation_id, xhr) => { opid = operation_id; @@ -2353,6 +2355,7 @@ window.unzipItem = async function (itemPath) { // options { createFileParent: true, + generateThumbnails: true, progress: async function (operation_id, op_progress) { progwin.set_progress(op_progress); // update title if window is not visible @@ -2600,6 +2603,7 @@ window.untarItem = async function (itemPath) { `${rootdir.path }/`, { createFileParent: true, + generateThumbnails: true, progress: async function (operation_id, op_progress) { progwin.set_progress(op_progress); if ( document.visibilityState !== 'visible' ) { @@ -3273,4 +3277,4 @@ window.handle_same_name_exists = async ({ } return false; } -}; \ No newline at end of file +}; diff --git a/src/puter-js/src/modules/FileSystem/operations/upload.js b/src/puter-js/src/modules/FileSystem/operations/upload.js index d98b48894..bfbb9d216 100644 --- a/src/puter-js/src/modules/FileSystem/operations/upload.js +++ b/src/puter-js/src/modules/FileSystem/operations/upload.js @@ -2,6 +2,97 @@ import path from '../../../lib/path.js'; import * as utils from '../../../lib/utils.js'; import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; +const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024; +const DEFAULT_THUMBNAIL_DIMENSION = 128; +const MIN_THUMBNAIL_DIMENSION = 32; + +const isLikelyImageFile = (file) => { + if ( ! file ) return false; + if ( file.type && file.type.startsWith('image/') ) return true; + const name = (file.name || '').toLowerCase(); + return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.avif', '.jfif'].some(ext => name.endsWith(ext)); +}; + +const estimateDataUrlSize = (dataUrl) => { + if ( ! dataUrl ) return 0; + const commaIndex = dataUrl.indexOf(','); + const base64 = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1); + return Math.ceil(base64.length * 3 / 4); +}; + +const scaleDimensions = (width, height, maxDim) => { + const base = Math.max(width, height) || 1; + const scale = Math.min(1, maxDim / base); + const w = Math.max(1, Math.round(width * scale)); + const h = Math.max(1, Math.round(height * scale)); + return { width: w, height: h }; +}; + +const loadImageFromFile = (file) => new Promise((resolve, reject) => { + if ( typeof document === 'undefined' || typeof URL === 'undefined' || typeof Image === 'undefined' ) return resolve(null); + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + resolve(img); + }; + img.onerror = (e) => { + URL.revokeObjectURL(url); + reject(e); + }; + img.src = url; +}); + +const renderThumbnail = (img, maxDim, type, quality) => { + if ( !img || typeof document === 'undefined' ) return null; + const { width, height } = scaleDimensions(img.naturalWidth || img.width, img.naturalHeight || img.height, maxDim); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if ( ! ctx ) return null; + ctx.drawImage(img, 0, 0, width, height); + try { + return canvas.toDataURL(type, quality); + } catch (e) { + return null; + } +}; + +const defaultThumbnailGenerator = async (file) => { + try { + if ( typeof document === 'undefined' ) return undefined; + if ( typeof File === 'undefined' || !(file instanceof File) ) return undefined; + if ( ! isLikelyImageFile(file) ) return undefined; + + const img = await loadImageFromFile(file); + if ( ! img ) return undefined; + + let dimension = DEFAULT_THUMBNAIL_DIMENSION; + const formats = [ + { type: 'image/webp', quality: 0.85 }, + { type: 'image/jpeg', quality: 0.8 }, + { type: 'image/png' }, + ]; + + while ( dimension >= MIN_THUMBNAIL_DIMENSION ) { + for ( const { type, quality } of formats ) { + const dataUrl = renderThumbnail(img, dimension, type, quality); + if ( ! dataUrl ) continue; + if ( estimateDataUrlSize(dataUrl) <= MAX_THUMBNAIL_BYTES ) { + return dataUrl; + } + } + dimension = Math.floor(dimension / 2); + } + } catch (e) { + // Ignore thumbnail errors; upload should proceed without them. + return undefined; + } + + return undefined; +}; + /* eslint-disable */ const upload = async function (items, dirPath, options = {}) { return new Promise(async (resolve, reject) => { @@ -200,6 +291,19 @@ const upload = async function (items, dirPath, options = {}) { return error({ code: 'EMPTY_UPLOAD', message: 'No files or directories to upload.' }); } + let thumbnails = []; + const shouldGenerateThumbnails = options.generateThumbnails || options.thumbnailGenerator; + if ( files.length && shouldGenerateThumbnails ) { + const generator = options.thumbnailGenerator || defaultThumbnailGenerator; + thumbnails = await Promise.all(files.map(async (file) => { + try { + return await generator(file); + } catch (e) { + return undefined; + } + })); + } + // Check storage capacity. // We need to check the storage capacity before the upload starts because // we want to avoid uploading files in case there is not enough storage space. @@ -287,20 +391,28 @@ const upload = async function (items, dirPath, options = {}) { // Append file metadata to upload request if ( ! options.shortcutTo ) { for ( let i = 0; i < files.length; i++ ) { - fd.append('fileinfo', JSON.stringify({ + const thumbnail = thumbnails[i] ?? options.thumbnail ?? undefined; + const fileinfo_payload = { name: files[i].name, type: files[i].type, size: files[i].size, + }; + if ( thumbnail ) { + fileinfo_payload.thumbnail = thumbnail; + } + fd.append('fileinfo', JSON.stringify({ + ...fileinfo_payload, })); } } // Append write operations for each file for ( let i = 0; i < files.length; i++ ) { - fd.append('operation', JSON.stringify({ + const thumbnail = thumbnails[i] ?? options.thumbnail ?? undefined; + const operation = { op: options.shortcutTo ? 'shortcut' : 'write', dedupe_name: options.dedupeName ?? true, overwrite: options.overwrite ?? false, - thumbnail: options.thumbnail ?? undefined, + thumbnail, create_missing_ancestors: (options.createMissingAncestors || options.createMissingParents), operation_id: operation_id, path: ( @@ -315,7 +427,13 @@ const upload = async function (items, dirPath, options = {}) { shortcut_to: options.shortcutTo, shortcut_to_uid: options.shortcutTo, app_uid: options.appUID, - })); + }; + + if ( thumbnail === undefined ) { + delete operation.thumbnail; + } + + fd.append('operation', JSON.stringify(operation)); } // Append files to upload @@ -465,4 +583,4 @@ const upload = async function (items, dirPath, options = {}) { }); }; -export default upload; \ No newline at end of file +export default upload;