import {FileType} from 'chrome://file-manager/common/js/file_type.js';
import {assert, assertInstanceof} from 'chrome://resources/ash/common/assert.js';
import {ImageCache} from './cache.js';
import {ImageLoaderUtil} from './image_loader_util.js';
import {ImageOrientation} from './image_orientation.js';
import {LoadImageRequest, LoadImageResponse, LoadImageResponseStatus} from './load_image_request.js';
import {PiexLoader} from './piex_loader.js';
* Creates and starts downloading and then resizing of the image. Finally,
* returns the image using the callback.
*
* @param {string} id Request ID.
* @param {ImageCache} cache Cache object.
* @param {!LoadImageRequest} request Request message as a hash array.
* @param {function(!LoadImageResponse)} callback Response handler.
* @constructor
*/
export function ImageRequestTask(id, cache, request, callback) {
* Global ID (concatenated client ID and client request ID).
* @type {string}
* @private
*/
this.id_ = id;
* @type {ImageCache}
* @private
*/
this.cache_ = cache;
* @type {!LoadImageRequest}
* @private
*/
this.request_ = request;
* @type {function(!LoadImageResponse)}
* @private
*/
this.sendResponse_ = callback;
* Temporary image used to download images.
* @type {Image}
* @private
*/
this.image_ = new Image();
* MIME type of the fetched image.
* @type {?string}
* @private
*/
this.contentType_ = null;
* 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.
* @type {?string}
* @private
*/
this.ifd_ = null;
* Used to download remote images using http:// or https:// protocols.
* @type {XMLHttpRequest}
* @private
*/
this.xhr_ = null;
* Temporary canvas used to resize and compress the image.
* @type {HTMLCanvasElement}
* @private
*/
this.canvas_ =
(document.createElement('canvas'));
* @type {CanvasRenderingContext2D}
* @private
*/
this.context_ =
(this.canvas_.getContext('2d'));
* @type {ImageOrientation|null}
*/
this.renderOrientation_ = null;
* Callback to be called once downloading is finished.
* @type {?function()}
* @private
*/
this.downloadCallback_ = null;
* @type {boolean}
* @private
*/
this.aborted_ = false;
}
* Seeks offset to generate video thumbnail.
* TODO(ryoh):
* What is the best position for the thumbnail?
* The first frame seems not good -- sometimes it is a black frame.
* @const
* @type {number}
*/
ImageRequestTask.VIDEO_THUMBNAIL_POSITION = 3;
* The maximum milliseconds to load video. If loading video exceeds the limit,
* we give up generating video thumbnail and free the consumed memory.
* @const
* @type {number}
*/
ImageRequestTask.MAX_MILLISECONDS_TO_LOAD_VIDEO = 3000;
* The default size (width and height) of a square thumbnail. The value is set
* to match the behavior of drivefs thumbnail generation.
* See chromeos/ash/components/drivefs/mojom/drivefs.mojom
* @const
* @type {number}
*/
ImageRequestTask.DEFAULT_THUMBNAIL_SQUARE_SIZE = 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
* @const
* @type {number}
*/
ImageRequestTask.DEFAULT_THUMBNAIL_WIDTH = 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
* @const
* @type {number}
*/
ImageRequestTask.DEFAULT_THUMBNAIL_HEIGHT = 500;
* A map which is used to estimate content type from extension.
* @enum {string}
*/
ImageRequestTask.ExtensionContentTypeMap = {
gif: 'image/gif',
png: 'image/png',
svg: 'image/svg',
bmp: 'image/bmp',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
};
* Extracts MIME type of a data URL.
* @param {string|undefined} dataUrl Data URL.
* @return {?string} MIME type string, or null if the URL is invalid.
*/
ImageRequestTask.getDataUrlMimeType = function(dataUrl) {
const dataUrlMatches = (dataUrl || '').match(/^data:([^,;]*)[,;]/);
return dataUrlMatches ? dataUrlMatches[1] : null;
};
* Returns ID of the request.
* @return {string} Request ID.
*/
ImageRequestTask.prototype.getId = function() {
return this.id_;
};
* Returns the client's task ID for the request.
* @return {number}
*/
ImageRequestTask.prototype.getClientTaskId = function() {
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 {number} Priority.
*/
ImageRequestTask.prototype.getPriority = function() {
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 {function()} onSuccess Success callback.
* @param {function()} onFailure Failure callback.
*/
ImageRequestTask.prototype.loadFromCacheAndProcess = function(
onSuccess, onFailure) {
this.loadFromCache_(
function(width, height, ifd, data) {
this.ifd_ = ifd;
this.sendImageData_(width, height, data);
onSuccess();
}.bind(this),
onFailure);
};
* Tries to download the image, resizes and sends the response.
*
* @param {function()} callback Completion callback.
*/
ImageRequestTask.prototype.downloadAndProcess = function(callback) {
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 {function(number, number, ?string, string)} onSuccess
* Success callback with the image width, height, ?ifd, and data.
* @param {function()} onFailure Failure callback.
* @private
*/
ImageRequestTask.prototype.loadFromCache_ = function(onSuccess, onFailure) {
const cacheKey = LoadImageRequest.cacheKey(this.request_);
if (!cacheKey) {
onFailure();
return;
}
if (!this.request_.cache) {
this.cache_.removeImage(cacheKey);
onFailure();
return;
}
const timestamp = this.request_.timestamp;
if (!timestamp) {
onFailure();
return;
}
this.cache_.loadImage(cacheKey, timestamp, onSuccess, onFailure);
};
* Saves the image to the persistent cache.
*
* @param {number} width Image width.
* @param {number} height Image height.
* @param {string} data Image data.
* @private
*/
ImageRequestTask.prototype.saveToCache_ = function(width, height, data) {
const timestamp = this.request_.timestamp;
if (!this.request_.cache || !timestamp) {
return;
}
const cacheKey = LoadImageRequest.cacheKey(this.request_);
if (!cacheKey) {
return;
}
this.cache_.saveImage(cacheKey, timestamp, width, height, this.ifd_, data);
};
* Gets the target image size for external thumbnails, where supported.
The defaults replicate drivefs thumbnailer behavior.
* @return {{width: !number, height: !number}}
*/
ImageRequestTask.prototype.targetThumbnailSize_ = function() {
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 {function()} onSuccess Success callback.
* @param {function()} onFailure Failure callback.
* @private
*/
ImageRequestTask.prototype.downloadThumbnail_ = function(onSuccess, onFailure) {
this.image_.onload = function() {
URL.revokeObjectURL(this.image_.src);
onSuccess();
}.bind(this);
this.image_.onerror = function() {
URL.revokeObjectURL(this.image_.src);
onFailure();
}.bind(this);
const dataUrlMimeType =
ImageRequestTask.getDataUrlMimeType(this.request_.url);
if (dataUrlMimeType) {
this.image_.src = this.request_.url;
this.contentType_ = dataUrlMimeType;
return;
}
const resolveLocalFileSystemUrl = (url, onResolveSuccess) => {
window.webkitResolveLocalFileSystemURL(url, onResolveSuccess, error => {
console.warn(error);
onFailure();
});
};
const onExternalThumbnail = (dataUrl) => {
if (chrome.runtime.lastError) {
console.warn(chrome.runtime.lastError.message);
onFailure();
} else if (dataUrl) {
this.image_.src = dataUrl;
this.contentType_ = ImageRequestTask.getDataUrlMimeType(dataUrl);
} else {
onFailure();
}
};
const drivefsUrlMatches = this.request_.url.match(/^drivefs:(.*)/);
if (drivefsUrlMatches) {
const url = drivefsUrlMatches[1];
const cropToSquare = !!this.request_.crop;
resolveLocalFileSystemUrl(
url,
entry => chrome.fileManagerPrivate.getDriveThumbnail(
entry, cropToSquare, onExternalThumbnail));
return;
}
if (this.request_.url.endsWith('.pdf')) {
const {width, height} = this.targetThumbnailSize_();
resolveLocalFileSystemUrl(
this.request_.url,
entry => chrome.fileManagerPrivate.getPdfThumbnail(
entry, width, height, onExternalThumbnail));
return;
}
const isDocumentsProviderRequest = !!this.request_.url.match(RegExp(
'filesystem:chrome-extension://[a-z]+/external/arc-documents-provider/.*'));
if (isDocumentsProviderRequest) {
const {width, height} = this.targetThumbnailSize_();
resolveLocalFileSystemUrl(this.request_.url, entry => {
chrome.fileManagerPrivate.getArcDocumentsProviderThumbnail(
entry, width, height, onExternalThumbnail);
});
return;
}
const fileType = FileType.getTypeForName(this.request_.url);
if (fileType.type === 'raw') {
PiexLoader.load(this.request_.url, chrome.runtime.reload)
.then(
function(data) {
this.renderOrientation_ =
ImageOrientation.fromExifOrientation(data.orientation);
this.ifd_ = data.ifd;
this.contentType_ = data.mimeType;
const blob = new Blob([data.thumbnail], {type: data.mimeType});
this.image_.src = URL.createObjectURL(blob);
}.bind(this),
function() {
onFailure();
});
return;
}
if (fileType.type === 'video') {
this.createVideoThumbnailUrl_(this.request_.url)
.then(function(url) {
this.image_.src = url;
}.bind(this))
.catch(function(error) {
console.warn('Video thumbnail error: ', error);
onFailure();
});
return;
}
this.load(this.request_.url, (contentType, blob) => {
this.image_.src = blob ? URL.createObjectURL(blob) : '!';
this.contentType_ = contentType || null;
if (this.contentType_ === 'image/jpeg') {
this.renderOrientation_ = ImageOrientation.fromExifOrientation(1);
}
}, onFailure);
};
* Creates a video thumbnail data url from video file.
*
* @param {string} url Video URL.
* @return {!Promise<Blob>} Promise that resolves with the data url of video
* thumbnail.
* @private
*/
ImageRequestTask.prototype.createVideoThumbnailUrl_ = function(url) {
const video =
assertInstanceof(document.createElement('video'), HTMLVideoElement);
return Promise
.race([
new Promise((resolve, reject) => {
video.addEventListener('canplay', resolve);
video.addEventListener('error', reject);
video.currentTime = ImageRequestTask.VIDEO_THUMBNAIL_POSITION;
video.preload = 'auto';
video.src = url;
video.load();
}),
new Promise((resolve) => {
setTimeout(resolve, ImageRequestTask.MAX_MILLISECONDS_TO_LOAD_VIDEO);
}).then(() => {
video.src =
'';
throw new Error('Seeking video failed.');
}),
])
.then(() => {
const canvas = assertInstanceof(
document.createElement('canvas'), HTMLCanvasElement);
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
assertInstanceof(canvas.getContext('2d'), CanvasRenderingContext2D)
.drawImage(video, 0, 0);
return canvas.toDataURL();
});
};
* Loads an image.
*
* @param {string} url URL to the resource to be fetched.
* @param {function(string, Blob)} onSuccess Success callback with the content
* type and the fetched data.
* @param {function()} onFailure Failure callback.
*/
ImageRequestTask.prototype.load = function(url, onSuccess, onFailure) {
this.aborted_ = false;
const onMaybeSuccess =
(function(contentType, response) {
if (!contentType) {
contentType =
ImageRequestTask
.ExtensionContentTypeMap[this.extractExtension_(url)];
}
if (!this.aborted_) {
onSuccess(contentType, response);
}
}.bind(this));
const onMaybeFailure = (function(opt_code) {
if (!this.aborted_) {
onFailure();
}
}.bind(this));
const noCacheUrl = url + '?nocache=' + Date.now();
this.xhr_ =
ImageRequestTask.load_(noCacheUrl, onMaybeSuccess, onMaybeFailure);
};
* Extracts extension from url.
* @param {string} url Url.
* @return {string} Extracted extension, e.g. png.
*/
ImageRequestTask.prototype.extractExtension_ = function(url) {
const result = (/\.([a-zA-Z]+)$/i).exec(url);
return result ? result[1] : '';
};
* Fetches data using XmlHttpRequest.
*
* @param {string} url URL to the resource to be fetched.
* @param {function(string, Blob)} onSuccess Success callback with the content
* type and the fetched data.
* @param {function(number=)} onFailure Failure callback with the error code
* if available.
* @return {XMLHttpRequest} XHR instance.
* @private
*/
ImageRequestTask.load_ = function(url, onSuccess, onFailure) {
const xhr = new XMLHttpRequest();
xhr.responseType = 'blob';
xhr.onreadystatechange = function() {
if (xhr.readyState != 4) {
return;
}
if (xhr.status != 200) {
onFailure(xhr.status);
return;
}
const response = (xhr.response);
const contentType = xhr.getResponseHeader('Content-Type') || response.type;
onSuccess(contentType, response);
}.bind(this);
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 {boolean} imageChanged Whether the image has been changed.
* @private
*/
ImageRequestTask.prototype.sendImage_ = function(imageChanged) {
let width;
let height;
let data;
if (!imageChanged) {
width = this.image_.width;
height = this.image_.height;
data = this.image_.src;
} else {
width = this.canvas_.width;
height = this.canvas_.height;
switch (this.contentType_) {
case 'image/gif':
case 'image/png':
case 'image/svg':
case 'image/bmp':
data = this.canvas_.toDataURL('image/png');
break;
case 'image/jpeg':
default:
data = this.canvas_.toDataURL('image/jpeg', 0.9);
break;
}
}
this.sendImageData_(width, height, data);
this.saveToCache_(width, height, data);
};
* Sends the resized image via the callback.
*
* @param {number} width Image width.
* @param {number} height Image height.
* @param {string} data Image data.
* @private
*/
ImageRequestTask.prototype.sendImageData_ = function(width, height, data) {
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
*/
ImageRequestTask.prototype.onImageLoad_ = function() {
const requestOrientation = this.request_.orientation;
if (this.renderOrientation_) {
this.request_.orientation = this.renderOrientation_;
}
let imageChanged = false;
if (!(this.request_.url.match(/^data/) ||
this.request_.url.match(/^drivefs:/)) ||
ImageLoaderUtil.shouldProcess(
this.image_.width, this.image_.height, this.request_)) {
ImageLoaderUtil.resizeAndCrop(this.image_, this.canvas_, this.request_);
imageChanged = true;
}
if (this.renderOrientation_) {
this.request_.orientation = requestOrientation;
}
this.sendImage_(imageChanged);
this.cleanup_();
this.downloadCallback_();
};
* Handler, when loading of the image fails. Sends a failure response and
* finalizes the request process.
* @private
*/
ImageRequestTask.prototype.onImageError_ = function() {
this.sendResponse_(new LoadImageResponse(
LoadImageResponseStatus.ERROR, this.getClientTaskId()));
this.cleanup_();
this.downloadCallback_();
};
* Cancels the request.
*/
ImageRequestTask.prototype.cancel = function() {
this.cleanup_();
if (this.downloadCallback_) {
this.downloadCallback_();
}
};
* Cleans up memory used by this request.
* @private
*/
ImageRequestTask.prototype.cleanup_ = function() {
this.image_.onerror = function() {};
this.image_.onload = function() {};
this.image_.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAA' +
'ABAAEAAAICTAEAOw==';
this.aborted_ = true;
if (this.xhr_) {
this.xhr_.abort();
}
this.canvas_.width = 0;
this.canvas_.height = 0;
};