| // Copyright 2013 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {getFileTypeForName} from 'chrome://file-manager/common/js/file_types_base.js'; |
| import {assert} from 'chrome://resources/js/assert.js'; |
| |
| import type {ImageCache} from './cache.js'; |
| import {resizeAndCrop, shouldProcess} from './image_loader_util.js'; |
| import {ImageOrientation} from './image_orientation.js'; |
| import {cacheKey, type LoadImageRequest, LoadImageResponse, LoadImageResponseStatus} from './load_image_request.js'; |
| import {PiexLoader} from './piex_loader.js'; |
| import type {PrivateApi} from './sw_od_messages.js'; |
| |
| const ExtensionContentTypeMap = new Map<string, string>([ |
| ['gif', 'image/gif'], |
| ['png', 'image/png'], |
| ['svg', 'image/svg'], |
| ['bmp', 'image/bmp'], |
| ['jpg', 'image/jpeg'], |
| ['jpeg', 'image/jpeg'], |
| ]); |
| |
| const adpRegExp = RegExp( |
| '^filesystem:chrome-extension://[a-z]+/external/arc-documents-provider/'); |
| |
| /** |
| * Calls the imageLoaderPrivate API with the given message. |
| * |
| * @param msg The imageLoaderPrivate call arguments. |
| * @return A promise for the thumbnailDataUrl. |
| */ |
| function callImageLoaderPrivate(msg: PrivateApi): Promise<string> { |
| return new Promise<string>((resolve, reject) => { |
| chrome.runtime.sendMessage( |
| null, msg, undefined, (thumbnailDataUrl: string) => { |
| if (chrome.runtime.lastError) { |
| console.warn(chrome.runtime.lastError.message); |
| reject(chrome.runtime.lastError); |
| } else if (thumbnailDataUrl) { |
| resolve(thumbnailDataUrl); |
| } else { |
| reject(); |
| } |
| }); |
| }); |
| } |
| |
| /** |
| * Creates and starts downloading and then resizing of the image. Finally, |
| * returns the image using the callback. |
| */ |
| export class ImageRequestTask { |
| /** |
| * The maximum milliseconds to load video. If loading video exceeds the limit, |
| * we give up generating video thumbnail and free the consumed memory. |
| */ |
| static readonly MAX_MILLISECONDS_TO_LOAD_VIDEO: number = 10000; |
| |
| /** |
| * The default width of a non-square thumbnail. The value is set to match the |
| * behavior of drivefs thumbnail generation. |
| * See chromeos/ash/components/drivefs/mojom/drivefs.mojom |
| */ |
| static readonly DEFAULT_THUMBNAIL_SQUARE_SIZE: number = 360; |
| |
| /** |
| * The default width of a non-square thumbnail. The value is set to match the |
| * behavior of drivefs thumbnail generation. |
| * See chromeos/ash/components/drivefs/mojom/drivefs.mojom |
| */ |
| static readonly DEFAULT_THUMBNAIL_WIDTH: number = 500; |
| |
| /** |
| * The default height of a non-square thumbnail. The value is set to match the |
| * behavior of drivefs thumbnail generation. |
| * See chromeos/ash/components/drivefs/mojom/drivefs.mojom |
| */ |
| static readonly DEFAULT_THUMBNAIL_HEIGHT: number = 500; |
| |
| /** |
| * Temporary image used to download images. |
| */ |
| private image_: HTMLImageElement = new Image(); |
| |
| /** |
| * MIME type of the fetched image. |
| */ |
| private contentType_?: string; |
| |
| /** |
| * IFD data of the fetched image. Only RAW images provide a non-null |
| * ifd at this time. Drive images might provide an ifd in future. |
| */ |
| private ifd_?: string; |
| |
| /** |
| * Used to download remote images using http:// or https:// protocols. |
| */ |
| private xhr_: XMLHttpRequest|null = null; |
| |
| /** |
| * Temporary canvas used to resize and compress the image. |
| */ |
| private canvas_: HTMLCanvasElement; |
| |
| private renderOrientation_: ImageOrientation|null = null; |
| |
| /** |
| * Callback to be called once downloading is finished. |
| */ |
| private downloadCallback_: null|VoidCallback = null; |
| |
| private aborted_: boolean = false; |
| |
| /** |
| * @param id Request ID. |
| * @param cache Cache object. |
| * @param request Request message as a hash array. |
| * @param callback Response handler. |
| */ |
| constructor( |
| private id_: string, |
| private cache_: ImageCache, |
| private request_: LoadImageRequest, |
| private sendResponse_: (a: LoadImageResponse) => void, |
| ) { |
| this.canvas_ = document.createElement('canvas'); |
| } |
| |
| /** |
| * Extracts MIME type of a data URL. |
| * @param dataUrl Data URL. |
| * @return MIME type string, or null if the URL is invalid. |
| */ |
| static getDataUrlMimeType(dataUrl?: string): string|undefined { |
| const dataUrlMatches = (dataUrl || '').match(/^data:([^,;]*)[,;]/); |
| return dataUrlMatches ? dataUrlMatches[1] : undefined; |
| } |
| |
| /** |
| * Returns ID of the request. |
| * @return Request ID. |
| */ |
| getId(): string { |
| return this.id_; |
| } |
| |
| getClientTaskId(): number { |
| // Every incoming request should have been given a taskId. |
| assert(this.request_.taskId); |
| return this.request_.taskId; |
| } |
| |
| /** |
| * Returns priority of the request. The higher priority, the faster it will |
| * be handled. The highest priority is 0. The default one is 2. |
| * |
| * @return Priority. |
| */ |
| getPriority(): number { |
| return this.request_.priority !== undefined ? this.request_.priority : 2; |
| } |
| |
| /** |
| * Tries to load the image from cache, if it exists in the cache, and sends |
| * the response. Fails if the image is not found in the cache. |
| * |
| * @param onSuccess Success callback. |
| * @param onFailure Failure callback. |
| */ |
| loadFromCacheAndProcess(onSuccess: VoidCallback, onFailure: VoidCallback) { |
| this.loadFromCache_( |
| (width: number, height: number, ifd?: string, data?: string) => { |
| // Found in cache. |
| this.ifd_ = ifd; |
| this.sendImageData_(width, height, data!); |
| onSuccess(); |
| }, |
| onFailure, |
| ); // Not found in cache. |
| } |
| |
| /** |
| * Tries to download the image, resizes and sends the response. |
| * |
| * @param callback Completion callback. |
| */ |
| downloadAndProcess(callback: VoidCallback) { |
| if (this.downloadCallback_) { |
| throw new Error('Downloading already started.'); |
| } |
| |
| this.downloadCallback_ = callback; |
| this.downloadThumbnail_( |
| this.onImageLoad_.bind(this), |
| this.onImageError_.bind(this), |
| ); |
| } |
| |
| /** |
| * Fetches the image from the persistent cache. |
| * |
| * @param onSuccess callback with the image width, height, ?ifd, and data. |
| * @param onFailure Failure callback. |
| */ |
| private loadFromCache_( |
| onSuccess: ( |
| width: number, |
| height: number, |
| ifd?: string, |
| data?: string, |
| ) => void, |
| onFailure: VoidCallback, |
| ) { |
| const key = cacheKey(this.request_); |
| |
| if (!key) { |
| // Cache key is not provided for the request. |
| onFailure(); |
| return; |
| } |
| |
| if (!this.request_.cache) { |
| // Cache is disabled for this request; therefore, remove it from cache |
| // if existed. |
| this.cache_.removeImage(key); |
| onFailure(); |
| return; |
| } |
| |
| const timestamp = this.request_.timestamp; |
| if (!timestamp) { |
| // Persistent cache is available only when a timestamp is provided. |
| onFailure(); |
| return; |
| } |
| |
| this.cache_.loadImage(key, timestamp, onSuccess, onFailure); |
| } |
| |
| /** |
| * Saves the image to the persistent cache. |
| * |
| * @param width Image width. |
| * @param height Image height. |
| * @param data Image data. |
| */ |
| private saveToCache_(width: number, height: number, data: string) { |
| const timestamp = this.request_.timestamp; |
| |
| if (!this.request_.cache || !timestamp) { |
| // Persistent cache is available only when a timestamp is provided. |
| return; |
| } |
| |
| const key = cacheKey(this.request_); |
| if (!key) { |
| // Cache key is not provided for the request. |
| return; |
| } |
| |
| this.cache_.saveImage(key, timestamp, width, height, this.ifd_, data); |
| } |
| |
| /** |
| * Gets the target image size for external thumbnails, where supported. |
| * The defaults replicate drivefs thumbnailer behavior. |
| */ |
| private targetThumbnailSize_(): {width: number, height: number} { |
| const crop = !!this.request_.crop; |
| const defaultWidth = crop ? ImageRequestTask.DEFAULT_THUMBNAIL_SQUARE_SIZE : |
| ImageRequestTask.DEFAULT_THUMBNAIL_WIDTH; |
| const defaultHeight = crop ? |
| ImageRequestTask.DEFAULT_THUMBNAIL_SQUARE_SIZE : |
| ImageRequestTask.DEFAULT_THUMBNAIL_HEIGHT; |
| return { |
| width: this.request_.width || defaultWidth, |
| height: this.request_.height || defaultHeight, |
| }; |
| } |
| |
| /** |
| * Loads |this.image_| with the |this.request_.url| source or the thumbnail |
| * image of the source. |
| * |
| * @param onSuccess Success callback. |
| * @param onFailure Failure callback. |
| */ |
| private async downloadThumbnail_( |
| onSuccess: VoidCallback, |
| onFailure: VoidCallback, |
| ) { |
| // Load methods below set |this.image_.src|. Call revokeObjectURL(src) to |
| // release resources if the image src was created with createObjectURL(). |
| this.image_.onload = () => { |
| URL.revokeObjectURL(this.image_.src); |
| onSuccess(); |
| }; |
| this.image_.onerror = () => { |
| URL.revokeObjectURL(this.image_.src); |
| onFailure(); |
| }; |
| |
| // Load dataURL sources directly. |
| const dataUrlMimeType = ImageRequestTask.getDataUrlMimeType( |
| this.request_.url, |
| ); |
| const requestUrl = this.request_.url ?? ''; |
| if (dataUrlMimeType) { |
| this.image_.src = requestUrl; |
| this.contentType_ = dataUrlMimeType; |
| return; |
| } |
| |
| const onExternalThumbnail = (dataUrl: string) => { |
| this.image_.src = dataUrl; |
| this.contentType_ = ImageRequestTask.getDataUrlMimeType(dataUrl); |
| }; |
| |
| // Load Drive source thumbnail. |
| const drivefsUrlMatches = requestUrl.match(/^drivefs:(.*)/); |
| if (drivefsUrlMatches) { |
| callImageLoaderPrivate({ |
| apiMethod: 'getDriveThumbnail', |
| params: { |
| url: drivefsUrlMatches[1] || '', |
| cropToSquare: !!this.request_.crop, |
| }, |
| }) |
| .then(onExternalThumbnail) |
| .catch(onFailure); |
| return; |
| } |
| |
| // Load PDF source thumbnail. |
| if (requestUrl.endsWith('.pdf')) { |
| const {width, height} = this.targetThumbnailSize_(); |
| callImageLoaderPrivate({ |
| apiMethod: 'getPdfThumbnail', |
| params: { |
| url: requestUrl, |
| width, |
| height, |
| }, |
| }) |
| .then(onExternalThumbnail) |
| .catch(onFailure); |
| return; |
| } |
| |
| // Load ARC DocumentsProvider thumbnail, if supported. |
| if (requestUrl.match(adpRegExp)) { |
| const {width, height} = this.targetThumbnailSize_(); |
| callImageLoaderPrivate({ |
| apiMethod: 'getArcDocumentsProviderThumbnail', |
| params: { |
| url: requestUrl, |
| widthHint: width, |
| heightHint: height, |
| }, |
| }) |
| .then(onExternalThumbnail) |
| .catch(onFailure); |
| return; |
| } |
| |
| const fileType = getFileTypeForName(requestUrl); |
| |
| // Load video source thumbnail. |
| if (fileType.type === 'video') { |
| this.createVideoThumbnailUrl_(requestUrl) |
| .then((url) => { |
| this.image_.src = url; |
| }) |
| .catch((error) => { |
| console.warn('Video thumbnail error: ', error); |
| onFailure(); |
| }); |
| return; |
| } |
| |
| // Load the source directly. |
| this.load( |
| requestUrl, |
| (contentType, blob) => { |
| // Load RAW image source thumbnail. |
| if (fileType.type === 'raw') { |
| blob.arrayBuffer() |
| .then( |
| (buffer) => PiexLoader.load(buffer, chrome.runtime.reload)) |
| .then((data) => { |
| this.renderOrientation_ = |
| ImageOrientation.fromExifOrientation( |
| data.orientation, |
| ); |
| this.ifd_ = data.ifd ?? undefined; |
| this.contentType_ = data.mimeType; |
| const blob = |
| new Blob([data.thumbnail], {type: data.mimeType}); |
| this.image_.src = URL.createObjectURL(blob); |
| }) |
| .catch(onFailure); |
| return; |
| } |
| |
| this.image_.src = blob ? URL.createObjectURL(blob) : '!'; |
| this.contentType_ = contentType || undefined; |
| if (this.contentType_ === 'image/jpeg') { |
| this.renderOrientation_ = ImageOrientation.fromExifOrientation(1); |
| } |
| }, |
| onFailure, |
| ); |
| } |
| |
| /** |
| * Creates a video thumbnail data url from video file. |
| * |
| * @param url Video URL. |
| * @return Promise that resolves with the data url of video |
| * thumbnail. |
| */ |
| private createVideoThumbnailUrl_(url: string): Promise<string> { |
| const video: HTMLVideoElement = document.createElement('video'); |
| return Promise |
| .race([ |
| new Promise<void>((resolve, reject) => { |
| video.addEventListener('loadedmetadata', () => { |
| video.addEventListener('seeked', () => { |
| if (video.readyState >= video.HAVE_CURRENT_DATA) { |
| resolve(); |
| } else { |
| video.addEventListener('loadeddata', () => resolve()); |
| } |
| }); |
| // For videos with longer duration (>= 6 seconds), consider the |
| // frame at 3rd second, or use the frame at midpoint otherwise. |
| // This ensures the target position is always close to the |
| // beginning of the video. Seek operations may be costly if the |
| // video doesn't contain keyframes for referencing. |
| const thumbnailPosition = Math.min(video.duration / 2, 3); |
| video.currentTime = thumbnailPosition; |
| }); |
| video.addEventListener('error', reject); |
| video.preload = 'metadata'; |
| video.src = url; |
| video.load(); |
| }), |
| new Promise((resolve) => { |
| setTimeout( |
| resolve, ImageRequestTask.MAX_MILLISECONDS_TO_LOAD_VIDEO); |
| }).then(() => { |
| // If we can't get the frame at the midpoint of the video after 3 |
| // seconds have passed for some reason (e.g. unseekable video), we |
| // give up generating thumbnail. |
| // Make sure to stop loading remaining part of the video. |
| video.src = ''; |
| throw new Error('Seeking video failed.'); |
| }), |
| ]) |
| .then(() => { |
| const canvas: HTMLCanvasElement = document.createElement('canvas'); |
| canvas.width = video.videoWidth; |
| canvas.height = video.videoHeight; |
| canvas.getContext('2d')!.drawImage(video, 0, 0); |
| // Clearing the `src` helps the decoder to dispose its memory earlier. |
| video.src = ''; |
| return canvas.toDataURL(); |
| }); |
| } |
| |
| /** |
| * Loads an image. |
| * |
| * @param url URL to the resource to be fetched. |
| * @param onSuccess Success callback with the content type and the fetched |
| * data. |
| * @param onFailure Failure callback. |
| */ |
| load( |
| url: string, |
| onSuccess: (contentType: string|undefined, reponse: Blob) => void, |
| onFailure: VoidCallback, |
| ) { |
| this.aborted_ = false; |
| |
| // Do not call any callbacks when aborting. |
| const onMaybeSuccess = ( |
| contentType: string|undefined, |
| response: Blob, |
| ) => { |
| // When content type is not available, try to estimate it from url. |
| if (!contentType) { |
| contentType = ExtensionContentTypeMap.get(this.extractExtension_(url)); |
| } |
| |
| if (!this.aborted_) { |
| onSuccess(contentType, response); |
| } |
| }; |
| |
| const onMaybeFailure = () => { |
| if (!this.aborted_) { |
| onFailure(); |
| } |
| }; |
| |
| // The query parameter is workaround for crbug.com/379678, which forces the |
| // browser to obtain the latest contents of the image. |
| const noCacheUrl = url + '?nocache=' + Date.now(); |
| this.xhr_ = ImageRequestTask.load_( |
| noCacheUrl, |
| onMaybeSuccess, |
| onMaybeFailure, |
| ); |
| } |
| |
| /** |
| * Extracts extension from url. |
| * @param url Url. |
| * @return Extracted extension, e.g. png. |
| */ |
| private extractExtension_(url: string): string { |
| const result = /\.([a-zA-Z]+)$/i.exec(url); |
| return result ? result[1] ?? '' : ''; |
| } |
| |
| /** |
| * Fetches data using XmlHttpRequest. |
| * |
| * @param url URL to the resource to be fetched. |
| * @param onSuccess Success callback with the content type and the fetched |
| * data. |
| * @param onFailure Failure callback with the error code if available. |
| * @return XHR instance. |
| */ |
| private static load_( |
| url: string, |
| onSuccess: (a: string, b: Blob) => void, |
| onFailure: (a?: number) => void, |
| ): XMLHttpRequest { |
| const xhr = new XMLHttpRequest(); |
| xhr.responseType = 'blob'; |
| |
| xhr.onreadystatechange = () => { |
| if (xhr.readyState !== 4) { |
| return; |
| } |
| if (xhr.status !== 200) { |
| onFailure(xhr.status); |
| return; |
| } |
| const response: Blob = xhr.response; |
| const contentType = |
| xhr.getResponseHeader('Content-Type') || response.type; |
| onSuccess(contentType, response); |
| }; |
| |
| // Perform a xhr request. |
| try { |
| xhr.open('GET', url, true); |
| xhr.send(); |
| } catch (e) { |
| onFailure(); |
| } |
| |
| return xhr; |
| } |
| |
| /** |
| * Sends the resized image via the callback. If the image has been changed, |
| * then packs the canvas contents, otherwise sends the raw image data. |
| * |
| * @param imageChanged Whether the image has been changed. |
| */ |
| private sendImage_(imageChanged: boolean) { |
| let width: number; |
| let height: number; |
| let data: string; |
| |
| if (!imageChanged) { |
| // The image hasn't been processed, so the raw data can be directly |
| // forwarded for speed (no need to encode the image again). |
| width = this.image_.width; |
| height = this.image_.height; |
| data = this.image_.src; |
| } else { |
| // The image has been resized or rotated, therefore the canvas has to be |
| // encoded to get the correct compressed image data. |
| width = this.canvas_.width; |
| height = this.canvas_.height; |
| |
| switch (this.contentType_) { |
| case 'image/jpeg': |
| data = this.canvas_.toDataURL('image/jpeg', 0.9); |
| break; |
| default: |
| data = this.canvas_.toDataURL('image/png'); |
| break; |
| } |
| } |
| |
| // Send the image data and also save it in the persistent cache. |
| this.sendImageData_(width, height, data); |
| this.saveToCache_(width, height, data); |
| } |
| |
| /** |
| * Sends the resized image via the callback. |
| * |
| * @param width Image width. |
| * @param height Image height. |
| * @param data Image data. |
| */ |
| private sendImageData_(width: number, height: number, data: string) { |
| const result = {width, height, ifd: this.ifd_, data}; |
| this.sendResponse_( |
| new LoadImageResponse( |
| LoadImageResponseStatus.SUCCESS, |
| this.getClientTaskId(), |
| result, |
| ), |
| ); |
| } |
| |
| /** |
| * Handler, when contents are loaded into the image element. Performs image |
| * processing operations if needed, and finalizes the request process. |
| */ |
| private onImageLoad_() { |
| const requestOrientation = this.request_.orientation; |
| |
| // Override the request orientation before processing if needed. |
| if (this.renderOrientation_) { |
| this.request_.orientation = this.renderOrientation_; |
| } |
| |
| // Perform processing if the url is not a data url, or if there are some |
| // operations requested. |
| let imageChanged = false; |
| const reqUrl = this.request_.url ?? ''; |
| if (!(reqUrl.match(/^data/) || reqUrl.match(/^drivefs:/)) || |
| shouldProcess(this.image_.width, this.image_.height, this.request_)) { |
| resizeAndCrop(this.image_, this.canvas_, this.request_); |
| imageChanged = true; // The image is now on the <canvas>. |
| } |
| |
| // Restore the request orientation after processing. |
| if (this.renderOrientation_) { |
| this.request_.orientation = requestOrientation; |
| } |
| |
| // Finalize the request. |
| this.sendImage_(imageChanged); |
| this.cleanup_(); |
| if (this.downloadCallback_) { |
| this.downloadCallback_(); |
| } |
| } |
| |
| /** |
| * Handler, when loading of the image fails. Sends a failure response and |
| * finalizes the request process. |
| */ |
| private onImageError_() { |
| this.sendResponse_( |
| new LoadImageResponse( |
| LoadImageResponseStatus.ERROR, |
| this.getClientTaskId(), |
| ), |
| ); |
| this.cleanup_(); |
| if (this.downloadCallback_) { |
| this.downloadCallback_(); |
| } |
| } |
| |
| /** |
| * Cancels the request. |
| */ |
| cancel() { |
| this.cleanup_(); |
| |
| // If downloading has started, then call the callback. |
| if (this.downloadCallback_) { |
| this.downloadCallback_(); |
| } |
| } |
| |
| /** |
| * Cleans up memory used by this request. |
| */ |
| private cleanup_() { |
| this.image_.onerror = () => {}; |
| this.image_.onload = () => {}; |
| |
| // Transparent 1x1 pixel gif, to force garbage collecting. |
| this.image_.src = |
| 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAA' + |
| 'ABAAEAAAICTAEAOw=='; |
| |
| this.aborted_ = true; |
| if (this.xhr_) { |
| this.xhr_.abort(); |
| } |
| |
| // Dispose memory allocated by Canvas. |
| this.canvas_.width = 0; |
| this.canvas_.height = 0; |
| } |
| } |