blob: 9a7d90d11c60ae79a64e218c94c076e5d2cf508a [file] [log] [blame]
// Copyright 2013 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* 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 {!PiexLoader} piexLoader Piex loader for RAW file.
* @param {!LoadImageRequest} request Request message as a hash array.
* @param {function(!LoadImageResponse)} callback Response handler.
* @constructor
*/
function ImageRequest(id, cache, piexLoader, request, callback) {
/**
* Global ID (concatenated client ID and client request ID).
* @type {string}
* @private
*/
this.id_ = id;
/**
* @type {ImageCache}
* @private
*/
this.cache_ = cache;
/**
* @type {!PiexLoader}
* @private
*/
this.piexLoader_ = piexLoader;
/**
* @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;
/**
* Used to download remote images using http:// or https:// protocols.
* @type {AuthorizedXHR}
* @private
*/
this.xhr_ = new AuthorizedXHR();
/**
* Temporary canvas used to resize and compress the image.
* @type {HTMLCanvasElement}
* @private
*/
this.canvas_ =
/** @type {HTMLCanvasElement} */ (document.createElement('canvas'));
/**
* @type {CanvasRenderingContext2D}
* @private
*/
this.context_ =
/** @type {CanvasRenderingContext2D} */ (this.canvas_.getContext('2d'));
/**
* Callback to be called once downloading is finished.
* @type {?function()}
* @private
*/
this.downloadCallback_ = null;
}
/**
* 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}
*/
ImageRequest.VIDEO_THUMBNAIL_POSITION = 3; // [sec]
/**
* 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}
*/
ImageRequest.MAX_MILLISECONDS_TO_LOAD_VIDEO = 3000;
/**
* Returns ID of the request.
* @return {string} Request ID.
*/
ImageRequest.prototype.getId = function() {
return this.id_;
};
/**
* Returns the client's task ID for the request.
* @return {number}
*/
ImageRequest.prototype.getClientTaskId = function() {
// 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 {number} Priority.
*/
ImageRequest.prototype.getPriority = function() {
return (this.request_.priority !== undefined) ? this.request_.priority : 2;
};
/**
* Tries to load the image from cache if exists and sends the response.
*
* @param {function()} onSuccess Success callback.
* @param {function()} onFailure Failure callback.
*/
ImageRequest.prototype.loadFromCacheAndProcess = function(
onSuccess, onFailure) {
this.loadFromCache_(
function(data, width, height) { // Found in cache.
this.sendImageData_(data, width, height);
onSuccess();
}.bind(this),
onFailure); // Not found in cache.
};
/**
* Tries to download the image, resizes and sends the response.
* @param {function()} callback Completion callback.
*/
ImageRequest.prototype.downloadAndProcess = function(callback) {
if (this.downloadCallback_) {
throw new Error('Downloading already started.');
}
this.downloadCallback_ = callback;
this.downloadOriginal_(this.onImageLoad_.bind(this),
this.onImageError_.bind(this));
};
/**
* Fetches the image from the persistent cache.
*
* @param {function(string, number, number)} onSuccess Success callback.
* @param {function()} onFailure Failure callback.
* @private
*/
ImageRequest.prototype.loadFromCache_ = function(onSuccess, onFailure) {
let cacheKey = LoadImageRequest.cacheKey(this.request_);
if (!cacheKey) {
// 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(cacheKey);
onFailure();
return;
}
if (!this.request_.timestamp) {
// Persistent cache is available only when a timestamp is provided.
onFailure();
return;
}
this.cache_.loadImage(cacheKey,
this.request_.timestamp,
onSuccess,
onFailure);
};
/**
* Saves the image to the persistent cache.
*
* @param {string} data The image's data.
* @param {number} width Image width.
* @param {number} height Image height.
* @private
*/
ImageRequest.prototype.saveToCache_ = function(data, width, height) {
if (!this.request_.cache || !this.request_.timestamp) {
// Persistent cache is available only when a timestamp is provided.
return;
}
let cacheKey = LoadImageRequest.cacheKey(this.request_);
if (!cacheKey) {
// Cache key is not provided for the request.
return;
}
this.cache_.saveImage(cacheKey,
data,
width,
height,
this.request_.timestamp);
};
/**
* Downloads an image directly or for remote resources using the XmlHttpRequest.
*
* @param {function()} onSuccess Success callback.
* @param {function()} onFailure Failure callback.
* @private
*/
ImageRequest.prototype.downloadOriginal_ = 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);
// Download data urls directly since they are not supported by XmlHttpRequest.
var dataUrlMatches = this.request_.url.match(/^data:([^,;]*)[,;]/);
if (dataUrlMatches) {
this.image_.src = this.request_.url;
this.contentType_ = dataUrlMatches[1];
return;
}
var drivefsUrlMatches = this.request_.url.match(/^drivefs:(.*)/);
if (drivefsUrlMatches) {
window.webkitResolveLocalFileSystemURL(
drivefsUrlMatches[1],
entry => {
chrome.fileManagerPrivate.getThumbnail(
entry, !!this.request_.crop, thumbnail => {
if (!thumbnail) {
onFailure();
return;
}
this.image_.src = thumbnail;
this.contentType_ = 'image/png';
});
},
error => {
onFailure();
});
return;
}
var fileType = FileType.getTypeForName(this.request_.url);
// Load RAW images by using Piex loader instead of XHR.
if (fileType.type === 'raw') {
this.piexLoader_.load(this.request_.url).then(function(data) {
var blob = new Blob([data.thumbnail], {type: 'image/jpeg'});
var url = URL.createObjectURL(blob);
this.image_.src = url;
this.request_.orientation = data.orientation;
this.request_.colorSpace = data.colorSpace;
}.bind(this), function() {
// The error has already been logged in PiexLoader.
onFailure();
});
return;
}
// Load video thumbnails by using video tag instead of XHR.
if (fileType.type === 'video') {
this.createVideoThumbnailUrl_(this.request_.url).then(function(url) {
this.image_.src = url;
}.bind(this)).catch(function(error) {
console.error('Video thumbnail error: ', error);
onFailure();
});
return;
}
// Fetch the image via authorized XHR and parse it.
var parseImage = function(contentType, blob) {
if (contentType) {
this.contentType_ = contentType;
}
this.image_.src = URL.createObjectURL(blob);
}.bind(this);
// Request raw data via XHR.
this.xhr_.load(this.request_.url, parseImage, 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
*/
ImageRequest.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 = ImageRequest.VIDEO_THUMBNAIL_POSITION;
video.preload = 'auto';
video.src = url;
video.load();
}),
new Promise((resolve) => {
setTimeout(resolve, ImageRequest.MAX_MILLISECONDS_TO_LOAD_VIDEO);
}).then(() => {
// If we don't receive 'canplay' event after 3 seconds have passed for
// some reason (e.g. unseekable video), we give up generating
// thumbnail.
video.src =
''; // Make sure to stop loading remaining part of the video.
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();
});
};
/**
* Creates a XmlHttpRequest wrapper with injected OAuth2 authentication headers.
* @constructor
*/
function AuthorizedXHR() {
this.xhr_ = null;
this.aborted_ = false;
}
/**
* A map which is used to estimate content type from extension.
* @enum {string}
*/
AuthorizedXHR.ExtensionContentTypeMap = {
gif: 'image/gif',
png: 'image/png',
svg: 'image/svg',
bmp: 'image/bmp',
jpg: 'image/jpeg',
jpeg: 'image/jpeg'
};
/**
* Aborts the current request (if running).
*/
AuthorizedXHR.prototype.abort = function() {
this.aborted_ = true;
if (this.xhr_) {
this.xhr_.abort();
}
};
/**
* Loads an image using a OAuth2 token. If it fails, then tries to retry with
* a refreshed OAuth2 token.
*
* @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.
*/
AuthorizedXHR.prototype.load = function(url, onSuccess, onFailure) {
this.aborted_ = false;
// Do not call any callbacks when aborting.
var onMaybeSuccess = /** @type {function(string, Blob)} */ (
function(contentType, response) {
// When content type is not available, try to estimate it from url.
if (!contentType) {
contentType = AuthorizedXHR.ExtensionContentTypeMap[
this.extractExtension_(url)];
}
if (!this.aborted_) {
onSuccess(contentType, response);
}
}.bind(this));
var onMaybeFailure = /** @type {function(number=)} */ (function(opt_code) {
if (!this.aborted_) {
onFailure();
}
}.bind(this));
// Fetches the access token and makes an authorized call. If refresh is true,
// then forces refreshing the access token.
var requestTokenAndCall = function(refresh, onInnerSuccess, onInnerFailure) {
chrome.fileManagerPrivate.requestAccessToken(refresh, function(token) {
if (this.aborted_) {
return;
}
if (!token) {
onInnerFailure();
return;
}
this.xhr_ = AuthorizedXHR.load_(
token, url, onInnerSuccess, onInnerFailure);
}.bind(this));
}.bind(this);
// Refreshes the access token and retries the request.
var maybeRetryCall = function(code) {
if (this.aborted_) {
return;
}
requestTokenAndCall(true, onMaybeSuccess, onMaybeFailure);
}.bind(this);
// Do not request a token for local resources, since it is not necessary.
if (/^filesystem:/.test(url)) {
// The query parameter is workaround for
// crbug.com/379678, which force to obtain the latest contents of the image.
var noCacheUrl = url + '?nocache=' + Date.now();
this.xhr_ = AuthorizedXHR.load_(
null,
noCacheUrl,
onMaybeSuccess,
onMaybeFailure);
return;
}
// Make the request with reusing the current token. If it fails, then retry.
requestTokenAndCall(false, onMaybeSuccess, maybeRetryCall);
};
/**
* Extracts extension from url.
* @param {string} url Url.
* @return {string} Extracted extensiion, e.g. png.
*/
AuthorizedXHR.prototype.extractExtension_ = function(url) {
var result = (/\.([a-zA-Z]+)$/i).exec(url);
return result ? result[1] : '';
};
/**
* Fetches data using authorized XmlHttpRequest with the provided OAuth2 token.
* If the token is invalid, the request will fail.
*
* @param {?string} token OAuth2 token to be injected to the request. Null for
* no token.
* @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
*/
AuthorizedXHR.load_ = function(token, url, onSuccess, onFailure) {
let xhr = new XMLHttpRequest();
xhr.responseType = 'blob';
xhr.onreadystatechange = function() {
if (xhr.readyState != 4) {
return;
}
if (xhr.status != 200) {
onFailure(xhr.status);
return;
}
let response = /** @type {Blob} */ (xhr.response);
let contentType = xhr.getResponseHeader('Content-Type') || response.type;
onSuccess(contentType, response);
}.bind(this);
// Perform a xhr request.
try {
xhr.open('GET', url, true);
if (token) {
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
}
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
*/
ImageRequest.prototype.sendImage_ = function(imageChanged) {
var imageData;
var width;
var height;
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).
imageData = this.image_.src;
width = this.image_.width;
height = this.image_.height;
} 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/gif':
case 'image/png':
case 'image/svg':
case 'image/bmp':
imageData = this.canvas_.toDataURL('image/png');
break;
case 'image/jpeg':
default:
imageData = this.canvas_.toDataURL('image/jpeg', 0.9);
}
}
// Send and store in the persistent cache.
this.sendImageData_(imageData, width, height);
this.saveToCache_(imageData, width, height);
};
/**
* Sends the resized image via the callback.
* @param {string} data Compressed image data.
* @param {number} width Width.
* @param {number} height Height.
* @private
*/
ImageRequest.prototype.sendImageData_ = function(data, width, height) {
this.sendResponse_(new LoadImageResponse(
LoadImageResponseStatus.SUCCESS, this.getClientTaskId(),
{width: width, height: height, data: data}));
};
/**
* Handler, when contents are loaded into the image element. Performs resizing
* and finalizes the request process.
* @private
*/
ImageRequest.prototype.onImageLoad_ = function() {
// Perform processing if the url is not a data url, or if there are some
// operations requested.
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_);
ImageLoaderUtil.convertColorSpace(
this.canvas_, this.request_.colorSpace || ColorSpace.SRGB);
this.sendImage_(true); // Image changed.
} else {
this.sendImage_(false); // Image not changed.
}
this.cleanup_();
this.downloadCallback_();
};
/**
* Handler, when loading of the image fails. Sends a failure response and
* finalizes the request process.
* @private
*/
ImageRequest.prototype.onImageError_ = function() {
this.sendResponse_(new LoadImageResponse(
LoadImageResponseStatus.ERROR, this.getClientTaskId()));
this.cleanup_();
this.downloadCallback_();
};
/**
* Cancels the request.
*/
ImageRequest.prototype.cancel = function() {
this.cleanup_();
// If downloading has started, then call the callback.
if (this.downloadCallback_) {
this.downloadCallback_();
}
};
/**
* Cleans up memory used by this request.
* @private
*/
ImageRequest.prototype.cleanup_ = function() {
this.image_.onerror = function() {};
this.image_.onload = function() {};
// Transparent 1x1 pixel gif, to force garbage collecting.
this.image_.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAA' +
'ABAAEAAAICTAEAOw==';
this.xhr_.abort();
// Dispose memory allocated by Canvas.
this.canvas_.width = 0;
this.canvas_.height = 0;
};