Gallery: Add video uploads to gallery (#4796) * Gallery: Add video uploads to gallery * Improve thumbnailing logic * Add error handling to thumnailing process * Improve formatting in utils.js

a8004a9a9e84e1ff78d96e0aaa56728d31f08654

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
9 files changed, +181 -54Showing whitespace changes
package-lock.json+9 -5
@@ -73,7 +73,7 @@
73 "is-docker": "^3.0.0",73 "is-docker": "^3.0.0",
74 "localforage": "^1.10.0",74 "localforage": "^1.10.0",
75 "lodash": "^4.17.21",75 "lodash": "^4.17.21",
76 "mime-types": "^3.0.1",76 "mime-types": "^3.0.2",
77 "moment": "^2.30.1",77 "moment": "^2.30.1",
78 "morphdom": "^2.7.7",78 "morphdom": "^2.7.7",
79 "multer": "^2.0.2",79 "multer": "^2.0.2",
@@ -6432,15 +6432,19 @@
6432 }6432 }
6433 },6433 },
6434 "node_modules/mime-types": {6434 "node_modules/mime-types": {
6435 "version": "3.0.1",6435 "version": "3.0.2",
6436 "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",6436 "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
6437 "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",6437 "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
6438 "license": "MIT",6438 "license": "MIT",
6439 "dependencies": {6439 "dependencies": {
6440 "mime-db": "^1.54.0"6440 "mime-db": "^1.54.0"
6441 },6441 },
6442 "engines": {6442 "engines": {
6443 "node": ">= 0.6"6443 "node": ">=18"
6444 },
6445 "funding": {
6446 "type": "opencollective",
6447 "url": "https://opencollective.com/express"
6444 }6448 }
6445 },6449 },
6446 "node_modules/mime-types/node_modules/mime-db": {6450 "node_modules/mime-types/node_modules/mime-db": {
package.json+3 -3
@@ -63,7 +63,7 @@
63 "is-docker": "^3.0.0",63 "is-docker": "^3.0.0",
64 "localforage": "^1.10.0",64 "localforage": "^1.10.0",
65 "lodash": "^4.17.21",65 "lodash": "^4.17.21",
66 "mime-types": "^3.0.1",66 "mime-types": "^3.0.2",
67 "moment": "^2.30.1",67 "moment": "^2.30.1",
68 "morphdom": "^2.7.7",68 "morphdom": "^2.7.7",
69 "multer": "^2.0.2",69 "multer": "^2.0.2",
@@ -164,7 +164,7 @@
164 "@types/yargs": "^17.0.33",164 "@types/yargs": "^17.0.33",
165 "@types/yauzl": "^2.10.3",165 "@types/yauzl": "^2.10.3",
166 "eslint": "^8.57.1",166 "eslint": "^8.57.1",
167 "eslint-plugin-jsdoc": "^48.10.0",167 "eslint-plugin-jest": "^27.9.0",
168 "eslint-plugin-jest": "^27.9.0"168 "eslint-plugin-jsdoc": "^48.10.0"
169 }169 }
170}170}
public/scripts/constants.js+11 -0
@@ -118,6 +118,17 @@ export const MEDIA_TYPE = {
118};118};
119119
120/**120/**
121 * Bitwise flag-style media request types.
122 * @enum {number}
123 * @readonly
124 */
125export const MEDIA_REQUEST_TYPE = {
126 IMAGE: 0b001,
127 VIDEO: 0b010,
128 AUDIO: 0b100,
129};
130
131/**
121 * Scroll behavior options when appending media to messages.132 * Scroll behavior options when appending media to messages.
122 * @enum {string}133 * @enum {string}
123 * @readonly134 * @readonly
public/scripts/extensions/gallery/index.js+40 -12
@@ -8,7 +8,7 @@ import {
8 animation_easing,8 animation_easing,
9} from '../../../script.js';9} from '../../../script.js';
10import { groups, selected_group } from '../../group-chats.js';10import { groups, selected_group } from '../../group-chats.js';
11import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename, saveBase64AsFile, getFileExtension } from '../../utils.js';11import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename, saveBase64AsFile, getFileExtension, getVideoThumbnail, clamp } from '../../utils.js';
12import { loadMovingUIState } from '../../power-user.js';12import { loadMovingUIState } from '../../power-user.js';
13import { dragElement } from '../../RossAscends-mods.js';13import { dragElement } from '../../RossAscends-mods.js';
14import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';14import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -19,17 +19,14 @@ import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnum
19import { t, translate } from '../../i18n.js';19import { t, translate } from '../../i18n.js';
20import { Popup } from '../../popup.js';20import { Popup } from '../../popup.js';
21import { deleteMediaFromServer } from '../../chats.js';21import { deleteMediaFromServer } from '../../chats.js';
22import { MEDIA_REQUEST_TYPE, VIDEO_EXTENSIONS } from '../../constants.js';
2223
24const isVideo = (/** @type {string} */ url) => VIDEO_EXTENSIONS.some(ext => new RegExp(`.${ext}$`, 'i').test(url));
23const extensionName = 'gallery';25const extensionName = 'gallery';
24const extensionFolderPath = `scripts/extensions/${extensionName}/`;26const extensionFolderPath = `scripts/extensions/${extensionName}/`;
25let firstTime = true;27let firstTime = true;
26let deleteModeActive = false;28let deleteModeActive = false;
2729
28// Exposed defaults for future tweaking
29let thumbnailHeight = 150;
30let paginationVisiblePages = 10;
31let paginationMaxLinesPerPage = 2;
32let galleryMaxRows = 3;
3330
34// Remove all draggables associated with the gallery31// Remove all draggables associated with the gallery
35$('#movingDivs').on('click', '.dragClose', function () {32$('#movingDivs').on('click', '.dragClose', function () {
@@ -122,17 +119,34 @@ async function getGalleryItems(url) {
122 folder: url,119 folder: url,
123 sortField: sortObj.field,120 sortField: sortObj.field,
124 sortOrder: sortObj.order,121 sortOrder: sortObj.order,
122 type: MEDIA_REQUEST_TYPE.IMAGE | MEDIA_REQUEST_TYPE.VIDEO,
125 }),123 }),
126 });124 });
127125
128 url = await getSanitizedFilename(url);126 url = await getSanitizedFilename(url);
129127
130 const data = await response.json();128 const data = await response.json();
131 const items = data.map((file) => ({129 const items = [];
130
131 for (const file of data) {
132 const item = {
132 src: `user/images/${url}/${file}`,133 src: `user/images/${url}/${file}`,
133 srct: `user/images/${url}/${file}`,134 srct: `user/images/${url}/${file}`,
134 title: '', // Optional title for each item135 title: '', // Optional title for each item
135 }));136 };
137
138 if (isVideo(file)) {
139 try {
140 // 150px of max height with some allowance for various aspect ratios
141 const maxSide = Math.round(150 * 1.5);
142 item.srct = await getVideoThumbnail(item.src, maxSide, maxSide);
143 } catch (error) {
144 console.error('Failed to generate video thumbnail for gallery:', error);
145 }
146 }
147
148 items.push(item);
149 }
136150
137 return items;151 return items;
138}152}
@@ -198,6 +212,12 @@ function getSortOrder() {
198 * @returns {Promise<void>} - Promise representing the completion of the gallery initialization.212 * @returns {Promise<void>} - Promise representing the completion of the gallery initialization.
199 */213 */
200async function initGallery(items, url) {214async function initGallery(items, url) {
215 // Exposed defaults for future tweaking
216 const thumbnailHeight = 150;
217 const paginationVisiblePages = 5;
218 const paginationMaxLinesPerPage = 2;
219 const galleryMaxRows = clamp(Math.floor((window.innerHeight * 0.9 - 75) / thumbnailHeight), 1, 10);
220
201 const nonce = `nonce-${Math.random().toString(36).substring(2, 15)}`;221 const nonce = `nonce-${Math.random().toString(36).substring(2, 15)}`;
202 const gallery = $('#dragGallery');222 const gallery = $('#dragGallery');
203 gallery.addClass(nonce);223 gallery.addClass(nonce);
@@ -210,6 +230,7 @@ async function initGallery(items, url) {
210 galleryMaxRows: galleryMaxRows,230 galleryMaxRows: galleryMaxRows,
211 galleryPaginationTopButtons: false,231 galleryPaginationTopButtons: false,
212 galleryNavigationOverlayButtons: true,232 galleryNavigationOverlayButtons: true,
233 galleryPaginationMode: 'rectangles',
213 galleryTheme: {234 galleryTheme: {
214 navigationBar: { background: 'none', borderTop: '', borderBottom: '', borderRight: '', borderLeft: '' },235 navigationBar: { background: 'none', borderTop: '', borderBottom: '', borderRight: '', borderLeft: '' },
215 navigationBreadcrumb: { background: '#111', color: '#fff', colorHover: '#ccc', borderRadius: '4px' },236 navigationBreadcrumb: { background: '#111', color: '#fff', colorHover: '#ccc', borderRadius: '4px' },
@@ -399,7 +420,7 @@ async function makeMovable(url) {
399 // Create a hidden file input420 // Create a hidden file input
400 const fileInput = document.createElement('input');421 const fileInput = document.createElement('input');
401 fileInput.type = 'file';422 fileInput.type = 'file';
402 fileInput.accept = 'image/*';423 fileInput.accept = 'image/*,video/*';
403 fileInput.multiple = true;424 fileInput.multiple = true;
404 fileInput.style.display = 'none';425 fileInput.style.display = 'none';
405426
@@ -617,12 +638,19 @@ function makeDragImg(id, url) {
617 const newElement = document.importNode(template.content, true);638 const newElement = document.importNode(template.content, true);
618639
619 // Step 2: Append the given image640 // Step 2: Append the given image
620 const imgElem = document.createElement('img');641 const mediaElement = isVideo(url)
621 imgElem.src = url;642 ? document.createElement('video')
643 : document.createElement('img');
644 mediaElement.src = url;
645 if (mediaElement instanceof HTMLVideoElement) {
646 mediaElement.controls = true;
647 mediaElement.autoplay = true;
648 }
649
622 let uniqueId = `draggable_${id}`;650 let uniqueId = `draggable_${id}`;
623 const draggableElem = /** @type {HTMLElement} */ (newElement.querySelector('.draggable'));651 const draggableElem = /** @type {HTMLElement} */ (newElement.querySelector('.draggable'));
624 if (draggableElem) {652 if (draggableElem) {
625 draggableElem.appendChild(imgElem);653 draggableElem.appendChild(mediaElement);
626654
627 // Find a unique id for the draggable element655 // Find a unique id for the draggable element
628656
public/scripts/extensions/gallery/style.css+4 -0
@@ -49,3 +49,7 @@
49 opacity: 1;49 opacity: 1;
50 filter: unset;50 filter: unset;
51}51}
52
53.galleryImageDraggable video {
54 width: 100%;
55}
public/scripts/utils.js+81 -27
@@ -1207,6 +1207,84 @@ export function getVideoDurationFromDataURL(dataUrl) {
1207}1207}
12081208
1209/**1209/**
1210 * Gets a thumbnail image from a video URL.
1211 * @param {string} videoUrl URL of the video
1212 * @param {number|null} [maxWidth=null] Maximum width of the thumbnail
1213 * @param {number|null} [maxHeight=null] Maximum height of the thumbnail
1214 * @param {string} [type='image/jpeg'] MIME type of the thumbnail
1215 * @returns {Promise<string>} Promise that resolves to a data URL of the video thumbnail
1216 */
1217export function getVideoThumbnail(videoUrl, maxWidth = null, maxHeight = null, type = 'image/jpeg') {
1218 const video = document.createElement('video');
1219 video.src = videoUrl;
1220 return new Promise((resolve, reject) => {
1221 video.onloadeddata = function () {
1222 // Set the time to capture the thumbnail at the middle of the video
1223 video.currentTime = video.duration / 2;
1224 };
1225 video.onseeked = function () {
1226 // Create a canvas to draw the thumbnail
1227 const canvas = document.createElement('canvas');
1228 const ctx = canvas.getContext('2d');
1229 const { thumbnailWidth, thumbnailHeight } = calculateThumbnailSize(video.videoWidth, video.videoHeight, maxWidth, maxHeight);
1230
1231 canvas.width = thumbnailWidth;
1232 canvas.height = thumbnailHeight;
1233 ctx.imageSmoothingEnabled = true;
1234 ctx.imageSmoothingQuality = 'high';
1235 ctx.fillStyle = 'black';
1236 ctx.fillRect(0, 0, thumbnailWidth, thumbnailHeight);
1237 ctx.drawImage(video, 0, 0, thumbnailWidth, thumbnailHeight);
1238 // Get the data URL of the thumbnail
1239 const dataUrl = canvas.toDataURL(type);
1240 resolve(dataUrl);
1241 };
1242 video.onerror = function () {
1243 reject(new Error('Failed to load video'));
1244 };
1245 });
1246}
1247
1248/**
1249 * Calculates the thumbnail size for a media element while maintaining aspect ratio.
1250 * @param {number} width Media width
1251 * @param {number} height Media height
1252 * @param {number?} maxWidth Max width (null = no limit)
1253 * @param {number?} maxHeight Max height (null = no limit)
1254 * @returns {{ thumbnailWidth: number, thumbnailHeight: number }} Thumbnail size
1255 */
1256export function calculateThumbnailSize(width, height, maxWidth, maxHeight) {
1257 // Calculate the thumbnail dimensions while maintaining the aspect ratio
1258 const aspectRatio = width / height;
1259 let thumbnailWidth = maxWidth;
1260 let thumbnailHeight = maxHeight;
1261
1262 if (maxWidth === null) {
1263 thumbnailWidth = width;
1264 maxWidth = width;
1265 }
1266
1267 if (maxHeight === null) {
1268 thumbnailHeight = height;
1269 maxHeight = height;
1270 }
1271
1272 // Do not upscale if image is already smaller than max dimensions
1273 if (width <= maxWidth && height <= maxHeight) {
1274 thumbnailWidth = width;
1275 thumbnailHeight = height;
1276 } else {
1277 if (width > height) {
1278 thumbnailHeight = maxWidth / aspectRatio;
1279 } else {
1280 thumbnailWidth = maxHeight * aspectRatio;
1281 }
1282 }
1283
1284 return { thumbnailWidth: Math.round(thumbnailWidth), thumbnailHeight: Math.round(thumbnailHeight) };
1285}
1286
1287/**
1210 * Gets the duration of an audio from a data URL.1288 * Gets the duration of an audio from a data URL.
1211 * @param {string} dataUrl Audio data URL1289 * @param {string} dataUrl Audio data URL
1212 * @returns {Promise<number>} Duration in seconds1290 * @returns {Promise<number>} Duration in seconds
@@ -1690,33 +1768,7 @@ export function createThumbnail(dataUrl, maxWidth = null, maxHeight = null, type
1690 img.onload = () => {1768 img.onload = () => {
1691 const canvas = document.createElement('canvas');1769 const canvas = document.createElement('canvas');
1692 const ctx = canvas.getContext('2d');1770 const ctx = canvas.getContext('2d');
16931771 const { thumbnailWidth, thumbnailHeight } = calculateThumbnailSize(img.width, img.height, maxWidth, maxHeight);
1694 // Calculate the thumbnail dimensions while maintaining the aspect ratio
1695 const aspectRatio = img.width / img.height;
1696 let thumbnailWidth = maxWidth;
1697 let thumbnailHeight = maxHeight;
1698
1699 if (maxWidth === null) {
1700 thumbnailWidth = img.width;
1701 maxWidth = img.width;
1702 }
1703
1704 if (maxHeight === null) {
1705 thumbnailHeight = img.height;
1706 maxHeight = img.height;
1707 }
1708
1709 // Do not upscale if image is already smaller than max dimensions
1710 if (img.width <= maxWidth && img.height <= maxHeight) {
1711 thumbnailWidth = img.width;
1712 thumbnailHeight = img.height;
1713 } else {
1714 if (img.width > img.height) {
1715 thumbnailHeight = maxWidth / aspectRatio;
1716 } else {
1717 thumbnailWidth = maxHeight * aspectRatio;
1718 }
1719 }
17201772
1721 // Set the canvas dimensions and draw the resized image1773 // Set the canvas dimensions and draw the resized image
1722 canvas.width = thumbnailWidth;1774 canvas.width = thumbnailWidth;
@@ -2386,6 +2438,7 @@ export async function fetchFaFile(name) {
2386 .map(rule => rule.selectorText.split(/,\s*/).map(selector => selector.split('::').shift().slice(1)))2438 .map(rule => rule.selectorText.split(/,\s*/).map(selector => selector.split('::').shift().slice(1)))
2387 ;2439 ;
2388}2440}
2441
2389export async function fetchFa() {2442export async function fetchFa() {
2390 return [...new Set((await Promise.all([2443 return [...new Set((await Promise.all([
2391 fetchFaFile('fontawesome.min.css'),2444 fetchFaFile('fontawesome.min.css'),
@@ -2782,4 +2835,5 @@ export async function importFromExternalUrl(url, { preserveFileName = null } = {
2782 break;2835 break;
2783 }2836 }
2784}2837}
2838
2785export const clamp = (value, min, max) => Math.min(Math.max(value, min), max);2839export const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
src/constants.js+10 -0
@@ -513,6 +513,16 @@ export const MEDIA_EXTENSIONS = [
513 'aiff',513 'aiff',
514];514];
515515
516/**
517 * Bitwise flag-style media request types.
518 */
519export const MEDIA_REQUEST_TYPE = {
520 IMAGE: 0b001,
521 VIDEO: 0b010,
522 AUDIO: 0b100,
523};
524
525
516export const ZAI_ENDPOINT = {526export const ZAI_ENDPOINT = {
517 COMMON: 'common',527 COMMON: 'common',
518 CODING: 'coding',528 CODING: 'coding',
src/endpoints/images.js+3 -2
@@ -6,7 +6,7 @@ import express from 'express';
6import sanitize from 'sanitize-filename';6import sanitize from 'sanitize-filename';
77
8import { clientRelativePath, removeFileExtension, getImages, isPathUnderParent } from '../util.js';8import { clientRelativePath, removeFileExtension, getImages, isPathUnderParent } from '../util.js';
9import { MEDIA_EXTENSIONS } from '../constants.js';9import { MEDIA_EXTENSIONS, MEDIA_REQUEST_TYPE } from '../constants.js';
1010
11/**11/**
12 * Ensure the directory for the provided file path exists.12 * Ensure the directory for the provided file path exists.
@@ -93,6 +93,7 @@ router.post('/list/:folder?', (request, response) => {
93 }93 }
9494
95 const directoryPath = path.join(request.user.directories.userImages, sanitize(request.body.folder));95 const directoryPath = path.join(request.user.directories.userImages, sanitize(request.body.folder));
96 const type = Number(request.body.type ?? MEDIA_REQUEST_TYPE.IMAGE);
96 const sort = request.body.sortField || 'date';97 const sort = request.body.sortField || 'date';
97 const order = request.body.sortOrder || 'asc';98 const order = request.body.sortOrder || 'asc';
9899
@@ -100,7 +101,7 @@ router.post('/list/:folder?', (request, response) => {
100 fs.mkdirSync(directoryPath, { recursive: true });101 fs.mkdirSync(directoryPath, { recursive: true });
101 }102 }
102103
103 const images = getImages(directoryPath, sort);104 const images = getImages(directoryPath, sort, type);
104 if (order === 'desc') {105 if (order === 'desc') {
105 images.reverse();106 images.reverse();
106 }107 }
src/util.js+20 -5
@@ -17,7 +17,7 @@ import mime from 'mime-types';
17import { default as simpleGit } from 'simple-git';17import { default as simpleGit } from 'simple-git';
18import chalk from 'chalk';18import chalk from 'chalk';
19import bytes from 'bytes';19import bytes from 'bytes';
20import { LOG_LEVELS, CHAT_COMPLETION_SOURCES } from './constants.js';20import { LOG_LEVELS, CHAT_COMPLETION_SOURCES, MEDIA_REQUEST_TYPE } from './constants.js';
21import { serverDirectory } from './server-directory.js';21import { serverDirectory } from './server-directory.js';
22import { isFirefox } from './express-common.js';22import { isFirefox } from './express-common.js';
2323
@@ -500,9 +500,10 @@ export function removeOldBackups(directory, prefix, limit = null) {
500 * Get a list of images in a directory.500 * Get a list of images in a directory.
501 * @param {string} directoryPath Path to the directory containing the images501 * @param {string} directoryPath Path to the directory containing the images
502 * @param {'name' | 'date'} sortBy Sort images by name or date502 * @param {'name' | 'date'} sortBy Sort images by name or date
503 * @param {number} type Bitwise flag representing media types to include
503 * @returns {string[]} List of image file names504 * @returns {string[]} List of image file names
504 */505 */
505export function getImages(directoryPath, sortBy = 'name') {506export function getImages(directoryPath, sortBy = 'name', type = MEDIA_REQUEST_TYPE.IMAGE) {
506 function getSortFunction() {507 function getSortFunction() {
507 switch (sortBy) {508 switch (sortBy) {
508 case 'name':509 case 'name':
@@ -515,10 +516,24 @@ export function getImages(directoryPath, sortBy = 'name') {
515 }516 }
516517
517 return fs518 return fs
518 .readdirSync(directoryPath)519 .readdirSync(directoryPath, { withFileTypes: true })
520 .filter(dirent => dirent.isFile())
521 .map(dirent => dirent.name)
519 .filter(file => {522 .filter(file => {
520 const type = mime.lookup(file);523 const fileType = mime.lookup(file);
521 return type && type.startsWith('image/');524 if (!fileType) {
525 return false;
526 }
527 if ((type & MEDIA_REQUEST_TYPE.IMAGE) && fileType.startsWith('image/')) {
528 return true;
529 }
530 if ((type & MEDIA_REQUEST_TYPE.VIDEO) && fileType.startsWith('video/')) {
531 return true;
532 }
533 if ((type & MEDIA_REQUEST_TYPE.AUDIO) && fileType.startsWith('audio/')) {
534 return true;
535 }
536 return false;
522 })537 })
523 .sort(getSortFunction());538 .sort(getSortFunction());
524}539}