diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts index 6e186231e6e..8a515aeb92a 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts @@ -932,7 +932,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { private updatePlayerOnNoLive () { this.peertubePlayer.unload() this.peertubePlayer.disable() - this.peertubePlayer.setPoster(this.video.previewPath) + this.peertubePlayer.setPoster(this.video.previewUrl) } private buildHotkeysHelp (video: Video) { diff --git a/client/src/app/shared/shared-main/video/video-edit.model.ts b/client/src/app/shared/shared-main/video/video-edit.model.ts index e8e1f90ca09..d656fa1571d 100644 --- a/client/src/app/shared/shared-main/video/video-edit.model.ts +++ b/client/src/app/shared/shared-main/video/video-edit.model.ts @@ -1,4 +1,3 @@ -import { getAbsoluteAPIUrl } from '@app/helpers' import { objectKeysTyped } from '@peertube/peertube-core-utils' import { VideoCommentPolicyType, @@ -62,8 +61,8 @@ export class VideoEdit implements VideoUpdate { this.commentsPolicy = video.commentsPolicy.id this.downloadEnabled = video.downloadEnabled - if (video.thumbnailPath) this.thumbnailUrl = getAbsoluteAPIUrl() + video.thumbnailPath - if (video.previewPath) this.previewUrl = getAbsoluteAPIUrl() + video.previewPath + if (video.thumbnailUrl) this.thumbnailUrl = video.thumbnailUrl + if (video.previewUrl) this.previewUrl = video.previewUrl this.scheduleUpdate = video.scheduledUpdate this.originallyPublishedAt = video.originallyPublishedAt diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts index 1c092e9ceca..9f3145252c0 100644 --- a/client/src/app/shared/shared-main/video/video.model.ts +++ b/client/src/app/shared/shared-main/video/video.model.ts @@ -1,6 +1,6 @@ import { AuthUser } from '@app/core' import { User } from '@app/core/users/user.model' -import { durationToString, getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers' +import { durationToString, getAbsoluteEmbedUrl } from '@app/helpers' import { Actor } from '@app/shared/shared-main/account/actor.model' import { buildVideoWatchPath, getAllFiles, peertubeTranslate } from '@peertube/peertube-core-utils' import { @@ -48,14 +48,12 @@ export class Video implements VideoServerModel { name: string serverHost: string - thumbnailPath: string thumbnailUrl: string aspectRatio: number isLive: boolean - previewPath: string previewUrl: string embedPath: string @@ -125,8 +123,6 @@ export class Video implements VideoServerModel { } constructor (hash: VideoServerModel, translations: { [ id: string ]: string } = {}) { - const absoluteAPIUrl = getAbsoluteAPIUrl() - this.createdAt = new Date(hash.createdAt.toString()) this.publishedAt = new Date(hash.publishedAt.toString()) this.category = hash.category @@ -151,15 +147,9 @@ export class Video implements VideoServerModel { this.isLocal = hash.isLocal this.name = hash.name - this.thumbnailPath = hash.thumbnailPath - this.thumbnailUrl = this.thumbnailPath - ? hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath) - : null + this.thumbnailUrl = hash.thumbnailUrl - this.previewPath = hash.previewPath - this.previewUrl = this.previewPath - ? hash.previewUrl || (absoluteAPIUrl + hash.previewPath) - : null + this.previewUrl = hash.previewUrl this.embedPath = hash.embedPath this.embedUrl = hash.embedUrl || (getAbsoluteEmbedUrl() + hash.embedPath) diff --git a/client/src/app/shared/shared-tables/video-cell.component.html b/client/src/app/shared/shared-tables/video-cell.component.html index 709e2869f5e..37e617b94b4 100644 --- a/client/src/app/shared/shared-tables/video-cell.component.html +++ b/client/src/app/shared/shared-tables/video-cell.component.html @@ -1,7 +1,7 @@
- +
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.model.ts b/client/src/app/shared/shared-video-playlist/video-playlist.model.ts index e9ba4cf9693..d6d69a50a87 100644 --- a/client/src/app/shared/shared-video-playlist/video-playlist.model.ts +++ b/client/src/app/shared/shared-video-playlist/video-playlist.model.ts @@ -34,7 +34,6 @@ export class VideoPlaylist implements ServerVideoPlaylist { ownerAccount: AccountSummary videoChannel?: VideoChannelSummary - thumbnailPath: string thumbnailUrl: string embedPath: string @@ -63,11 +62,7 @@ export class VideoPlaylist implements ServerVideoPlaylist { this.description = hash.description this.privacy = hash.privacy - this.thumbnailPath = hash.thumbnailPath - - this.thumbnailUrl = this.thumbnailPath - ? hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath) - : absoluteAPIUrl + '/client/assets/images/default-playlist.jpg' + this.thumbnailUrl = hash.thumbnailUrl || absoluteAPIUrl + '/client/assets/images/default-playlist.jpg' this.embedPath = hash.embedPath this.embedUrl = hash.embedUrl || (getAbsoluteEmbedUrl() + hash.embedPath) diff --git a/client/src/standalone/player/src/shared/playlist/playlist-menu-item.ts b/client/src/standalone/player/src/shared/playlist/playlist-menu-item.ts index 578950161fb..abf0df92cb2 100644 --- a/client/src/standalone/player/src/shared/playlist/playlist-menu-item.ts +++ b/client/src/standalone/player/src/shared/playlist/playlist-menu-item.ts @@ -83,7 +83,7 @@ class PlaylistMenuItem extends Component { positionBlock.appendChild(player) const thumbnail = super.createEl('img', { - src: window.location.origin + videoElement.video.thumbnailPath + src: window.location.origin + videoElement.video.thumbnailUrl }) const infoBlock = super.createEl('div', { diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index 74af84059c4..86dd13d20aa 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts @@ -381,7 +381,7 @@ export class PeerTubeEmbed { this.peertubePlayer.unload() this.peertubePlayer.disable() - this.peertubePlayer.setPoster(video.previewPath) + this.peertubePlayer.setPoster(video.previewUrl) } private async handlePasswordError (err: PeerTubeServerError) { diff --git a/client/src/standalone/videos/shared/player-options-builder.ts b/client/src/standalone/videos/shared/player-options-builder.ts index 3bd0c199538..6e4da132d70 100644 --- a/client/src/standalone/videos/shared/player-options-builder.ts +++ b/client/src/standalone/videos/shared/player-options-builder.ts @@ -294,7 +294,7 @@ export class PlayerOptionsBuilder { duration: video.duration, videoRatio: video.aspectRatio, - poster: getBackendUrl() + video.previewPath, + poster: video.previewUrl, embedUrl: getBackendUrl() + video.embedPath, embedTitle: video.name, diff --git a/config/default.yaml b/config/default.yaml index 6e5dae6f6f9..1c469e28494 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -266,6 +266,12 @@ object_storage: prefix: '' base_url: '' + # Video thumbnails + thumbnails: + bucket_name: 'thumbnails' + prefix: '' + base_url: '' + log: level: 'info' # 'debug' | 'info' | 'warn' | 'error' diff --git a/package.json b/package.json index d6aa10ccfcc..a545b3b13ae 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,6 @@ "ip-anonymize": "^0.1.0", "ipaddr.js": "2.2.0", "iso-639-3": "3.0.1", - "jimp": "^0.22.4", "js-yaml": "^4.0.0", "jsonld": "~8.3.1", "jsonwebtoken": "^9.0.2", @@ -181,6 +180,7 @@ "sanitize-html": "2.x", "sequelize": "~6.37.3", "sequelize-typescript": "^2.0.0-beta.1", + "sharp": "^0.33.5", "short-uuid": "^5.2.0", "sitemap": "^8.0.0", "socket.io": "^4.5.4", diff --git a/packages/ffmpeg/src/ffmpeg-images.ts b/packages/ffmpeg/src/ffmpeg-images.ts index 2f6ecbff2ad..96b3e99c07f 100644 --- a/packages/ffmpeg/src/ffmpeg-images.ts +++ b/packages/ffmpeg/src/ffmpeg-images.ts @@ -1,8 +1,31 @@ import { MutexInterface } from 'async-mutex' import { FfprobeData } from 'fluent-ffmpeg' +import { Duplex, PassThrough, Readable, Stream, Writable } from 'node:stream' import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' import { getVideoStreamDuration } from './ffprobe.js' +async function streamToBuffer (readableStream: Stream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + + readableStream.on('data', data => { + if (typeof data === 'string') { + chunks.push(Buffer.from(data, 'utf-8')) + } else if (data instanceof Buffer) { + chunks.push(data) + } else { + const jsonData = JSON.stringify(data) + chunks.push(Buffer.from(jsonData, 'utf-8')) + } + }) + + readableStream.on('end', () => { + resolve(Buffer.concat(chunks)) + }) + + readableStream.on('error', reject) + }) +} export class FFmpegImage { private readonly commandWrapper: FFmpegCommandWrapper @@ -10,39 +33,30 @@ export class FFmpegImage { this.commandWrapper = new FFmpegCommandWrapper(options) } - convertWebPToJPG (options: { - path: string - destination: string - }): Promise { - const { path, destination } = options - - this.commandWrapper.buildCommand(path) - .output(destination) - - return this.commandWrapper.runCommand({ silent: true }) - } - - processGIF (options: { - path: string - destination: string + async processGIF (options: { + source: string | Buffer + destination: string | null newSize?: { width: number, height: number } - }): Promise { - const { path, destination, newSize } = options + }) { + const { source, destination, newSize } = options - const command = this.commandWrapper.buildCommand(path) + const command = this.commandWrapper.buildCommand(source === 'string' ? source : Readable.from(source)) if (newSize) command.size(`${newSize.width}x${newSize.height}`) - command.output(destination) + const stream = new Duplex() + command.output(destination ?? stream) - return this.commandWrapper.runCommand() + await this.commandWrapper.runCommand() + + return streamToBuffer(stream) } // --------------------------------------------------------------------------- async generateThumbnailFromVideo (options: { fromPath: string - output: string + output: string | null framesToAnalyze: number scale?: { width: number @@ -54,26 +68,32 @@ export class FFmpegImage { let duration = await getVideoStreamDuration(fromPath, ffprobe) if (isNaN(duration)) duration = 0 + const outputPath = options.output + const outputStream = new PassThrough() - this.buildGenerateThumbnailFromVideo(options) + this.buildGenerateThumbnailFromVideo({ ...options, output: outputPath ?? outputStream }) .seekInput(duration / 2) try { - return await this.commandWrapper.runCommand() + await this.commandWrapper.runCommand() + + return outputPath ?? await streamToBuffer(outputStream) } catch (err) { this.commandWrapper.debugLog('Cannot generate thumbnail from video using seek input, fallback to no seek', { err }) this.commandWrapper.resetCommand() - this.buildGenerateThumbnailFromVideo(options) + this.buildGenerateThumbnailFromVideo({ ...options, output: outputPath ?? outputStream }) + + await this.commandWrapper.runCommand() - return this.commandWrapper.runCommand() + return outputPath ?? await streamToBuffer(outputStream) } } private buildGenerateThumbnailFromVideo (options: { fromPath: string - output: string + output: string | Writable framesToAnalyze: number scale?: { width: number @@ -87,7 +107,13 @@ export class FFmpegImage { .outputOption('-frames:v 1') .outputOption('-q:v 5') .outputOption('-abort_on empty_output') - .output(output) + + if (output instanceof Writable) { + command.outputOption('-f image2') + command.pipe(output) + } else { + command.addOutput(output) + } if (scale) { command.videoFilter(`scale=${scale.width}x${scale.height}:force_original_aspect_ratio=decrease`) diff --git a/packages/models/src/videos/playlist/video-playlist.model.ts b/packages/models/src/videos/playlist/video-playlist.model.ts index 4261aac256e..105a3412e36 100644 --- a/packages/models/src/videos/playlist/video-playlist.model.ts +++ b/packages/models/src/videos/playlist/video-playlist.model.ts @@ -17,8 +17,7 @@ export interface VideoPlaylist { description: string privacy: VideoConstant - thumbnailPath: string - thumbnailUrl?: string + thumbnailUrl: string videosLength: number diff --git a/packages/models/src/videos/video.model.ts b/packages/models/src/videos/video.model.ts index 65ed150e031..3d2be4b6660 100644 --- a/packages/models/src/videos/video.model.ts +++ b/packages/models/src/videos/video.model.ts @@ -35,11 +35,9 @@ export interface Video extends Partial { isLive: boolean - thumbnailPath: string - thumbnailUrl?: string + thumbnailUrl: string - previewPath: string - previewUrl?: string + previewUrl: string embedPath: string embedUrl?: string diff --git a/packages/tests/src/api/live/live-save-replay.ts b/packages/tests/src/api/live/live-save-replay.ts index 70abcf20b98..1fe964da1f6 100644 --- a/packages/tests/src/api/live/live-save-replay.ts +++ b/packages/tests/src/api/live/live-save-replay.ts @@ -154,9 +154,9 @@ describe('Save replay setting', function () { async function checkVideoThumbnail (videoId: string, thumbnailfile: string, previewfile?: string) { for (const server of servers) { const video = await server.videos.get({ id: videoId }) - await testImageGeneratedByFFmpeg(server.url, thumbnailfile, video.thumbnailPath, '') + await testImageGeneratedByFFmpeg(server.url, thumbnailfile, video.thumbnailUrl, '') - if (previewfile) await testImageGeneratedByFFmpeg(server.url, previewfile, video.previewPath, '') + if (previewfile) await testImageGeneratedByFFmpeg(server.url, previewfile, video.previewUrl, '') } } diff --git a/packages/tests/src/api/live/live.ts b/packages/tests/src/api/live/live.ts index a9ebff50574..49b734de712 100644 --- a/packages/tests/src/api/live/live.ts +++ b/packages/tests/src/api/live/live.ts @@ -129,8 +129,8 @@ describe('Test live', function () { expect(video.downloadEnabled).to.be.false expect(video.privacy.id).to.equal(VideoPrivacy.PUBLIC) - await testImageGeneratedByFFmpeg(server.url, 'video_short1-preview.webm', video.previewPath) - await testImageGeneratedByFFmpeg(server.url, 'video_short1.webm', video.thumbnailPath) + await testImageGeneratedByFFmpeg(server.url, 'video_short1-preview.webm', video.previewUrl) + await testImageGeneratedByFFmpeg(server.url, 'video_short1.webm', video.thumbnailUrl) const live = await server.live.get({ videoId: liveVideoUUID }) @@ -170,8 +170,8 @@ describe('Test live', function () { expect(video.privacy.id).to.equal(VideoPrivacy.UNLISTED) expect(video.nsfw).to.be.true - await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) - await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: video.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: video.previewUrl, expectedStatus: HttpStatusCode.OK_200 }) } }) diff --git a/packages/tests/src/api/runners/runner-vod-transcoding.ts b/packages/tests/src/api/runners/runner-vod-transcoding.ts index 433e355a868..d4c396558c5 100644 --- a/packages/tests/src/api/runners/runner-vod-transcoding.ts +++ b/packages/tests/src/api/runners/runner-vod-transcoding.ts @@ -464,8 +464,7 @@ describe('Test runner VOD transcoding', function () { const video = await servers[0].videos.get({ id: videoUUID }) const { body: inputFile } = await makeGetRequest({ - url: servers[0].url, - path: video.previewPath, + url: video.previewUrl, expectedStatus: HttpStatusCode.OK_200 }) diff --git a/packages/tests/src/api/server/lazy-static.ts b/packages/tests/src/api/server/lazy-static.ts index 447a2c80133..dc531a04b87 100644 --- a/packages/tests/src/api/server/lazy-static.ts +++ b/packages/tests/src/api/server/lazy-static.ts @@ -35,8 +35,8 @@ describe('Test lazy static endpoinds', function () { const fetchRemoteImages = async () => { const video = await servers[1].videos.get({ id: videoId }) - await makeGetRequest({ url: servers[1].url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) - await makeGetRequest({ url: servers[1].url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: servers[1].url, path: video.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: servers[1].url, path: video.previewUrl, expectedStatus: HttpStatusCode.OK_200 }) } await fetchRemoteImages() diff --git a/packages/tests/src/api/server/services.ts b/packages/tests/src/api/server/services.ts index c6074f25c76..1e5c0f3e422 100644 --- a/packages/tests/src/api/server/services.ts +++ b/packages/tests/src/api/server/services.ts @@ -82,7 +82,7 @@ describe('Test services', function () { `title="${video.name}" src="http://${server.host}/videos/embed/${video.shortUUID}${suffix.output}" ` + 'frameborder="0" allowfullscreen>' - const expectedThumbnailUrl = 'http://' + server.host + video.previewPath + const expectedThumbnailUrl = video.previewUrl expect(res.body.html).to.equal(expectedHtml) expect(res.body.title).to.equal(video.name) diff --git a/packages/tests/src/api/transcoding/transcoder.ts b/packages/tests/src/api/transcoding/transcoder.ts index e1f9e05e967..f02bd55811a 100644 --- a/packages/tests/src/api/transcoding/transcoder.ts +++ b/packages/tests/src/api/transcoding/transcoder.ts @@ -327,8 +327,8 @@ describe('Test video transcoding', function () { expect(videoDetails.files).to.have.lengthOf(1) - await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) - await makeGetRequest({ url: server.url, path: videoDetails.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: videoDetails.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: videoDetails.previewUrl, expectedStatus: HttpStatusCode.OK_200 }) const magnetUri = videoDetails.files[0].magnetUri expect(magnetUri).to.contain('.mp4') @@ -351,8 +351,8 @@ describe('Test video transcoding', function () { expect(videoDetails.files).to.have.lengthOf(1) - await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) - await makeGetRequest({ url: server.url, path: videoDetails.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: videoDetails.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: videoDetails.previewUrl, expectedStatus: HttpStatusCode.OK_200 }) const magnetUri = videoDetails.files[0].magnetUri expect(magnetUri).to.contain('.mp4') diff --git a/packages/tests/src/api/users/user-videos.ts b/packages/tests/src/api/users/user-videos.ts index 47310e75e46..f45c1eb6d0f 100644 --- a/packages/tests/src/api/users/user-videos.ts +++ b/packages/tests/src/api/users/user-videos.ts @@ -143,8 +143,8 @@ describe('Test user videos', function () { const video = data[0] expect(video.name).to.equal('super user video') - expect(video.thumbnailPath).to.not.be.null - expect(video.previewPath).to.not.be.null + expect(video.thumbnailUrl).to.not.be.null + expect(video.previewUrl).to.not.be.null }) it('Should be able to filter by channel in my videos', async function () { @@ -159,8 +159,8 @@ describe('Test user videos', function () { const video = data[0] expect(video.name).to.equal('super user video') - expect(video.thumbnailPath).to.not.be.null - expect(video.previewPath).to.not.be.null + expect(video.thumbnailUrl).to.not.be.null + expect(video.previewUrl).to.not.be.null } { diff --git a/packages/tests/src/api/videos/multiple-servers.ts b/packages/tests/src/api/videos/multiple-servers.ts index de021dcf959..2a966663ba4 100644 --- a/packages/tests/src/api/videos/multiple-servers.ts +++ b/packages/tests/src/api/videos/multiple-servers.ts @@ -785,7 +785,10 @@ describe('Test multiple servers', function () { for (const server of servers) { const video = await server.videos.get({ id: videoUUID }) - await testImageGeneratedByFFmpeg(server.url, 'video_short1-preview.webm', video.previewPath) + /** + * TODO: Change to work with previewUrl + */ + await testImageGeneratedByFFmpeg(server.url, 'video_short1-preview.webm', video.previewUrl) } }) }) diff --git a/packages/tests/src/api/videos/single-server.ts b/packages/tests/src/api/videos/single-server.ts index 1370a23691a..8551057a7ca 100644 --- a/packages/tests/src/api/videos/single-server.ts +++ b/packages/tests/src/api/videos/single-server.ts @@ -261,7 +261,7 @@ describe('Test a single server', function () { for (const video of data) { const videoName = video.name.replace(' name', '') - await testImageGeneratedByFFmpeg(server.url, videoName, video.thumbnailPath) + await testImageGeneratedByFFmpeg(server.url, videoName, video.thumbnailUrl) } }) diff --git a/packages/tests/src/api/videos/video-imports.ts b/packages/tests/src/api/videos/video-imports.ts index 562bfe82272..ceb8b2206f1 100644 --- a/packages/tests/src/api/videos/video-imports.ts +++ b/packages/tests/src/api/videos/video-imports.ts @@ -71,7 +71,7 @@ async function checkVideoServer2 (server: PeerTubeServer, id: number | string) { expect(video.description).to.equal('my super description') expect(video.tags).to.deep.equal([ 'supertag1', 'supertag2' ]) - await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', video.thumbnailPath) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', video.thumbnailUrl) expect(video.files).to.have.lengthOf(1) @@ -119,15 +119,15 @@ describe('Test video imports', function () { expect(video.name).to.equal('small video - youtube') { - expect(video.thumbnailPath).to.match(new RegExp(`^/lazy-static/thumbnails/.+.jpg$`)) - expect(video.previewPath).to.match(new RegExp(`^/lazy-static/previews/.+.jpg$`)) + expect(video.thumbnailUrl).to.match(new RegExp(`.+.jpg$`)) + expect(video.previewUrl).to.match(new RegExp(`.+.jpg$`)) const suffix = mode === 'yt-dlp' ? '_yt_dlp' : '' - await testImageGeneratedByFFmpeg(servers[0].url, 'video_import_thumbnail' + suffix, video.thumbnailPath) - await testImageGeneratedByFFmpeg(servers[0].url, 'video_import_preview' + suffix, video.previewPath) + await testImageGeneratedByFFmpeg(servers[0].url, 'video_import_thumbnail' + suffix, video.thumbnailUrl) + await testImageGeneratedByFFmpeg(servers[0].url, 'video_import_preview' + suffix, video.previewUrl) } const bodyCaptions = await servers[0].captions.list({ videoId: video.id }) diff --git a/packages/tests/src/api/videos/video-playlist-thumbnails.ts b/packages/tests/src/api/videos/video-playlist-thumbnails.ts index d79c92f723c..67e3b338812 100644 --- a/packages/tests/src/api/videos/video-playlist-thumbnails.ts +++ b/packages/tests/src/api/videos/video-playlist-thumbnails.ts @@ -83,7 +83,7 @@ describe('Playlist thumbnail', function () { for (const server of servers) { const p = await getPlaylistWithoutThumbnail(server) - await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailPath) + await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailUrl) } }) @@ -110,7 +110,7 @@ describe('Playlist thumbnail', function () { for (const server of servers) { const p = await getPlaylistWithThumbnail(server) - await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailUrl) } }) @@ -135,7 +135,7 @@ describe('Playlist thumbnail', function () { for (const server of servers) { const p = await getPlaylistWithoutThumbnail(server) - await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailPath) + await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailUrl) } }) @@ -160,7 +160,7 @@ describe('Playlist thumbnail', function () { for (const server of servers) { const p = await getPlaylistWithThumbnail(server) - await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailUrl) } }) @@ -176,7 +176,7 @@ describe('Playlist thumbnail', function () { for (const server of servers) { const p = await getPlaylistWithoutThumbnail(server) - await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailPath) + await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailUrl) } }) @@ -192,7 +192,7 @@ describe('Playlist thumbnail', function () { for (const server of servers) { const p = await getPlaylistWithThumbnail(server) - await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailUrl) } }) @@ -208,7 +208,7 @@ describe('Playlist thumbnail', function () { for (const server of servers) { const p = await getPlaylistWithoutThumbnail(server) - expect(p.thumbnailPath).to.be.null + expect(p.thumbnailUrl).to.be.null } }) @@ -224,7 +224,7 @@ describe('Playlist thumbnail', function () { for (const server of servers) { const p = await getPlaylistWithThumbnail(server) - await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailUrl) } }) diff --git a/packages/tests/src/api/videos/video-playlists.ts b/packages/tests/src/api/videos/video-playlists.ts index d418a7b3f90..024c60a7bae 100644 --- a/packages/tests/src/api/videos/video-playlists.ts +++ b/packages/tests/src/api/videos/video-playlists.ts @@ -316,11 +316,11 @@ describe('Test video playlists', function () { const playlist2 = body.data.find(p => p.displayName === 'playlist 2') expect(playlist2).to.not.be.undefined - await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', playlist2.thumbnailPath) + await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', playlist2.thumbnailUrl) const playlist3 = body.data.find(p => p.displayName === 'playlist 3') expect(playlist3).to.not.be.undefined - await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', playlist3.thumbnailPath) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', playlist3.thumbnailUrl) } const body = await servers[2].playlists.list({ start: 0, count: 5 }) @@ -338,7 +338,7 @@ describe('Test video playlists', function () { const playlist2 = body.data.find(p => p.displayName === 'playlist 2') expect(playlist2).to.not.be.undefined - await testImageGeneratedByFFmpeg(servers[2].url, 'thumbnail-playlist', playlist2.thumbnailPath) + await testImageGeneratedByFFmpeg(servers[2].url, 'thumbnail-playlist', playlist2.thumbnailUrl) expect(body.data.find(p => p.displayName === 'playlist 3')).to.not.be.undefined }) diff --git a/packages/tests/src/api/videos/video-source.ts b/packages/tests/src/api/videos/video-source.ts index fd593fbfdb3..dd41961af35 100644 --- a/packages/tests/src/api/videos/video-source.ts +++ b/packages/tests/src/api/videos/video-source.ts @@ -266,8 +266,8 @@ describe('Test video source management', function () { // Grab old paths to ensure we'll regenerate - previousPaths.push(video.previewPath) - previousPaths.push(video.thumbnailPath) + previousPaths.push(video.previewUrl) + previousPaths.push(video.thumbnailUrl) for (const file of files) { previousPaths.push(file.fileUrl) @@ -312,11 +312,11 @@ describe('Test video source management', function () { const files = getAllFiles(video) expect(files).to.have.lengthOf(4 * 2) - expect(previousPaths).to.not.include(video.previewPath) - expect(previousPaths).to.not.include(video.thumbnailPath) + expect(previousPaths).to.not.include(video.previewUrl) + expect(previousPaths).to.not.include(video.thumbnailUrl) - await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) - await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.previewUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) for (const file of files) { expect(previousPaths).to.not.include(file.fileUrl) @@ -389,11 +389,11 @@ describe('Test video source management', function () { for (const server of servers) { const video = await server.videos.get({ id: uuid }) - previousPaths.push(video.previewPath) - previousPaths.push(video.thumbnailPath) + previousPaths.push(video.previewUrl) + previousPaths.push(video.thumbnailUrl) - await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) - await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.previewUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) } await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' }) @@ -402,11 +402,11 @@ describe('Test video source management', function () { for (const server of servers) { const video = await server.videos.get({ id: uuid }) - expect(previousPaths).to.include(video.previewPath) - expect(previousPaths).to.include(video.thumbnailPath) + expect(previousPaths).to.include(video.previewUrl) + expect(previousPaths).to.include(video.thumbnailUrl) - await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) - await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.previewUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) } }) diff --git a/packages/tests/src/cli/house-keeping.ts b/packages/tests/src/cli/house-keeping.ts index 329d90d5a4e..9fa02f7ebe1 100644 --- a/packages/tests/src/cli/house-keeping.ts +++ b/packages/tests/src/cli/house-keeping.ts @@ -28,8 +28,8 @@ describe('House keeping CLI', function () { { const { data } = await servers[0].videos.list() for (const video of data) { - await makeGetRequest({ url: servers[0].url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) - await makeGetRequest({ url: servers[0].url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: servers[0].url, path: video.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: servers[0].url, path: video.previewUrl, expectedStatus: HttpStatusCode.OK_200 }) } } diff --git a/packages/tests/src/cli/regenerate-thumbnails.ts b/packages/tests/src/cli/regenerate-thumbnails.ts index 9767749155d..17ede45c860 100644 --- a/packages/tests/src/cli/regenerate-thumbnails.ts +++ b/packages/tests/src/cli/regenerate-thumbnails.ts @@ -16,8 +16,8 @@ async function testThumbnail (server: PeerTubeServer, videoId: number | string) const video = await server.videos.get({ id: videoId }) const requests = [ - makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }), - makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + makeGetRequest({ url: server.url, path: video.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }), + makeGetRequest({ url: server.url, path: video.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) ] for (const req of requests) { @@ -48,7 +48,7 @@ describe('Test regenerate thumbnails CLI', function () { const videoUUID1 = (await servers[0].videos.quickUpload({ name: 'video 1' })).uuid video1 = await servers[0].videos.get({ id: videoUUID1 }) - thumbnail1Path = join(servers[0].servers.buildDirectory('thumbnails'), basename(video1.thumbnailPath)) + thumbnail1Path = join(servers[0].servers.buildDirectory('thumbnails'), basename(video1.thumbnailUrl)) const videoUUID2 = (await servers[0].videos.quickUpload({ name: 'video 2' })).uuid video2 = await servers[0].videos.get({ id: videoUUID2 }) @@ -61,9 +61,9 @@ describe('Test regenerate thumbnails CLI', function () { remoteVideo = await servers[0].videos.get({ id: videoUUID }) // Load remote thumbnail on disk - await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) - thumbnailRemotePath = join(servers[0].servers.buildDirectory('thumbnails'), basename(remoteVideo.thumbnailPath)) + thumbnailRemotePath = join(servers[0].servers.buildDirectory('thumbnails'), basename(remoteVideo.thumbnailUrl)) } await writeFile(thumbnail1Path, '') @@ -72,17 +72,17 @@ describe('Test regenerate thumbnails CLI', function () { it('Should have empty thumbnails', async function () { { - const res = await makeGetRequest({ url: servers[0].url, path: video1.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + const res = await makeGetRequest({ url: servers[0].url, path: video1.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) expect(res.body).to.have.lengthOf(0) } { - const res = await makeGetRequest({ url: servers[0].url, path: video2.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + const res = await makeGetRequest({ url: servers[0].url, path: video2.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) expect(res.body).to.not.have.lengthOf(0) } { - const res = await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + const res = await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) expect(res.body).to.have.lengthOf(0) } }) @@ -97,21 +97,21 @@ describe('Test regenerate thumbnails CLI', function () { await testThumbnail(servers[0], video1.uuid) await testThumbnail(servers[0], video2.uuid) - const res = await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + const res = await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) expect(res.body).to.have.lengthOf(0) }) it('Should have deleted old thumbnail files', async function () { { - await makeGetRequest({ url: servers[0].url, path: video1.thumbnailPath, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await makeGetRequest({ url: servers[0].url, path: video1.thumbnailUrl, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) } { - await makeGetRequest({ url: servers[0].url, path: video2.thumbnailPath, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await makeGetRequest({ url: servers[0].url, path: video2.thumbnailUrl, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) } { - const res = await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + const res = await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailUrl, expectedStatus: HttpStatusCode.OK_200 }) expect(res.body).to.have.lengthOf(0) } }) diff --git a/packages/tests/src/server-helpers/image.ts b/packages/tests/src/server-helpers/image.ts index f345ca883dc..596f76b022f 100644 --- a/packages/tests/src/server-helpers/image.ts +++ b/packages/tests/src/server-helpers/image.ts @@ -37,28 +37,28 @@ describe('Image helpers', function () { it('Should skip processing if the source image is okay', async function () { const input = buildAbsoluteFixturePath('custom-thumbnail.jpg') - await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) + await processImage({ source: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) await checkBuffers(input, imageDestJPG, true) }) it('Should not skip processing if the source image does not have the appropriate extension', async function () { const input = buildAbsoluteFixturePath('custom-thumbnail.png') - await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) + await processImage({ source: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) await checkBuffers(input, imageDestJPG, false) }) it('Should not skip processing if the source image does not have the appropriate size', async function () { const input = buildAbsoluteFixturePath('custom-preview.jpg') - await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) + await processImage({ source: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) await checkBuffers(input, imageDestJPG, false) }) it('Should not skip processing if the source image does not have the appropriate size', async function () { const input = buildAbsoluteFixturePath('custom-thumbnail-big.jpg') - await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) + await processImage({ source: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) await checkBuffers(input, imageDestJPG, false) }) @@ -67,7 +67,7 @@ describe('Image helpers', function () { const input = buildAbsoluteFixturePath('exif.jpg') expect(await hasTitleExif(input)).to.be.true - await processImage({ path: input, destination: imageDestJPG, newSize: { width: 100, height: 100 }, keepOriginal: true }) + await processImage({ source: input, destination: imageDestJPG, newSize: { width: 100, height: 100 }, keepOriginal: true }) await checkBuffers(input, imageDestJPG, false) expect(await hasTitleExif(imageDestJPG)).to.be.false @@ -77,7 +77,7 @@ describe('Image helpers', function () { const input = buildAbsoluteFixturePath('exif.jpg') expect(await hasTitleExif(input)).to.be.true - await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) + await processImage({ source: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) await checkBuffers(input, imageDestJPG, false) expect(await hasTitleExif(imageDestJPG)).to.be.false @@ -87,7 +87,7 @@ describe('Image helpers', function () { const input = buildAbsoluteFixturePath('exif.png') expect(await hasTitleExif(input)).to.be.true - await processImage({ path: input, destination: imageDestPNG, newSize: thumbnailSize, keepOriginal: true }) + await processImage({ source: input, destination: imageDestPNG, newSize: thumbnailSize, keepOriginal: true }) expect(await hasTitleExif(imageDestPNG)).to.be.false }) diff --git a/packages/tests/src/shared/videos.ts b/packages/tests/src/shared/videos.ts index a0949da6f68..b29add62ed1 100644 --- a/packages/tests/src/shared/videos.ts +++ b/packages/tests/src/shared/videos.ts @@ -271,12 +271,12 @@ export async function completeVideoCheck (options: { expect(video.channel.createdAt).to.exist expect(dateIsValid(video.channel.updatedAt.toString())).to.be.true - expect(video.thumbnailPath).to.exist - await testImageGeneratedByFFmpeg(server.url, attributes.thumbnailfile || attributes.fixture, video.thumbnailPath) + expect(video.thumbnailUrl).to.exist + await testImageGeneratedByFFmpeg(server.url, attributes.thumbnailfile || attributes.fixture, video.thumbnailUrl) if (attributes.previewfile) { - expect(video.previewPath).to.exist - await testImageGeneratedByFFmpeg(server.url, attributes.previewfile, video.previewPath) + expect(video.previewUrl).to.exist + await testImageGeneratedByFFmpeg(server.url, attributes.previewfile, video.previewUrl) } if (attributes.files) { @@ -313,8 +313,8 @@ export async function checkVideoFilesWereRemoved (options: { const webVideoFiles = video.files || [] const hlsFiles = video.streamingPlaylists[0]?.files || [] - const thumbnailName = basename(video.thumbnailPath) - const previewName = basename(video.previewPath) + const thumbnailName = basename(video.thumbnailUrl) + const previewName = basename(video.previewUrl) const torrentNames = webVideoFiles.concat(hlsFiles).map(f => basename(f.torrentUrl)) diff --git a/server/core/helpers/audit-logger.ts b/server/core/helpers/audit-logger.ts index c4b95faae9a..07421647c06 100644 --- a/server/core/helpers/audit-logger.ts +++ b/server/core/helpers/audit-logger.ts @@ -116,8 +116,8 @@ const videoKeysToKeep = new Set([ 'duration', 'isLocal', 'name', - 'thumbnailPath', - 'previewPath', + 'thumbnailUrl', + 'previewUrl', 'nsfw', 'waitTranscoding', 'account-id', diff --git a/server/core/helpers/ffmpeg/ffmpeg-image.ts b/server/core/helpers/ffmpeg/ffmpeg-image.ts index 1f6c6a3c32e..7217fd835d9 100644 --- a/server/core/helpers/ffmpeg/ffmpeg-image.ts +++ b/server/core/helpers/ffmpeg/ffmpeg-image.ts @@ -8,7 +8,3 @@ export function processGIF (options: Parameters[0]) { export function generateThumbnailFromVideo (options: Parameters[0]) { return new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail')).generateThumbnailFromVideo(options) } - -export function convertWebPToJPG (options: Parameters[0]) { - return new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail')).convertWebPToJPG(options) -} diff --git a/server/core/helpers/image-utils.ts b/server/core/helpers/image-utils.ts index c36aa34de63..c7d9209390e 100644 --- a/server/core/helpers/image-utils.ts +++ b/server/core/helpers/image-utils.ts @@ -1,54 +1,56 @@ import { copy, remove } from 'fs-extra/esm' -import { readFile, rename } from 'fs/promises' -import { ColorActionName } from '@jimp/plugin-color' import { buildUUID, getLowercaseExtension } from '@peertube/peertube-node-utils' -import { convertWebPToJPG, processGIF } from './ffmpeg/index.js' +import { MIMETYPES } from '@server/initializers/constants.js' +import { processGIF } from './ffmpeg/index.js' import { logger } from './logger.js' - -import type Jimp from 'jimp' +import sharp from 'sharp' export function generateImageFilename (extension = '.jpg') { return buildUUID() + extension } export async function processImage (options: { - path: string - destination: string + source: string | Buffer + destination: string | null newSize?: { width: number, height: number } keepOriginal?: boolean // default false }) { - const { path, destination, newSize, keepOriginal = false } = options + const { source, destination, newSize, keepOriginal = false } = options + const sourcePath = typeof source === 'string' ? source : null - const extension = getLowercaseExtension(path) + const extension = sourcePath ? getLowercaseExtension(sourcePath) : '.jpg' - if (path === destination) { - throw new Error('Jimp/FFmpeg needs an input path different that the output path.') + if (source === destination) { + throw new Error('sharp/FFmpeg needs an input path different than the output path.') } - logger.debug('Processing image %s to %s.', path, destination) + logger.debug('Processing image %s to %s.', sourcePath ?? 'from buffer', destination ?? 'to buffer') + + let processDestination // Use FFmpeg to process GIF if (extension === '.gif') { - await processGIF({ path, destination, newSize }) + processDestination = await processGIF({ source, destination, newSize }) } else { - await jimpProcessor({ path, destination, newSize, inputExt: extension }) + processDestination = await sharpProcessor({ source, destination, newSize, inputExt: extension }) } - if (keepOriginal !== true) await remove(path) + if (keepOriginal !== true && !!sourcePath) await remove(sourcePath) + + logger.debug( + 'Finished processing image %s to %s.', + sourcePath ?? 'from buffer', destination ?? 'to buffer' + ) - logger.debug('Finished processing image %s to %s.', path, destination) + return processDestination } export async function getImageSize (path: string) { - const inputBuffer = await readFile(path) - - const Jimp = await import('jimp') - - const image = await Jimp.default.read(inputBuffer) + const image = await sharp(path).metadata() return { - width: image.getWidth(), - height: image.getHeight() + width: image.width, + height: image.height } } @@ -56,90 +58,103 @@ export async function getImageSize (path: string) { // Private // --------------------------------------------------------------------------- -async function jimpProcessor (options: { - path: string - destination: string +async function sharpProcessor (options: { + source: string | Buffer + destination: string | null newSize?: { width: number height: number } inputExt: string -}) { - const { path, destination, newSize, inputExt } = options - - let sourceImage: Jimp - const inputBuffer = await readFile(path) - - const Jimp = await import('jimp') - - try { - sourceImage = await Jimp.default.read(inputBuffer) - } catch (err) { - logger.debug('Cannot read %s with jimp. Try to convert the image using ffmpeg first.', path, { err }) - - const newName = path + '.jpg' - await convertWebPToJPG({ path, destination: newName }) - await rename(newName, path) - - sourceImage = await Jimp.default.read(path) - } +}): Promise { + const { source, destination, newSize, inputExt } = options - await remove(destination) + const sourceImage = sharp(source) + const sourceImageMetadata = await sourceImage.metadata() // Optimization if the source file has the appropriate size - const outputExt = getLowercaseExtension(destination) - if (skipProcessing({ sourceImage, newSize, imageBytes: inputBuffer.byteLength, inputExt, outputExt })) { - return copy(path, destination) + const outputExt = typeof destination === 'string' ? getLowercaseExtension(destination) : null + const mimeType = MIMETYPES.IMAGE.EXT_MIMETYPE[inputExt] + // TODO: Remove null check, just for debug + if (skipProcessing({ sourceImageMetadata, newSize, imageBytes: sourceImageMetadata.size, inputExt, outputExt }) && source === null) { + if (destination === null) { + return sourceImage.toFormat('jpg').toBuffer() + } + + if (typeof source === 'string') { + await copy(source, destination) + } else { + await write(sourceImage, destination) + } + + return sourceImage.toFormat('jpg').toBuffer() } if (newSize) { - await autoResize({ sourceImage, newSize, destination }) - } else { + const processedImage = await autoResize({ mimeType, sourceImage, sourceImageMetadata, newSize, destination }) + return processedImage.toFormat('jpg').toBuffer() + } + + if (typeof destination === 'string') { await write(sourceImage, destination) } + + return sourceImage.toFormat('jpg').toBuffer() } async function autoResize (options: { - sourceImage: Jimp + sourceImage: sharp.Sharp + sourceImageMetadata: sharp.Metadata + mimeType: string newSize: { width: number, height: number } - destination: string + destination: string | null }) { - const { sourceImage, newSize, destination } = options + const { sourceImage, sourceImageMetadata, newSize, destination } = options // Portrait mode targeting a landscape, apply some effect on the image - const sourceIsPortrait = sourceImage.getWidth() <= sourceImage.getHeight() + const sourceIsPortrait = sourceImageMetadata.width <= sourceImageMetadata.height const destIsPortraitOrSquare = newSize.width <= newSize.height - removeExif(sourceImage) + let processedImage: sharp.Sharp if (sourceIsPortrait && !destIsPortraitOrSquare) { - const baseImage = sourceImage.cloneQuiet().cover(newSize.width, newSize.height) - .color([ { apply: ColorActionName.SHADE, params: [ 50 ] } ]) - - const topImage = sourceImage.cloneQuiet().contain(newSize.width, newSize.height) + const portrait = await sourceImage.toBuffer() + const background = await sourceImage + .resize({ fit: 'cover', height: newSize.height, width: newSize.width }) + .modulate({ brightness: 0.5 }) + .toBuffer() + + processedImage = sharp(portrait) + .resize({ width: newSize.width, height: newSize.height, fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .composite([ { blend: 'dest-over', input: background } ]) + } else { + processedImage = sourceImage.resize(newSize.width, newSize.height) + } - return write(baseImage.blit(topImage, 0, 0), destination) + if (typeof destination === 'string') { + await write(processedImage, destination) + return processedImage } - return write(sourceImage.cover(newSize.width, newSize.height), destination) + return processedImage } -function write (image: Jimp, destination: string) { - return image.quality(80).writeAsync(destination) +function write (image: sharp.Sharp, destination: string) { + return image.jpeg({ quality: 80 }).toFile(destination) } function skipProcessing (options: { - sourceImage: Jimp + sourceImageMetadata: sharp.Metadata newSize?: { width: number, height: number } imageBytes: number inputExt: string - outputExt: string + outputExt: string | null }) { - const { sourceImage, newSize, imageBytes, inputExt, outputExt } = options + const { sourceImageMetadata, newSize, imageBytes, inputExt, outputExt } = options - if (hasExif(sourceImage)) return false - if (newSize && (sourceImage.getWidth() !== newSize.width || sourceImage.getHeight() !== newSize.height)) return false - if (inputExt !== outputExt) return false + if (sourceImageMetadata.exif) return false + if (newSize && (sourceImageMetadata.width !== newSize.width || sourceImageMetadata.height !== newSize.height)) return false + if (outputExt !== null && inputExt !== outputExt) return false const kB = 1000 @@ -150,11 +165,3 @@ function skipProcessing (options: { return imageBytes <= 15 * kB } - -function hasExif (image: Jimp) { - return !!(image.bitmap as any).exifBuffer -} - -function removeExif (image: Jimp) { - (image.bitmap as any).exifBuffer = null -} diff --git a/server/core/initializers/checker-before-init.ts b/server/core/initializers/checker-before-init.ts index 6720f65dbe1..792c70aa7aa 100644 --- a/server/core/initializers/checker-before-init.ts +++ b/server/core/initializers/checker-before-init.ts @@ -73,6 +73,7 @@ function checkMissedConfig () { 'object_storage.web_videos.prefix', 'object_storage.web_videos.base_url', 'object_storage.original_video_files.bucket_name', 'object_storage.original_video_files.prefix', 'object_storage.original_video_files.base_url', 'object_storage.max_request_attempts', 'object_storage.captions.bucket_name', 'object_storage.captions.prefix', 'object_storage.captions.base_url', + 'object_storage.thumbnails.bucket_name', 'object_storage.thumbnails.prefix', 'object_storage.thumbnails.base_url', 'theme.default', 'feeds.videos.count', 'feeds.comments.count', 'geo_ip.enabled', 'geo_ip.country.database_url', 'geo_ip.city.database_url', diff --git a/server/core/initializers/config.ts b/server/core/initializers/config.ts index 0976060c183..b220c9bd1bd 100644 --- a/server/core/initializers/config.ts +++ b/server/core/initializers/config.ts @@ -182,6 +182,11 @@ const CONFIG = { PREFIX: config.get('object_storage.user_exports.prefix'), BASE_URL: config.get('object_storage.user_exports.base_url') }, + THUMBNAILS: { + BUCKET_NAME: config.get('object_storage.thumbnails.bucket_name'), + PREFIX: config.get('object_storage.thumbnails.prefix'), + BASE_URL: config.get('object_storage.thumbnails.base_url') + }, ORIGINAL_VIDEO_FILES: { BUCKET_NAME: config.get('object_storage.original_video_files.bucket_name'), PREFIX: config.get('object_storage.original_video_files.prefix'), diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index d5ac322ebc4..832b2c8fe96 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -46,7 +46,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js' // --------------------------------------------------------------------------- -export const LAST_MIGRATION_VERSION = 875 +export const LAST_MIGRATION_VERSION = 880 // --------------------------------------------------------------------------- diff --git a/server/core/initializers/migrations/0880-thumbnail-object-storage.ts b/server/core/initializers/migrations/0880-thumbnail-object-storage.ts new file mode 100644 index 00000000000..7f2020ffb02 --- /dev/null +++ b/server/core/initializers/migrations/0880-thumbnail-object-storage.ts @@ -0,0 +1,32 @@ +import { FileStorage } from '@peertube/peertube-models' +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + const { transaction } = utils + + { + await utils.queryInterface.addColumn('thumbnail', 'storage', { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: FileStorage.FILE_SYSTEM + }, { transaction }) + + await utils.queryInterface.changeColumn('thumbnail', 'storage', { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: null + }, { transaction }) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + down, up +} diff --git a/server/core/lib/local-actor.ts b/server/core/lib/local-actor.ts index c48127a2c74..02aacf549dc 100644 --- a/server/core/lib/local-actor.ts +++ b/server/core/lib/local-actor.ts @@ -43,7 +43,12 @@ export async function updateLocalActorImageFiles (options: { const imageName = buildUUID() + extension const destination = join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, imageName) - await processImageFromWorker({ path: imagePhysicalFile.path, destination, newSize: imageSize, keepOriginal: true }) + await processImageFromWorker({ + source: imagePhysicalFile.path, + destination, + newSize: imageSize, + keepOriginal: true + }) return { imageName, diff --git a/server/core/lib/object-storage/keys.ts b/server/core/lib/object-storage/keys.ts index 1c1e92d74fb..b524dd273b4 100644 --- a/server/core/lib/object-storage/keys.ts +++ b/server/core/lib/object-storage/keys.ts @@ -24,3 +24,7 @@ export function generateCaptionObjectStorageKey (filename: string) { export function generateUserExportObjectStorageKey (filename: string) { return filename } + +export function generateThumbnailObjectStorageKey (filename: string) { + return filename +} diff --git a/server/core/lib/object-storage/object-storage-helpers.ts b/server/core/lib/object-storage/object-storage-helpers.ts index 1934467ab91..5f12b09f2f3 100644 --- a/server/core/lib/object-storage/object-storage-helpers.ts +++ b/server/core/lib/object-storage/object-storage-helpers.ts @@ -11,11 +11,23 @@ import { getInternalUrl } from './urls.js' import { getClient } from './shared/client.js' import { lTags } from './shared/logger.js' -import type { _Object, ObjectCannedACL, PutObjectCommandInput, S3Client } from '@aws-sdk/client-s3' +import { + CopyObjectCommand, + CopyObjectCommandInput, + type + _Object, + type + ObjectCannedACL, + type + PutObjectCommandInput, + type + S3Client +} from '@aws-sdk/client-s3' type BucketInfo = { BUCKET_NAME: string PREFIX?: string + BASE_URL?: string } async function listKeysOfPrefix (prefix: string, bucketInfo: BucketInfo, continuationToken?: string) { @@ -92,6 +104,34 @@ async function storeStream (options: { return uploadToStorage({ objectStorageKey, content: stream, bucketInfo, isPrivate, contentType }) } +async function storeBuffer (options: { + buffer: Buffer + objectStorageKey: string + bucketInfo: BucketInfo + isPrivate: boolean + + contentType?: string +}): Promise { + const { buffer, objectStorageKey, bucketInfo, isPrivate, contentType } = options + + logger.debug('Streaming file to %s%s in bucket %s', bucketInfo.PREFIX, objectStorageKey, bucketInfo.BUCKET_NAME, lTags()) + + return uploadToStorage({ objectStorageKey, content: buffer, bucketInfo, isPrivate, contentType }) +} + +async function copyObjectKey (options: { + bucketInfo: BucketInfo + destinationKey: string + isPrivate: boolean + sourceKey: string +}) { + const { bucketInfo, destinationKey, isPrivate, sourceKey } = options + + logger.debug('Copying object from %s to %s in bucket %s', sourceKey, destinationKey, bucketInfo.BUCKET_NAME, lTags()) + + return copyOnStorage({ bucketInfo, destinationKey, isPrivate, sourceKey }) +} + // --------------------------------------------------------------------------- async function updateObjectACL (options: { @@ -276,9 +316,12 @@ export { buildKey, + copyObjectKey, + storeObject, storeContent, storeStream, + storeBuffer, removeObject, removeObjectByFullKey, @@ -298,7 +341,7 @@ export { // --------------------------------------------------------------------------- async function uploadToStorage (options: { - content: Readable | string + content: Buffer | Readable | string objectStorageKey: string bucketInfo: BucketInfo isPrivate: boolean @@ -353,6 +396,31 @@ async function uploadToStorage (options: { } } +async function copyOnStorage (options: { + bucketInfo: BucketInfo + destinationKey: string + isPrivate: boolean + sourceKey: string +}) { + const { bucketInfo, destinationKey, isPrivate, sourceKey } = options + const s3Client = await getClient() + + const CopySource = `/${bucketInfo.BUCKET_NAME}${(sourceKey)}` + + const input: CopyObjectCommandInput = { + Bucket: bucketInfo.BUCKET_NAME, + CopySource, + Key: buildKey(destinationKey, CONFIG.OBJECT_STORAGE.THUMBNAILS) + } + + const acl = getACL(isPrivate) + if (acl) input.ACL = acl + + const command = new CopyObjectCommand(input) + + return s3Client.send(command) +} + async function applyOnPrefix (options: { prefix: string bucketInfo: BucketInfo diff --git a/server/core/lib/object-storage/thumbnail.ts b/server/core/lib/object-storage/thumbnail.ts new file mode 100644 index 00000000000..00d3d079ba4 --- /dev/null +++ b/server/core/lib/object-storage/thumbnail.ts @@ -0,0 +1,31 @@ +import { CONFIG } from '@server/initializers/config.js' +import { MThumbnail } from '@server/types/models/index.js' +import { generateThumbnailObjectStorageKey } from './keys.js' +import { copyObjectKey, removeObject, storeBuffer } from './shared/index.js' +import { getInternalUrl, getObjectStorageKey } from './index.js' + +export function storeThumbnailFile (data: Buffer, thumbnail: MThumbnail) { + return storeBuffer({ + buffer: data, + objectStorageKey: generateThumbnailObjectStorageKey(thumbnail.filename), + bucketInfo: CONFIG.OBJECT_STORAGE.THUMBNAILS, + isPrivate: false + }) +} + +export async function copyThumbnailFile (sourceThumbnail: MThumbnail, destinationThumbnail: MThumbnail) { + const destinationKey = generateThumbnailObjectStorageKey(destinationThumbnail.filename) + + await copyObjectKey({ + bucketInfo: CONFIG.OBJECT_STORAGE.THUMBNAILS, + destinationKey, + isPrivate: false, + sourceKey: getObjectStorageKey(sourceThumbnail.fileUrl, CONFIG.OBJECT_STORAGE.THUMBNAILS) + }) + + return getInternalUrl(CONFIG.OBJECT_STORAGE.THUMBNAILS, destinationKey) +} + +export function removeThumbnailObjectStorage (thumbnail: MThumbnail) { + return removeObject(generateThumbnailObjectStorageKey(thumbnail.filename), CONFIG.OBJECT_STORAGE.THUMBNAILS) +} diff --git a/server/core/lib/object-storage/urls.ts b/server/core/lib/object-storage/urls.ts index ca185710c01..4c62442f342 100644 --- a/server/core/lib/object-storage/urls.ts +++ b/server/core/lib/object-storage/urls.ts @@ -15,6 +15,10 @@ export function getObjectStoragePublicFileUrl (fileUrl: string, objectStorageCon return replaceByBaseUrl(fileUrl, baseUrl) } +export function getObjectStorageKey (publicUrl: string, bucketInfo: BucketInfo) { + return publicUrl.replace(getBaseUrl(bucketInfo, bucketInfo.BASE_URL), '/') +} + // --------------------------------------------------------------------------- export function getHLSPrivateFileUrl (video: MVideoUUID, filename: string) { diff --git a/server/core/lib/thumbnail.ts b/server/core/lib/thumbnail.ts index 2a26c162940..1c378916fad 100644 --- a/server/core/lib/thumbnail.ts +++ b/server/core/lib/thumbnail.ts @@ -1,9 +1,8 @@ -import { ThumbnailType, ThumbnailType_Type, VideoFileStream } from '@peertube/peertube-models' +import { FileStorage, ThumbnailType, ThumbnailType_Type, VideoFileStream } from '@peertube/peertube-models' import { generateThumbnailFromVideo } from '@server/helpers/ffmpeg/ffmpeg-image.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import Bluebird from 'bluebird' import { FfprobeData } from 'fluent-ffmpeg' -import { remove } from 'fs-extra/esm' import { join } from 'path' import { generateImageFilename } from '../helpers/image-utils.js' import { CONFIG } from '../initializers/config.js' @@ -14,11 +13,34 @@ import { MThumbnail } from '../types/models/video/thumbnail.js' import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist.js' import { VideoPathManager } from './video-path-manager.js' import { downloadImageFromWorker, processImageFromWorker } from './worker/parent-process.js' +import { copyThumbnailFile, storeThumbnailFile } from './object-storage/thumbnail.js' const lTags = loggerTagsFactory('thumbnail') type ImageSize = { height?: number, width?: number } +export async function copyLocalPlaylistMiniatureFromObjectStorage (options: { + sourceThumbnail: MThumbnail + playlist: MVideoPlaylistThumbnail +}) { + const { playlist, sourceThumbnail } = options + const { filename, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist) + + const thumbnail = buildThumbnailModel({ + automaticallyGenerated: true, + existingThumbnail, + filename, + height, + type: ThumbnailType.MINIATURE, + width + }) + + thumbnail.storage = FileStorage.OBJECT_STORAGE + thumbnail.fileUrl = await copyThumbnailFile(sourceThumbnail, thumbnail) + + return thumbnail +} + export function updateLocalPlaylistMiniatureFromExisting (options: { inputPath: string playlist: MVideoPlaylistThumbnail @@ -29,9 +51,15 @@ export function updateLocalPlaylistMiniatureFromExisting (options: { const { inputPath, playlist, automaticallyGenerated, keepOriginal = false, size } = options const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist, size) const type = ThumbnailType.MINIATURE - - const thumbnailCreator = () => { - return processImageFromWorker({ path: inputPath, destination: outputPath, newSize: { width, height }, keepOriginal }) + const storage = CONFIG.OBJECT_STORAGE.ENABLED ? FileStorage.OBJECT_STORAGE : FileStorage.FILE_SYSTEM + + const thumbnailCreator = async () => { + return await processImageFromWorker({ + source: inputPath, + destination: outputPath, + newSize: { width, height }, + keepOriginal + }) } return updateThumbnailFromFunction({ @@ -41,7 +69,7 @@ export function updateLocalPlaylistMiniatureFromExisting (options: { width, type, automaticallyGenerated, - onDisk: true, + onDisk: storage === FileStorage.FILE_SYSTEM, existingThumbnail }) } @@ -54,17 +82,33 @@ export function updateRemotePlaylistMiniatureFromUrl (options: { const { downloadUrl, playlist, size } = options const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist, size) const type = ThumbnailType.MINIATURE + const storage = CONFIG.OBJECT_STORAGE.ENABLED ? FileStorage.OBJECT_STORAGE : FileStorage.FILE_SYSTEM // Only save the file URL if it is a remote playlist const fileUrl = playlist.isOwned() ? null : downloadUrl - const thumbnailCreator = () => { - return downloadImageFromWorker({ url: downloadUrl, destDir: basePath, destName: filename, size: { width, height } }) + const thumbnailCreator = async () => { + return await downloadImageFromWorker({ + url: downloadUrl, + destDir: basePath, + destName: filename, + size: { width, height }, + saveOnDisk: storage === FileStorage.FILE_SYSTEM + }) } - return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true }) + return updateThumbnailFromFunction({ + thumbnailCreator, + filename, + height, + width, + type, + existingThumbnail, + fileUrl, + onDisk: storage === FileStorage.FILE_SYSTEM + }) } // --------------------------------------------------------------------------- @@ -80,9 +124,15 @@ export function updateLocalVideoMiniatureFromExisting (options: { const { inputPath, video, type, automaticallyGenerated, size, keepOriginal = false } = options const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size) - - const thumbnailCreator = () => { - return processImageFromWorker({ path: inputPath, destination: outputPath, newSize: { width, height }, keepOriginal }) + const storage = CONFIG.OBJECT_STORAGE.ENABLED ? FileStorage.OBJECT_STORAGE : FileStorage.FILE_SYSTEM + + const thumbnailCreator = async () => { + return await processImageFromWorker({ + source: inputPath, + destination: outputPath, + newSize: { width, height }, + keepOriginal + }) } return updateThumbnailFromFunction({ @@ -93,7 +143,7 @@ export function updateLocalVideoMiniatureFromExisting (options: { type, automaticallyGenerated, existingThumbnail, - onDisk: true + onDisk: storage === FileStorage.FILE_SYSTEM }) } @@ -117,38 +167,40 @@ export function generateLocalVideoMiniature (options: { return -1 }) - let biggestImagePath: string + let biggestImage: Buffer return Bluebird.mapSeries(metadatas, metadata => { - const { filename, basePath, height, width, existingThumbnail, outputPath, type } = metadata + const { filename, height, width, existingThumbnail, outputPath, type } = metadata - let thumbnailCreator: () => Promise + const storage = CONFIG.OBJECT_STORAGE.ENABLED ? FileStorage.OBJECT_STORAGE : FileStorage.FILE_SYSTEM + let thumbnailCreator: () => Promise if (videoFile.isAudio()) { - thumbnailCreator = () => processImageFromWorker({ - path: ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, + thumbnailCreator = async () => await processImageFromWorker({ + source: ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, destination: outputPath, newSize: { width, height }, keepOriginal: true }) - } else if (biggestImagePath) { - thumbnailCreator = () => processImageFromWorker({ - path: biggestImagePath, + } else if (biggestImage) { + thumbnailCreator = async () => await processImageFromWorker({ + source: biggestImage, destination: outputPath, newSize: { width, height }, keepOriginal: true }) } else { - thumbnailCreator = () => generateImageFromVideoFile({ - fromPath: input, - folder: basePath, - imageName: filename, - size: { height, width }, - ffprobe - }) + thumbnailCreator = async () => { + biggestImage = await generateImageFromVideoFile({ + fromPath: input, + destination: outputPath, + size: { height, width }, + ffprobe + }) + + return biggestImage + } } - if (!biggestImagePath) biggestImagePath = outputPath - return updateThumbnailFromFunction({ thumbnailCreator, filename, @@ -156,7 +208,7 @@ export function generateLocalVideoMiniature (options: { width, type, automaticallyGenerated: true, - onDisk: true, + onDisk: storage === FileStorage.FILE_SYSTEM, existingThumbnail }) }) @@ -180,21 +232,37 @@ export function updateLocalVideoMiniatureFromUrl (options: { : downloadUrl const thumbnailUrlChanged = hasThumbnailUrlChanged(existingThumbnail, downloadUrl, video) + const storage = CONFIG.OBJECT_STORAGE.ENABLED ? FileStorage.OBJECT_STORAGE : FileStorage.FILE_SYSTEM // Do not change the thumbnail filename if the file did not change const filename = thumbnailUrlChanged ? updatedFilename : existingThumbnail.filename - const thumbnailCreator = () => { + const thumbnailCreator = async () => { if (thumbnailUrlChanged) { - return downloadImageFromWorker({ url: downloadUrl, destDir: basePath, destName: filename, size: { width, height } }) + return await downloadImageFromWorker({ + url: downloadUrl, + destDir: basePath, + destName: filename, + size: { width, height }, + saveOnDisk: storage === FileStorage.FILE_SYSTEM + }) } - return Promise.resolve() + return Promise.resolve(undefined) } - return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true }) + return updateThumbnailFromFunction({ + thumbnailCreator, + filename, + height, + width, + type, + existingThumbnail, + fileUrl, + onDisk: storage === FileStorage.FILE_SYSTEM + }) } export function updateRemoteVideoThumbnail (options: { @@ -262,7 +330,7 @@ function hasThumbnailUrlChanged (existingThumbnail: MThumbnail, downloadUrl: str return !existingUrl || existingUrl !== downloadUrl || downloadUrl.endsWith(`${video.uuid}.jpg`) } -function buildMetadataFromPlaylist (playlist: MVideoPlaylistThumbnail, size: ImageSize) { +function buildMetadataFromPlaylist (playlist: MVideoPlaylistThumbnail, size?: ImageSize) { const filename = playlist.generateThumbnailName() const basePath = CONFIG.STORAGE.THUMBNAILS_DIR @@ -290,7 +358,7 @@ function buildMetadataFromVideo (video: MVideoThumbnail, type: ThumbnailType_Typ filename, basePath, existingThumbnail, - outputPath: join(basePath, filename), + outputPath: CONFIG.OBJECT_STORAGE.ENABLED ? null : join(basePath, filename), height: size ? size.height : THUMBNAILS_SIZE.height, width: size ? size.width : THUMBNAILS_SIZE.width } @@ -305,7 +373,7 @@ function buildMetadataFromVideo (video: MVideoThumbnail, type: ThumbnailType_Typ filename, basePath, existingThumbnail, - outputPath: join(basePath, filename), + outputPath: CONFIG.OBJECT_STORAGE.ENABLED ? null : join(basePath, filename), height: size ? size.height : PREVIEWS_SIZE.height, width: size ? size.width : PREVIEWS_SIZE.width } @@ -315,7 +383,7 @@ function buildMetadataFromVideo (video: MVideoThumbnail, type: ThumbnailType_Typ } async function updateThumbnailFromFunction (parameters: { - thumbnailCreator: () => Promise + thumbnailCreator: () => Promise filename: string height: number width: number @@ -330,13 +398,44 @@ async function updateThumbnailFromFunction (parameters: { filename, width, height, - type, existingThumbnail, onDisk, automaticallyGenerated = null, fileUrl = null } = parameters + const thumbnail = buildThumbnailModel({ + automaticallyGenerated, + existingThumbnail, + filename, + height, + type: ThumbnailType.MINIATURE, + width + }) + + const thumbnailDestination = await thumbnailCreator() + + if (onDisk) { + thumbnail.fileUrl = fileUrl + thumbnail.storage = FileStorage.FILE_SYSTEM + } else { + thumbnail.storage = FileStorage.OBJECT_STORAGE + thumbnail.fileUrl = await storeThumbnailFile(thumbnailDestination, thumbnail) + } + + return thumbnail +} + +function buildThumbnailModel (options: { + automaticallyGenerated: boolean + existingThumbnail?: MThumbnail + filename: string + height: number + type: ThumbnailType_Type + width: number +}) { + const { automaticallyGenerated, existingThumbnail, filename, height, type, width } = options + const oldFilename = existingThumbnail && existingThumbnail.filename !== filename ? existingThumbnail.filename : undefined @@ -347,46 +446,40 @@ async function updateThumbnailFromFunction (parameters: { thumbnail.height = height thumbnail.width = width thumbnail.type = type - thumbnail.fileUrl = fileUrl thumbnail.automaticallyGenerated = automaticallyGenerated - thumbnail.onDisk = onDisk + thumbnail.onDisk = false if (oldFilename) thumbnail.previousThumbnailFilename = oldFilename - await thumbnailCreator() - return thumbnail } async function generateImageFromVideoFile (options: { fromPath: string - folder: string - imageName: string + destination: string size: { width: number, height: number } ffprobe?: FfprobeData }) { - const { fromPath, folder, imageName, size, ffprobe } = options - - const pendingImageName = 'pending-' + imageName - const pendingImagePath = join(folder, pendingImageName) + const { destination, fromPath, size, ffprobe } = options try { const framesToAnalyze = CONFIG.THUMBNAILS.GENERATION_FROM_VIDEO.FRAMES_TO_ANALYZE - await generateThumbnailFromVideo({ fromPath, output: pendingImagePath, framesToAnalyze, ffprobe, scale: size }) - - const destination = join(folder, imageName) - await processImageFromWorker({ path: pendingImagePath, destination, newSize: size }) + const thumbnailSource = await generateThumbnailFromVideo({ + fromPath, + output: null, + framesToAnalyze, + ffprobe, + scale: size + }) - return destination + return processImageFromWorker({ + source: thumbnailSource, + destination, + newSize: size + }) } catch (err) { logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() }) - try { - await remove(pendingImagePath) - } catch (err) { - logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() }) - } - throw err } } diff --git a/server/core/lib/video-playlist.ts b/server/core/lib/video-playlist.ts index 546ddba4f57..23d79b50b28 100644 --- a/server/core/lib/video-playlist.ts +++ b/server/core/lib/video-playlist.ts @@ -1,11 +1,11 @@ import * as Sequelize from 'sequelize' -import { VideoPlaylistPrivacy, VideoPlaylistType } from '@peertube/peertube-models' +import { FileStorage, VideoPlaylistPrivacy, VideoPlaylistType } from '@peertube/peertube-models' import { VideoPlaylistModel } from '../models/video/video-playlist.js' -import { MAccount, MVideoThumbnail } from '../types/models/index.js' +import { MAccount, MThumbnail, MVideoThumbnail } from '../types/models/index.js' import { MVideoPlaylistOwner, MVideoPlaylistThumbnail } from '../types/models/video/video-playlist.js' import { getLocalVideoPlaylistActivityPubUrl } from './activitypub/url.js' import { VideoMiniaturePermanentFileCache } from './files-cache/video-miniature-permanent-file-cache.js' -import { updateLocalPlaylistMiniatureFromExisting } from './thumbnail.js' +import { copyLocalPlaylistMiniatureFromObjectStorage, updateLocalPlaylistMiniatureFromExisting } from './thumbnail.js' import { logger } from '@server/helpers/logger.js' export async function createWatchLaterPlaylist (account: MAccount, t: Sequelize.Transaction) { @@ -34,18 +34,32 @@ export async function generateThumbnailForPlaylist (videoPlaylist: MVideoPlaylis return } + const copyFileFromDisk = (video.isOwned && videoMiniature.storage === FileStorage.FILE_SYSTEM) || !video.isOwned() + let thumbnailModel: MThumbnail + + if (copyFileFromDisk) { // Ensure the file is on disk - const videoMiniaturePermanentFileCache = new VideoMiniaturePermanentFileCache() - const inputPath = videoMiniature.isOwned() - ? videoMiniature.getPath() - : await videoMiniaturePermanentFileCache.downloadRemoteFile(videoMiniature) - - const thumbnailModel = await updateLocalPlaylistMiniatureFromExisting({ - inputPath, - playlist: videoPlaylist, - automaticallyGenerated: true, - keepOriginal: true - }) + const videoMiniaturePermanentFileCache = new VideoMiniaturePermanentFileCache() + let inputPath: string + + if (video.isOwned() && videoMiniature.storage === FileStorage.FILE_SYSTEM) { + inputPath = videoMiniature.getPath() + } else { + inputPath = await videoMiniaturePermanentFileCache.downloadRemoteFile(videoMiniature) as string + } + + thumbnailModel = await updateLocalPlaylistMiniatureFromExisting({ + inputPath, + playlist: videoPlaylist, + automaticallyGenerated: true, + keepOriginal: true + }) + } else { + thumbnailModel = await copyLocalPlaylistMiniatureFromObjectStorage({ + sourceThumbnail: videoMiniature, + playlist: videoPlaylist + }) + } thumbnailModel.videoPlaylistId = videoPlaylist.id diff --git a/server/core/lib/worker/parent-process.ts b/server/core/lib/worker/parent-process.ts index 1a2606edbd9..03ffabcc293 100644 --- a/server/core/lib/worker/parent-process.ts +++ b/server/core/lib/worker/parent-process.ts @@ -3,18 +3,19 @@ import { Piscina } from 'piscina' import { JOB_CONCURRENCY, WORKER_THREADS } from '@server/initializers/constants.js' import type httpBroadcast from './workers/http-broadcast.js' import type downloadImage from './workers/image-downloader.js' -import type processImage from './workers/image-processor.js' +import processImage from './workers/image-processor.js' import type getImageSize from './workers/get-image-size.js' import type signJsonLDObject from './workers/sign-json-ld-object.js' import type buildDigest from './workers/build-digest.js' import type httpUnicast from './workers/http-unicast.js' import { logger } from '@server/helpers/logger.js' +import { Readable } from 'stream' let downloadImageWorker: Piscina -export function downloadImageFromWorker (options: Parameters[0]): Promise> { +export async function downloadImageFromWorker (options: Parameters[0]): Promise> { if (!downloadImageWorker) { - downloadImageWorker = new Piscina({ + downloadImageWorker = new Piscina({ filename: new URL(join('workers', 'image-downloader.js'), import.meta.url).href, concurrentTasksPerWorker: WORKER_THREADS.DOWNLOAD_IMAGE.CONCURRENCY, maxThreads: WORKER_THREADS.DOWNLOAD_IMAGE.MAX_THREADS, @@ -24,16 +25,21 @@ export function downloadImageFromWorker (options: Parameters logger.error('Error in download image worker', { err })) } - return downloadImageWorker.run(options) + const processedImage = await downloadImageWorker.run(options) + + return processedImage + } // --------------------------------------------------------------------------- let processImageWorker: Piscina -export function processImageFromWorker (options: Parameters[0]): Promise> { +export async function processImageFromWorker ( + options: Parameters[0] +): Promise> { if (!processImageWorker) { - processImageWorker = new Piscina({ + processImageWorker = new Piscina({ filename: new URL(join('workers', 'image-processor.js'), import.meta.url).href, concurrentTasksPerWorker: WORKER_THREADS.PROCESS_IMAGE.CONCURRENCY, maxThreads: WORKER_THREADS.PROCESS_IMAGE.MAX_THREADS, @@ -43,7 +49,9 @@ export function processImageFromWorker (options: Parameters processImageWorker.on('error', err => logger.error('Error in process image worker', { err })) } - return processImageWorker.run(options) + const processedImage = await processImageWorker.run(options) + + return processedImage } // --------------------------------------------------------------------------- diff --git a/server/core/lib/worker/workers/image-downloader.ts b/server/core/lib/worker/workers/image-downloader.ts index acc994ab9af..479a5a68de0 100644 --- a/server/core/lib/worker/workers/image-downloader.ts +++ b/server/core/lib/worker/workers/image-downloader.ts @@ -9,23 +9,26 @@ async function downloadImage (options: { destDir: string destName: string size?: { width: number, height: number } + saveOnDisk?: boolean }) { - const { url, destDir, destName, size } = options + const { url, destDir, destName, size, saveOnDisk = true } = options const tmpPath = join(CONFIG.STORAGE.TMP_DIR, 'pending-' + destName) await doRequestAndSaveToFile(url, tmpPath) - const destPath = join(destDir, destName) + const destination = saveOnDisk ? join(destDir, destName) : null try { - await processImage({ path: tmpPath, destination: destPath, newSize: size }) + return await processImage({ + source: tmpPath, + destination, + newSize: size + }) } catch (err) { await remove(tmpPath) throw err } - - return destPath } export default downloadImage diff --git a/server/core/models/abuse/abuse.ts b/server/core/models/abuse/abuse.ts index 5f57868efb9..f88c38a1aef 100644 --- a/server/core/models/abuse/abuse.ts +++ b/server/core/models/abuse/abuse.ts @@ -153,7 +153,7 @@ export enum ScopeNames { model: VideoAbuseModel.unscoped(), include: [ { - attributes: [ 'id', 'uuid', 'name', 'nsfw' ], + attributes: [ 'id', 'uuid', 'name', 'nsfw', 'remote' ], model: VideoModel.unscoped(), include: [ { diff --git a/server/core/models/video/formatter/video-api-format.ts b/server/core/models/video/formatter/video-api-format.ts index 4557053efc9..c32d2b74722 100644 --- a/server/core/models/video/formatter/video-api-format.ts +++ b/server/core/models/video/formatter/video-api-format.ts @@ -110,8 +110,8 @@ export function videoModelToFormattedJSON (video: MVideoFormattable, options: Vi likes: video.likes, dislikes: video.dislikes, - thumbnailPath: video.getMiniatureStaticPath(), - previewPath: video.getPreviewStaticPath(), + thumbnailUrl: video.getMiniatureStaticPath(), + previewUrl: video.getPreviewStaticPath(), embedPath: video.getEmbedStaticPath(), createdAt: video.createdAt, updatedAt: video.updatedAt, diff --git a/server/core/models/video/sql/video/shared/video-table-attributes.ts b/server/core/models/video/sql/video/shared/video-table-attributes.ts index 61908fd94d2..c209d975e9e 100644 --- a/server/core/models/video/sql/video/shared/video-table-attributes.ts +++ b/server/core/models/video/sql/video/shared/video-table-attributes.ts @@ -53,13 +53,12 @@ export class VideoTableAttributes { } getThumbnailAttributes () { - let attributeKeys = [ 'id', 'type', 'filename' ] + let attributeKeys = [ 'id', 'type', 'filename', 'fileUrl' ] if (this.mode === 'get') { attributeKeys = attributeKeys.concat([ 'height', 'width', - 'fileUrl', 'onDisk', 'automaticallyGenerated', 'videoId', diff --git a/server/core/models/video/thumbnail.ts b/server/core/models/video/thumbnail.ts index 2a1827096ec..1fb7c3ac30d 100644 --- a/server/core/models/video/thumbnail.ts +++ b/server/core/models/video/thumbnail.ts @@ -1,6 +1,6 @@ -import { ActivityIconObject, ThumbnailType, type ThumbnailType_Type } from '@peertube/peertube-models' +import { ActivityIconObject, ThumbnailType, type ThumbnailType_Type, type FileStorageType, FileStorage } from '@peertube/peertube-models' import { afterCommitIfTransaction } from '@server/helpers/database-utils.js' -import { MThumbnail, MThumbnailVideo, MVideo, MVideoPlaylist } from '@server/types/models/index.js' +import { MThumbnail, MThumbnailVideo, MVideo, MVideoOwned, MVideoPlaylist } from '@server/types/models/index.js' import { remove } from 'fs-extra/esm' import { join } from 'path' import { @@ -22,6 +22,7 @@ import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initiali import { VideoPlaylistModel } from './video-playlist.js' import { VideoModel } from './video.js' import { SequelizeModel } from '../shared/sequelize-type.js' +import { getObjectStoragePublicFileUrl } from '@server/lib/object-storage/index.js' @Table({ tableName: 'thumbnail', @@ -75,6 +76,10 @@ export class ThumbnailModel extends SequelizeModel { @Column videoId: number + @AllowNull(false) + @Column + storage: FileStorageType + @BelongsTo(() => VideoModel, { foreignKey: { allowNull: true @@ -188,11 +193,11 @@ export class ThumbnailModel extends SequelizeModel { // --------------------------------------------------------------------------- - getOriginFileUrl (videoOrPlaylist: MVideo | MVideoPlaylist) { + getOriginFileUrl (videoOrPlaylist: MVideoOwned | MVideoPlaylist) { const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename // FIXME: typings - if ((videoOrPlaylist as MVideo).isOwned()) return WEBSERVER.URL + staticPath + if ((videoOrPlaylist as MVideo).isOwned() && this.storage === FileStorage.FILE_SYSTEM) return WEBSERVER.URL + staticPath return this.fileUrl } @@ -224,7 +229,7 @@ export class ThumbnailModel extends SequelizeModel { } isOwned () { - return !this.fileUrl + return getObjectStoragePublicFileUrl(this.fileUrl, CONFIG.OBJECT_STORAGE.THUMBNAILS) !== this.fileUrl } // --------------------------------------------------------------------------- diff --git a/server/core/models/video/video-playlist.ts b/server/core/models/video/video-playlist.ts index ba4f898b4f6..3a6e6b71019 100644 --- a/server/core/models/video/video-playlist.ts +++ b/server/core/models/video/video-playlist.ts @@ -70,6 +70,8 @@ import { import { ThumbnailModel } from './thumbnail.js' import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from './video-channel.js' import { VideoPlaylistElementModel } from './video-playlist-element.js' +import { getObjectStoragePublicFileUrl } from '@server/lib/object-storage/index.js' +import { CONFIG } from '@server/initializers/config.js' enum ScopeNames { AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', @@ -646,7 +648,7 @@ export class VideoPlaylistModel extends SequelizeModel { getThumbnailUrl () { if (!this.hasThumbnail()) return null - return WEBSERVER.URL + LAZY_STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename + return getObjectStoragePublicFileUrl(this.Thumbnail.fileUrl, CONFIG.OBJECT_STORAGE.THUMBNAILS) } getThumbnailStaticPath () { @@ -725,7 +727,7 @@ export class VideoPlaylistModel extends SequelizeModel { label: VideoPlaylistModel.getPrivacyLabel(this.privacy) }, - thumbnailPath: this.getThumbnailStaticPath(), + thumbnailUrl: this.getThumbnailUrl(), embedPath: this.getEmbedStaticPath(), type: { diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts index 377e5d40546..bbe244388c5 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -25,6 +25,7 @@ import { getPrivaciesForFederation } from '@server/helpers/video.js' import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js' import { LiveManager } from '@server/lib/live/live-manager.js' import { + getObjectStoragePublicFileUrl, removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeOriginalFileObjectStorage, @@ -205,7 +206,7 @@ export type ForAPIOptions = { required: true }, { - attributes: [ 'type', 'filename' ], + attributes: [ 'type', 'filename', 'fileUrl' ], model: ThumbnailModel, required: false } @@ -1899,18 +1900,26 @@ export class VideoModel extends SequelizeModel { return buildVideoEmbedPath({ shortUUID: uuidToShort(this.uuid) }) } - getMiniatureStaticPath (this: Pick) { + getMiniatureStaticPath (this: Pick) { const thumbnail = this.getMiniature() if (!thumbnail) return null - return thumbnail.getLocalStaticPath() + if (thumbnail.storage === FileStorage.FILE_SYSTEM) { + return thumbnail.getLocalStaticPath() + } + + return thumbnail.getOriginFileUrl(this) } getPreviewStaticPath (this: Pick) { const preview = this.getPreview() if (!preview) return null - return preview.getLocalStaticPath() + if (preview.storage === FileStorage.FILE_SYSTEM) { + return preview.getLocalStaticPath() + } + + return getObjectStoragePublicFileUrl(preview.fileUrl, CONFIG.OBJECT_STORAGE.THUMBNAILS) } toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video { diff --git a/server/core/types/models/abuse/abuse.ts b/server/core/types/models/abuse/abuse.ts index 5d0ab8a7025..81a35ae1dd9 100644 --- a/server/core/types/models/abuse/abuse.ts +++ b/server/core/types/models/abuse/abuse.ts @@ -43,7 +43,17 @@ export type MVideoAbuseFormattable = 'Video', Pick< MVideoAccountLightBlacklistAllFiles, - 'id' | 'uuid' | 'name' | 'nsfw' | 'getMiniature' | 'getMiniatureStaticPath' | 'isBlacklisted' | 'VideoChannel' | 'Thumbnails' + 'id' | + 'uuid' | + 'name' | + 'nsfw' | + 'getMiniature' | + 'getMiniatureStaticPath' | + 'isBlacklisted' | + 'VideoChannel' | + 'Thumbnails' | + 'remote' | + 'isOwned' > > diff --git a/server/scripts/migrations/peertube-4.2.ts b/server/scripts/migrations/peertube-4.2.ts index 9fddaa0f4df..e36f11298ea 100644 --- a/server/scripts/migrations/peertube-4.2.ts +++ b/server/scripts/migrations/peertube-4.2.ts @@ -108,7 +108,12 @@ async function generateSmallerAvatar (actor: MActorDefault) { const source = join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, sourceFilename) const destination = join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, newImageName) - await processImage({ path: source, destination, newSize: imageSize, keepOriginal: true }) + await processImage({ + source, + destination, + newSize: imageSize, + keepOriginal: true + }) const actorImageInfo = { name: newImageName, diff --git a/server/scripts/regenerate-thumbnails.ts b/server/scripts/regenerate-thumbnails.ts index d4346ce400d..2813193fce5 100644 --- a/server/scripts/regenerate-thumbnails.ts +++ b/server/scripts/regenerate-thumbnails.ts @@ -5,6 +5,7 @@ import { generateImageFilename, processImage } from '@server/helpers/image-utils import { THUMBNAILS_SIZE } from '@server/initializers/constants.js' import { initDatabaseModels } from '@server/initializers/database.js' import { VideoModel } from '@server/models/video/video.js' +import { storeThumbnailFile } from '@server/lib/object-storage/thumbnail.js' program .description('Regenerate local thumbnails using preview files') @@ -35,6 +36,9 @@ async function processVideo (id: number) { const previewPath = preview.getPath() + /** + * TODO: Handle object storage + */ if (!await pathExists(previewPath)) { throw new Error(`Preview ${previewPath} does not exist on disk`) } @@ -51,8 +55,16 @@ async function processVideo (id: number) { thumbnail.width = size.width thumbnail.height = size.height - const thumbnailPath = thumbnail.getPath() - await processImage({ path: previewPath, destination: thumbnailPath, newSize: size, keepOriginal: true }) + const destination = await processImage({ + source: previewPath, + destination: thumbnail.getPath(), + newSize: size, + keepOriginal: true + }) + + if (Buffer.isBuffer(destination)) { + await storeThumbnailFile(destination, thumbnail) + } // Save new attributes await thumbnail.save() diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 3c148eaa1c9..c9faca4b57e 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -8381,12 +8381,12 @@ components: example: What is PeerTube? minLength: 3 maxLength: 120 - thumbnailPath: + thumbnailUrl: type: string - example: /lazy-static/thumbnails/a65bc12f-9383-462e-81ae-8207e8b434ee.jpg - previewPath: + example: https://peertube.cpy.re/lazy-static/thumbnails/a65bc12f-9383-462e-81ae-8207e8b434ee.jpg + previewUrl: type: string - example: /lazy-static/previews/a65bc12f-9383-462e-81ae-8207e8b434ee.jpg + example: https://peertube.cpy.re/lazy-static/previews/a65bc12f-9383-462e-81ae-8207e8b434ee.jpg embedPath: type: string example: /videos/embed/a65bc12f-9383-462e-81ae-8207e8b434ee @@ -8763,7 +8763,7 @@ components: videoLength: type: integer minimum: 0 - thumbnailPath: + thumbnailUrl: type: string privacy: $ref: '#/components/schemas/VideoPlaylistPrivacyConstant' diff --git a/yarn.lock b/yarn.lock index f59a270dca0..3d00ff2a88b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -742,6 +742,13 @@ ky-universal "^0.11.0" undici "^5.21.2" +"@emnapi/runtime@^1.2.0": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.3.1.tgz#0fcaa575afc31f455fd33534c19381cfce6c6f60" + integrity sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw== + dependencies: + tslib "^2.4.0" + "@esbuild/aix-ppc64@0.23.1": version "0.23.1" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz#51299374de171dbd80bb7d838e1cfce9af36f353" @@ -1055,279 +1062,135 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== -"@ioredis/commands@^1.1.1": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" - integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== - -"@isaacs/cliui@^8.0.2": - version "8.0.2" - resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" - integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== - dependencies: - string-width "^5.1.2" - string-width-cjs "npm:string-width@^4.2.0" - strip-ansi "^7.0.1" - strip-ansi-cjs "npm:strip-ansi@^6.0.1" - wrap-ansi "^8.1.0" - wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" - -"@jimp/bmp@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.22.12.tgz#0316044dc7b1a90274aef266d50349347fb864d4" - integrity sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g== - dependencies: - "@jimp/utils" "^0.22.12" - bmp-js "^0.1.0" - -"@jimp/core@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/core/-/core-0.22.12.tgz#70785ea7d10b138fb65bcfe9f712826f00a10e1d" - integrity sha512-l0RR0dOPyzMKfjUW1uebzueFEDtCOj9fN6pyTYWWOM/VS4BciXQ1VVrJs8pO3kycGYZxncRKhCoygbNr8eEZQA== - dependencies: - "@jimp/utils" "^0.22.12" - any-base "^1.1.0" - buffer "^5.2.0" - exif-parser "^0.1.12" - file-type "^16.5.4" - isomorphic-fetch "^3.0.0" - pixelmatch "^4.0.2" - tinycolor2 "^1.6.0" +"@img/sharp-darwin-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08" + integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ== + optionalDependencies: + "@img/sharp-libvips-darwin-arm64" "1.0.4" -"@jimp/custom@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/custom/-/custom-0.22.12.tgz#236f2a3f016b533c50869ff22ad1ac00dd0c36be" - integrity sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q== - dependencies: - "@jimp/core" "^0.22.12" +"@img/sharp-darwin-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz#e03d3451cd9e664faa72948cc70a403ea4063d61" + integrity sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q== + optionalDependencies: + "@img/sharp-libvips-darwin-x64" "1.0.4" -"@jimp/gif@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/gif/-/gif-0.22.12.tgz#6caccb45df497fb971b7a88690345596e22163c0" - integrity sha512-y6BFTJgch9mbor2H234VSjd9iwAhaNf/t3US5qpYIs0TSbAvM02Fbc28IaDETj9+4YB4676sz4RcN/zwhfu1pg== - dependencies: - "@jimp/utils" "^0.22.12" - gifwrap "^0.10.1" - omggif "^1.0.9" +"@img/sharp-libvips-darwin-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz#447c5026700c01a993c7804eb8af5f6e9868c07f" + integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg== -"@jimp/jpeg@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/jpeg/-/jpeg-0.22.12.tgz#b5c74a5aac9826245311370dda8c71a1fcca05ed" - integrity sha512-Rq26XC/uQWaQKyb/5lksCTCxXhtY01NJeBN+dQv5yNYedN0i7iYu+fXEoRsfaJ8xZzjoANH8sns7rVP4GE7d/Q== - dependencies: - "@jimp/utils" "^0.22.12" - jpeg-js "^0.4.4" +"@img/sharp-libvips-darwin-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz#e0456f8f7c623f9dbfbdc77383caa72281d86062" + integrity sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ== -"@jimp/plugin-blit@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz#0fa8320767fda77434b4408798655ff7c7e415d4" - integrity sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ== - dependencies: - "@jimp/utils" "^0.22.12" +"@img/sharp-libvips-linux-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz#979b1c66c9a91f7ff2893556ef267f90ebe51704" + integrity sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA== -"@jimp/plugin-blur@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz#0c37b2ff4e588b45f4307b4f13d3d0eef813920d" - integrity sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw== - dependencies: - "@jimp/utils" "^0.22.12" +"@img/sharp-libvips-linux-arm@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz#99f922d4e15216ec205dcb6891b721bfd2884197" + integrity sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g== -"@jimp/plugin-circle@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-circle/-/plugin-circle-0.22.12.tgz#9fffda83d3fc5bad8c1e1492b15b1433cb42e16e" - integrity sha512-SWVXx1yiuj5jZtMijqUfvVOJBwOifFn0918ou4ftoHgegc5aHWW5dZbYPjvC9fLpvz7oSlptNl2Sxr1zwofjTg== - dependencies: - "@jimp/utils" "^0.22.12" +"@img/sharp-libvips-linux-s390x@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz#f8a5eb1f374a082f72b3f45e2fb25b8118a8a5ce" + integrity sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA== -"@jimp/plugin-color@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-color/-/plugin-color-0.22.12.tgz#1e49f2e7387186507e917b0686599767c15be336" - integrity sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA== - dependencies: - "@jimp/utils" "^0.22.12" - tinycolor2 "^1.6.0" +"@img/sharp-libvips-linux-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz#d4c4619cdd157774906e15770ee119931c7ef5e0" + integrity sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw== -"@jimp/plugin-contain@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-contain/-/plugin-contain-0.22.12.tgz#ed5ed9af3d4afd02a7568ff8d60603cff340e3f3" - integrity sha512-Eo3DmfixJw3N79lWk8q/0SDYbqmKt1xSTJ69yy8XLYQj9svoBbyRpSnHR+n9hOw5pKXytHwUW6nU4u1wegHNoQ== - dependencies: - "@jimp/utils" "^0.22.12" +"@img/sharp-libvips-linuxmusl-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz#166778da0f48dd2bded1fa3033cee6b588f0d5d5" + integrity sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA== -"@jimp/plugin-cover@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-cover/-/plugin-cover-0.22.12.tgz#4abbfabe4c78c71d8d46e707c35a65dc55f08afd" - integrity sha512-z0w/1xH/v/knZkpTNx+E8a7fnasQ2wHG5ze6y5oL2dhH1UufNua8gLQXlv8/W56+4nJ1brhSd233HBJCo01BXA== - dependencies: - "@jimp/utils" "^0.22.12" +"@img/sharp-libvips-linuxmusl-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz#93794e4d7720b077fcad3e02982f2f1c246751ff" + integrity sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw== -"@jimp/plugin-crop@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz#e28329a9f285071442998560b040048d2ef5c32e" - integrity sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw== - dependencies: - "@jimp/utils" "^0.22.12" +"@img/sharp-linux-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz#edb0697e7a8279c9fc829a60fc35644c4839bb22" + integrity sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA== + optionalDependencies: + "@img/sharp-libvips-linux-arm64" "1.0.4" -"@jimp/plugin-displace@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-displace/-/plugin-displace-0.22.12.tgz#2e4b2b989a23da6687c49f2f628e1e6d686ec9b6" - integrity sha512-qpRM8JRicxfK6aPPqKZA6+GzBwUIitiHaZw0QrJ64Ygd3+AsTc7BXr+37k2x7QcyCvmKXY4haUrSIsBug4S3CA== - dependencies: - "@jimp/utils" "^0.22.12" +"@img/sharp-linux-arm@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz#422c1a352e7b5832842577dc51602bcd5b6f5eff" + integrity sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ== + optionalDependencies: + "@img/sharp-libvips-linux-arm" "1.0.5" -"@jimp/plugin-dither@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-dither/-/plugin-dither-0.22.12.tgz#3cc5f3a58dbf85653c4e532d31a756a4fc8cabf7" - integrity sha512-jYgGdSdSKl1UUEanX8A85v4+QUm+PE8vHFwlamaKk89s+PXQe7eVE3eNeSZX4inCq63EHL7cX580dMqkoC3ZLw== - dependencies: - "@jimp/utils" "^0.22.12" +"@img/sharp-linux-s390x@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz#f5c077926b48e97e4a04d004dfaf175972059667" + integrity sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q== + optionalDependencies: + "@img/sharp-libvips-linux-s390x" "1.0.4" -"@jimp/plugin-fisheye@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-fisheye/-/plugin-fisheye-0.22.12.tgz#77aef2f3ec59c0bafbd2dbc94b89eab60ce05a3e" - integrity sha512-LGuUTsFg+fOp6KBKrmLkX4LfyCy8IIsROwoUvsUPKzutSqMJnsm3JGDW2eOmWIS/jJpPaeaishjlxvczjgII+Q== - dependencies: - "@jimp/utils" "^0.22.12" +"@img/sharp-linux-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz#d806e0afd71ae6775cc87f0da8f2d03a7c2209cb" + integrity sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA== + optionalDependencies: + "@img/sharp-libvips-linux-x64" "1.0.4" -"@jimp/plugin-flip@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-flip/-/plugin-flip-0.22.12.tgz#7e2154592da01afcf165a3f9d1d25032aa8d8c57" - integrity sha512-m251Rop7GN8W0Yo/rF9LWk6kNclngyjIJs/VXHToGQ6EGveOSTSQaX2Isi9f9lCDLxt+inBIb7nlaLLxnvHX8Q== - dependencies: - "@jimp/utils" "^0.22.12" +"@img/sharp-linuxmusl-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz#252975b915894fb315af5deea174651e208d3d6b" + integrity sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" -"@jimp/plugin-gaussian@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-gaussian/-/plugin-gaussian-0.22.12.tgz#49a40950cedbbea6c84b3a6bccc45365fe78d6b7" - integrity sha512-sBfbzoOmJ6FczfG2PquiK84NtVGeScw97JsCC3rpQv1PHVWyW+uqWFF53+n3c8Y0P2HWlUjflEla2h/vWShvhg== - dependencies: - "@jimp/utils" "^0.22.12" +"@img/sharp-linuxmusl-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz#3f4609ac5d8ef8ec7dadee80b560961a60fd4f48" + integrity sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64" "1.0.4" -"@jimp/plugin-invert@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-invert/-/plugin-invert-0.22.12.tgz#c569e85c1f59911a9a33ef36a51c9cf26065078e" - integrity sha512-N+6rwxdB+7OCR6PYijaA/iizXXodpxOGvT/smd/lxeXsZ/empHmFFFJ/FaXcYh19Tm04dGDaXcNF/dN5nm6+xQ== +"@img/sharp-wasm32@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz#6f44f3283069d935bb5ca5813153572f3e6f61a1" + integrity sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg== dependencies: - "@jimp/utils" "^0.22.12" + "@emnapi/runtime" "^1.2.0" -"@jimp/plugin-mask@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-mask/-/plugin-mask-0.22.12.tgz#0ac0d9c282f403255b126556521f90fb8e2997f0" - integrity sha512-4AWZg+DomtpUA099jRV8IEZUfn1wLv6+nem4NRJC7L/82vxzLCgXKTxvNvBcNmJjT9yS1LAAmiJGdWKXG63/NA== - dependencies: - "@jimp/utils" "^0.22.12" +"@img/sharp-win32-ia32@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz#1a0c839a40c5351e9885628c85f2e5dfd02b52a9" + integrity sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ== -"@jimp/plugin-normalize@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-normalize/-/plugin-normalize-0.22.12.tgz#6c44d216f2489cf9b0e0f1e03aa5dfb97f198c53" - integrity sha512-0So0rexQivnWgnhacX4cfkM2223YdExnJTTy6d06WbkfZk5alHUx8MM3yEzwoCN0ErO7oyqEWRnEkGC+As1FtA== - dependencies: - "@jimp/utils" "^0.22.12" - -"@jimp/plugin-print@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-print/-/plugin-print-0.22.12.tgz#6a49020947a9bf21a5a28324425670a25587ca65" - integrity sha512-c7TnhHlxm87DJeSnwr/XOLjJU/whoiKYY7r21SbuJ5nuH+7a78EW1teOaj5gEr2wYEd7QtkFqGlmyGXY/YclyQ== - dependencies: - "@jimp/utils" "^0.22.12" - load-bmfont "^1.4.1" +"@img/sharp-win32-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz#56f00962ff0c4e0eb93d34a047d29fa995e3e342" + integrity sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg== -"@jimp/plugin-resize@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz#f92acbf73beb97dd1fe93b166ef367a323b81e81" - integrity sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg== - dependencies: - "@jimp/utils" "^0.22.12" +"@ioredis/commands@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" + integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== -"@jimp/plugin-rotate@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz#2235d45aeb4914ff70d99e95750a6d9de45a0d9f" - integrity sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA== - dependencies: - "@jimp/utils" "^0.22.12" - -"@jimp/plugin-scale@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz#91f1ec3d114ff44092b946a16e66b14d918e32ed" - integrity sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw== +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== dependencies: - "@jimp/utils" "^0.22.12" - -"@jimp/plugin-shadow@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-shadow/-/plugin-shadow-0.22.12.tgz#52e3a1d55f61ddfcfb3265544f8d23b887a667b8" - integrity sha512-FX8mTJuCt7/3zXVoeD/qHlm4YH2bVqBuWQHXSuBK054e7wFRnRnbSLPUqAwSeYP3lWqpuQzJtgiiBxV3+WWwTg== - dependencies: - "@jimp/utils" "^0.22.12" - -"@jimp/plugin-threshold@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugin-threshold/-/plugin-threshold-0.22.12.tgz#1efe20e154bf3a1fc4a5cc016092dbacaa60c958" - integrity sha512-4x5GrQr1a/9L0paBC/MZZJjjgjxLYrqSmWd+e+QfAEPvmRxdRoQ5uKEuNgXnm9/weHQBTnQBQsOY2iFja+XGAw== - dependencies: - "@jimp/utils" "^0.22.12" - -"@jimp/plugins@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/plugins/-/plugins-0.22.12.tgz#45a3b96d2d24cec21d4f8b79d1cfcec6fcb2f1d4" - integrity sha512-yBJ8vQrDkBbTgQZLty9k4+KtUQdRjsIDJSPjuI21YdVeqZxYywifHl4/XWILoTZsjTUASQcGoH0TuC0N7xm3ww== - dependencies: - "@jimp/plugin-blit" "^0.22.12" - "@jimp/plugin-blur" "^0.22.12" - "@jimp/plugin-circle" "^0.22.12" - "@jimp/plugin-color" "^0.22.12" - "@jimp/plugin-contain" "^0.22.12" - "@jimp/plugin-cover" "^0.22.12" - "@jimp/plugin-crop" "^0.22.12" - "@jimp/plugin-displace" "^0.22.12" - "@jimp/plugin-dither" "^0.22.12" - "@jimp/plugin-fisheye" "^0.22.12" - "@jimp/plugin-flip" "^0.22.12" - "@jimp/plugin-gaussian" "^0.22.12" - "@jimp/plugin-invert" "^0.22.12" - "@jimp/plugin-mask" "^0.22.12" - "@jimp/plugin-normalize" "^0.22.12" - "@jimp/plugin-print" "^0.22.12" - "@jimp/plugin-resize" "^0.22.12" - "@jimp/plugin-rotate" "^0.22.12" - "@jimp/plugin-scale" "^0.22.12" - "@jimp/plugin-shadow" "^0.22.12" - "@jimp/plugin-threshold" "^0.22.12" - timm "^1.6.1" - -"@jimp/png@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/png/-/png-0.22.12.tgz#e033586caf38d9c9d33808e92eb87c4d7f0aa1eb" - integrity sha512-Mrp6dr3UTn+aLK8ty/dSKELz+Otdz1v4aAXzV5q53UDD2rbB5joKVJ/ChY310B+eRzNxIovbUF1KVrUsYdE8Hg== - dependencies: - "@jimp/utils" "^0.22.12" - pngjs "^6.0.0" - -"@jimp/tiff@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/tiff/-/tiff-0.22.12.tgz#67cac3f2ded6fde3ef631fbf74bea0fa53800123" - integrity sha512-E1LtMh4RyJsoCAfAkBRVSYyZDTtLq9p9LUiiYP0vPtXyxX4BiYBUYihTLSBlCQg5nF2e4OpQg7SPrLdJ66u7jg== - dependencies: - utif2 "^4.0.1" - -"@jimp/types@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/types/-/types-0.22.12.tgz#6f83761ba171cb8cd5998fa66a5cbfb0b22d3d8c" - integrity sha512-wwKYzRdElE1MBXFREvCto5s699izFHNVvALUv79GXNbsOVqlwlOxlWJ8DuyOGIXoLP4JW/m30YyuTtfUJgMRMA== - dependencies: - "@jimp/bmp" "^0.22.12" - "@jimp/gif" "^0.22.12" - "@jimp/jpeg" "^0.22.12" - "@jimp/png" "^0.22.12" - "@jimp/tiff" "^0.22.12" - timm "^1.6.1" - -"@jimp/utils@^0.22.12": - version "0.22.12" - resolved "https://registry.yarnpkg.com/@jimp/utils/-/utils-0.22.12.tgz#8ffaed8f2dc2962539ccaf14727ac60793c7a537" - integrity sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q== - dependencies: - regenerator-runtime "^0.13.3" + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" "@jridgewell/gen-mapping@^0.3.5": version "0.3.8" @@ -2562,11 +2425,6 @@ dependencies: uint8-util "^2.2.5" -"@tokenizer/token@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" - integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A== - "@types/archiver@^6.0.2": version "6.0.3" resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-6.0.3.tgz#074eb6f4febc0128c25a205a8263da3d4688df53" @@ -2845,11 +2703,6 @@ dependencies: undici-types "~6.20.0" -"@types/node@16.9.1": - version "16.9.1" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708" - integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g== - "@types/node@^17.0.5": version "17.0.45" resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190" @@ -3831,11 +3684,6 @@ bluebird@^3.5.0: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== -bmp-js@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.1.0.tgz#e05a63f796a6c1ff25f4771ec7adadc148c07233" - integrity sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw== - body-parser@1.20.3: version "1.20.3" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" @@ -3911,11 +3759,6 @@ buffer-equal-constant-time@1.0.1: resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== -buffer-equal@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-0.0.1.tgz#91bc74b11ea405bc916bc6aa908faafa5b4aac4b" - integrity sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA== - buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -3929,7 +3772,7 @@ buffer@5.6.0: base64-js "^1.0.2" ieee754 "^1.1.4" -buffer@^5.2.0, buffer@^5.2.1, buffer@^5.5.0: +buffer@^5.2.1, buffer@^5.5.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -4086,13 +3929,6 @@ canonicalize@^1.0.1: resolved "https://registry.yarnpkg.com/canonicalize/-/canonicalize-1.0.8.tgz#24d1f1a00ed202faafd9bf8e63352cd4450c6df1" integrity sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A== -centra@^2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/centra/-/centra-2.7.0.tgz#4c8312a58436e8a718302011561db7e6a2b0ec18" - integrity sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg== - dependencies: - follow-redirects "^1.15.6" - chai-json-schema@^1.5.0: version "1.5.1" resolved "https://registry.yarnpkg.com/chai-json-schema/-/chai-json-schema-1.5.1.tgz#d9ae4c8f8c6e24ff4d402ceddfaa865d1ca107f4" @@ -4327,7 +4163,7 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.6.0: +color-string@^1.6.0, color-string@^1.9.0: version "1.9.1" resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== @@ -4348,6 +4184,14 @@ color@^3.1.3: color-convert "^1.9.3" color-string "^1.6.0" +color@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" + integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== + dependencies: + color-convert "^2.0.1" + color-string "^1.9.0" + colors@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" @@ -4856,7 +4700,7 @@ detect-indent@^6.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6" integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA== -detect-libc@^2.0.0, detect-libc@^2.0.1: +detect-libc@^2.0.0, detect-libc@^2.0.1, detect-libc@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== @@ -4936,11 +4780,6 @@ dom-serializer@^2.0.0: domhandler "^5.0.2" entities "^4.2.0" -dom-walk@^0.1.0: - version "0.1.2" - resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84" - integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w== - domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" @@ -5686,11 +5525,6 @@ execa@^9.3.0: strip-final-newline "^4.0.0" yoctocolors "^2.0.0" -exif-parser@^0.1.12: - version "0.1.12" - resolved "https://registry.yarnpkg.com/exif-parser/-/exif-parser-0.1.12.tgz#58a9d2d72c02c1f6f02a0ef4a9166272b7760922" - integrity sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw== - expand-template@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" @@ -5895,15 +5729,6 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-type@^16.5.4: - version "16.5.4" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-16.5.4.tgz#474fb4f704bee427681f98dd390058a172a6c2fd" - integrity sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw== - dependencies: - readable-web-to-node-stream "^3.0.0" - strtok3 "^6.2.4" - token-types "^4.1.1" - filename-reserved-regex@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz#3d5dd6d4e2d73a3fed2ebc4cd0b3448869a081f7" @@ -6004,11 +5829,6 @@ fn.name@1.x.x: resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== -follow-redirects@^1.15.6: - version "1.15.9" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" - integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== - for-each@^0.3.3: version "0.3.5" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" @@ -6267,14 +6087,6 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" -gifwrap@^0.10.1: - version "0.10.1" - resolved "https://registry.yarnpkg.com/gifwrap/-/gifwrap-0.10.1.tgz#9ed46a5d51913b482d4221ce9c727080260b681e" - integrity sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw== - dependencies: - image-q "^4.0.0" - omggif "^1.0.10" - github-from-package@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" @@ -6350,14 +6162,6 @@ global-prefix@^1.0.1: is-windows "^1.0.1" which "^1.2.14" -global@~4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406" - integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w== - dependencies: - min-document "^2.19.0" - process "^0.11.10" - globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -6690,13 +6494,6 @@ ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.1: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== -image-q@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/image-q/-/image-q-4.0.0.tgz#31e075be7bae3c1f42a85c469b4732c358981776" - integrity sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw== - dependencies: - "@types/node" "16.9.1" - immediate-chunk-store@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/immediate-chunk-store/-/immediate-chunk-store-2.2.0.tgz#f56d30ecc7171f6cfcf632b0eb8395a89f92c03c" @@ -6957,11 +6754,6 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -is-function@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.2.tgz#4f097f30abf6efadac9833b17ca5dc03f8144e08" - integrity sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ== - is-generator-function@^1.0.10: version "1.1.0" resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.0.tgz#bf3eeda931201394f57b5dba2800f91a238309ca" @@ -7150,14 +6942,6 @@ iso-639-3@3.0.1: resolved "https://registry.yarnpkg.com/iso-639-3/-/iso-639-3-3.0.1.tgz#4be56987c46fbda79da63a3d90d6552d7429dcea" integrity sha512-SdljCYXOexv/JmbQ0tvigHN43yECoscVpe2y2hlEqy/CStXQlroPhZLj7zKLRiGqLJfw8k7B973UAMDoQczVgQ== -isomorphic-fetch@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz#0267b005049046d2421207215d45d6a262b8b8b4" - integrity sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA== - dependencies: - node-fetch "^2.6.1" - whatwg-fetch "^3.4.1" - isstream@0.1.x: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -7183,16 +6967,6 @@ jaeger-client@^3.15.0: uuid "^8.3.2" xorshift "^1.1.1" -jimp@^0.22.4: - version "0.22.12" - resolved "https://registry.yarnpkg.com/jimp/-/jimp-0.22.12.tgz#f99d1f3ec0d9d930cb7bd8f5b479859ee3a15694" - integrity sha512-R5jZaYDnfkxKJy1dwLpj/7cvyjxiclxU3F4TrI/J4j2rS0niq6YDUMoPn5hs8GDpO+OZGo7Ky057CRtWesyhfg== - dependencies: - "@jimp/custom" "^0.22.12" - "@jimp/plugins" "^0.22.12" - "@jimp/types" "^0.22.12" - regenerator-runtime "^0.13.3" - join-async-iterator@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/join-async-iterator/-/join-async-iterator-1.1.1.tgz#7d2857d7f4066267861888d264769e842110d07e" @@ -7511,20 +7285,6 @@ linkify-it@5.0.0, linkify-it@^5.0.0: dependencies: uc.micro "^2.0.0" -load-bmfont@^1.4.1: - version "1.4.2" - resolved "https://registry.yarnpkg.com/load-bmfont/-/load-bmfont-1.4.2.tgz#e0f4516064fa5be8439f9c3696c01423a64e8717" - integrity sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog== - dependencies: - buffer-equal "0.0.1" - mime "^1.3.4" - parse-bmfont-ascii "^1.0.3" - parse-bmfont-binary "^1.0.5" - parse-bmfont-xml "^1.1.4" - phin "^3.7.1" - xhr "^2.0.1" - xtend "^4.0.0" - load-ip-set@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/load-ip-set/-/load-ip-set-3.0.1.tgz#a2cecf523eb7e2396d3d43e80d87a95f2031696f" @@ -7897,7 +7657,7 @@ mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: dependencies: mime-db "1.52.0" -mime@1.6.0, mime@^1.3.4, mime@^1.6.0: +mime@1.6.0, mime@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== @@ -7927,13 +7687,6 @@ mimic-response@^4.0.0: resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-4.0.0.tgz#35468b19e7c75d10f5165ea25e75a5ceea7cf70f" integrity sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg== -min-document@^2.19.0: - version "2.19.0" - resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" - integrity sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ== - dependencies: - dom-walk "^0.1.0" - minimatch@9.0.3: version "9.0.3" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" @@ -8254,7 +8007,7 @@ node-domexception@^2.0.1: resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-2.0.1.tgz#83b0d101123b5bbf91018fd569a58b88ae985e5b" integrity sha512-M85rnSC7WQ7wnfQTARPT4LrK7nwCHLdDFOCcItZMhTQjyCebJH8GciKqYJNgaOFZs9nFmTmd/VMyi3OW5jA47w== -node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.7: +node-fetch@^2.6.0, node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -8442,11 +8195,6 @@ obuf@~1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== -omggif@^1.0.10, omggif@^1.0.9: - version "1.0.10" - resolved "https://registry.yarnpkg.com/omggif/-/omggif-1.0.10.tgz#ddaaf90d4a42f532e9e7cb3a95ecdd47f17c7b19" - integrity sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw== - on-finished@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" @@ -8629,7 +8377,7 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== -pako@^1.0.11, pako@^1.0.3, pako@~1.0.2: +pako@^1.0.3, pako@~1.0.2: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== @@ -8641,34 +8389,11 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-bmfont-ascii@^1.0.3: - version "1.0.6" - resolved "https://registry.yarnpkg.com/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz#11ac3c3ff58f7c2020ab22769079108d4dfa0285" - integrity sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA== - -parse-bmfont-binary@^1.0.5: - version "1.0.6" - resolved "https://registry.yarnpkg.com/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz#d038b476d3e9dd9db1e11a0b0e53a22792b69006" - integrity sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA== - -parse-bmfont-xml@^1.1.4: - version "1.1.6" - resolved "https://registry.yarnpkg.com/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz#016b655da7aebe6da38c906aca16bf0415773767" - integrity sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA== - dependencies: - xml-parse-from-string "^1.0.0" - xml2js "^0.5.0" - parse-duration@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/parse-duration/-/parse-duration-1.1.2.tgz#20008e6c507814761864669bb936e3f4a9a80758" integrity sha512-p8EIONG8L0u7f8GFgfVlL4n8rnChTt8O5FSxgxMz2tjc9FMP199wxVKVB6IbKx11uTbKHACSvaLVIKNnoeNR/A== -parse-headers@^2.0.0: - version "2.0.5" - resolved "https://registry.yarnpkg.com/parse-headers/-/parse-headers-2.0.5.tgz#069793f9356a54008571eb7f9761153e6c770da9" - integrity sha512-ft3iAoLOB/MlwbNXgzy43SWGP6sQki2jQvAyBg/zDFAgr9bfNWZIUj42Kw2eJIl8kEi4PbgE6U1Zau/HwI75HA== - parse-json@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" @@ -8804,11 +8529,6 @@ peberminta@^0.9.0: resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.9.0.tgz#8ec9bc0eb84b7d368126e71ce9033501dca2a352" integrity sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ== -peek-readable@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-4.1.0.tgz#4ece1111bf5c2ad8867c314c81356847e8a62e72" - integrity sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg== - peek-stream@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/peek-stream/-/peek-stream-1.1.3.tgz#3b35d84b7ccbbd262fff31dc10da56856ead6d67" @@ -8897,13 +8617,6 @@ pgpass@1.x: dependencies: split2 "^4.1.0" -phin@^3.7.1: - version "3.7.1" - resolved "https://registry.yarnpkg.com/phin/-/phin-3.7.1.tgz#bf841da75ee91286691b10e41522a662aa628fd6" - integrity sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ== - dependencies: - centra "^2.7.0" - picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" @@ -8931,13 +8644,6 @@ piscina@^4.1.0: optionalDependencies: "@napi-rs/nice" "^1.0.1" -pixelmatch@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-4.0.2.tgz#8f47dcec5011b477b67db03c243bc1f3085e8854" - integrity sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA== - dependencies: - pngjs "^3.0.0" - pixelmatch@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-6.0.0.tgz#82bfad31becb8973746e8f7b0d88160cd10ade6d" @@ -8952,16 +8658,6 @@ please-upgrade-node@^3.2.0: dependencies: semver-compare "^1.0.0" -pngjs@^3.0.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f" - integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w== - -pngjs@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821" - integrity sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg== - pngjs@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-7.0.0.tgz#a8b7446020ebbc6ac739db6c5415a65d17090e26" @@ -9451,7 +9147,7 @@ readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable string_decoder "^1.1.1" util-deprecate "^1.0.1" -readable-stream@^4.0.0, readable-stream@^4.7.0: +readable-stream@^4.0.0: version "4.7.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.7.0.tgz#cedbd8a1146c13dfff8dab14068028d58c15ac91" integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg== @@ -9462,13 +9158,6 @@ readable-stream@^4.0.0, readable-stream@^4.7.0: process "^0.11.10" string_decoder "^1.3.0" -readable-web-to-node-stream@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz#392ba37707af5bf62d725c36c1b5d6ef4119eefc" - integrity sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw== - dependencies: - readable-stream "^4.7.0" - readable-wrap@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/readable-wrap/-/readable-wrap-1.0.0.tgz#3b5a211c631e12303a54991c806c17e7ae206bff" @@ -9533,11 +9222,6 @@ reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: get-proto "^1.0.1" which-builtin-type "^1.2.1" -regenerator-runtime@^0.13.3: - version "0.13.11" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" - integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== - regexp.prototype.flags@^1.5.3: version "1.5.4" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" @@ -9822,7 +9506,7 @@ semver@^6.0.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.0.0, semver@^7.3.2, semver@^7.3.5, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0: +semver@^7.0.0, semver@^7.3.2, semver@^7.3.5, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3: version "7.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.1.tgz#abd5098d82b18c6c81f6074ff2647fd3e7220c9f" integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== @@ -9943,6 +9627,35 @@ setprototypeof@1.2.0: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== +sharp@^0.33.5: + version "0.33.5" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.33.5.tgz#13e0e4130cc309d6a9497596715240b2ec0c594e" + integrity sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw== + dependencies: + color "^4.2.3" + detect-libc "^2.0.3" + semver "^7.6.3" + optionalDependencies: + "@img/sharp-darwin-arm64" "0.33.5" + "@img/sharp-darwin-x64" "0.33.5" + "@img/sharp-libvips-darwin-arm64" "1.0.4" + "@img/sharp-libvips-darwin-x64" "1.0.4" + "@img/sharp-libvips-linux-arm" "1.0.5" + "@img/sharp-libvips-linux-arm64" "1.0.4" + "@img/sharp-libvips-linux-s390x" "1.0.4" + "@img/sharp-libvips-linux-x64" "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" + "@img/sharp-libvips-linuxmusl-x64" "1.0.4" + "@img/sharp-linux-arm" "0.33.5" + "@img/sharp-linux-arm64" "0.33.5" + "@img/sharp-linux-s390x" "0.33.5" + "@img/sharp-linux-x64" "0.33.5" + "@img/sharp-linuxmusl-arm64" "0.33.5" + "@img/sharp-linuxmusl-x64" "0.33.5" + "@img/sharp-wasm32" "0.33.5" + "@img/sharp-win32-ia32" "0.33.5" + "@img/sharp-win32-x64" "0.33.5" + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -10442,14 +10155,6 @@ strnum@^1.0.5: resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" integrity sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA== -strtok3@^6.2.4: - version "6.3.0" - resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-6.3.0.tgz#358b80ffe6d5d5620e19a073aa78ce947a90f9a0" - integrity sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw== - dependencies: - "@tokenizer/token" "^0.3.0" - peek-readable "^4.1.0" - superagent@^9.0.1: version "9.0.2" resolved "https://registry.yarnpkg.com/superagent/-/superagent-9.0.2.tgz#a18799473fc57557289d6b63960610e358bdebc1" @@ -10631,21 +10336,11 @@ timestring@^6.0.0: resolved "https://registry.yarnpkg.com/timestring/-/timestring-6.0.0.tgz#b0c7c331981ecf2066ce88bcfb8ee3ae32e7a0f6" integrity sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA== -timm@^1.6.1: - version "1.7.1" - resolved "https://registry.yarnpkg.com/timm/-/timm-1.7.1.tgz#96bab60c7d45b5a10a8a4d0f0117c6b7e5aff76f" - integrity sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw== - tiny-lru@11.2.11: version "11.2.11" resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-11.2.11.tgz#5089a6a4a157f5a97b82aa930b44d550ac5c4778" integrity sha512-27BIW0dIWTYYoWNnqSmoNMKe5WIbkXsc0xaCQHd3/3xT2XMuMJrzHdrO9QBFR14emBz1Bu0dOAs2sCBBrvgPQA== -tinycolor2@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" - integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== - titleize@2: version "2.1.0" resolved "https://registry.yarnpkg.com/titleize/-/titleize-2.1.0.tgz#5530de07c22147a0488887172b5bd94f5b30a48f" @@ -10691,14 +10386,6 @@ token-stream@1.0.0: resolved "https://registry.yarnpkg.com/token-stream/-/token-stream-1.0.0.tgz#cc200eab2613f4166d27ff9afc7ca56d49df6eb4" integrity sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg== -token-types@^4.1.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/token-types/-/token-types-4.2.1.tgz#0f897f03665846982806e138977dbe72d44df753" - integrity sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ== - dependencies: - "@tokenizer/token" "^0.3.0" - ieee754 "^1.2.1" - toposort-class@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toposort-class/-/toposort-class-1.0.1.tgz#7ffd1f78c8be28c3ba45cd4e1a3f5ee193bd9988" @@ -11006,13 +10693,6 @@ utf-8-validate@^6.0.4: dependencies: node-gyp-build "^4.3.0" -utif2@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/utif2/-/utif2-4.1.0.tgz#e768d37bd619b995d56d9780b5d2b4611a3d932b" - integrity sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w== - dependencies: - pako "^1.0.11" - util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -11175,11 +10855,6 @@ webtorrent@^2.5.17: optionalDependencies: utp-native "^2.5.3" -whatwg-fetch@^3.4.1: - version "3.6.20" - resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70" - integrity sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg== - whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" @@ -11397,16 +11072,6 @@ xhr2@0.2.1: resolved "https://registry.yarnpkg.com/xhr2/-/xhr2-0.2.1.tgz#4e73adc4f9cfec9cbd2157f73efdce3a5f108a93" integrity sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw== -xhr@^2.0.1: - version "2.6.0" - resolved "https://registry.yarnpkg.com/xhr/-/xhr-2.6.0.tgz#b69d4395e792b4173d6b7df077f0fc5e4e2b249d" - integrity sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA== - dependencies: - global "~4.4.0" - is-function "^1.0.1" - parse-headers "^2.0.0" - xtend "^4.0.0" - xml-js@^1.6.11: version "1.6.11" resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9" @@ -11414,11 +11079,6 @@ xml-js@^1.6.11: dependencies: sax "^1.2.4" -xml-parse-from-string@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz#a9029e929d3dbcded169f3c6e28238d95a5d5a28" - integrity sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g== - xml2js@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.5.0.tgz#d9440631fbb2ed800203fad106f2724f62c493b7"