Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
1977 lines
64 KiB
JavaScript
Executable file
1977 lines
64 KiB
JavaScript
Executable file
// This file contains Gallery code for editing images
|
|
//
|
|
'use strict';
|
|
/* global
|
|
$,
|
|
CONFIG_MAX_EDIT_PIXEL_SIZE,
|
|
CONFIG_MAX_IMAGE_PIXEL_SIZE,
|
|
cropResizeRotate,
|
|
currentFileIndex,
|
|
Downsample,
|
|
editedPhotoIndex:true,
|
|
GestureDetector,
|
|
ImageData,
|
|
ImageProcessorThread,
|
|
files,
|
|
justSavedEditedImage:true,
|
|
KeyEvent,
|
|
LAYOUT_MODE,
|
|
photodb,
|
|
setView,
|
|
showFile,
|
|
Spinner,
|
|
LazyLoader,
|
|
JPEGParser
|
|
*/
|
|
/* exported
|
|
editPhotoIfCardNotFull
|
|
*/
|
|
|
|
var editedPhotoURL; // The blob URL of the photo we're currently editing
|
|
var editSettings; // Settings object to perform edits and initialize the UI
|
|
var imageEditor;
|
|
var currentEditTool;
|
|
var savedEditSettings; // Object to save editSettings before entering edit tool
|
|
|
|
var editOptionButtons =
|
|
Array.slice($('edit-options').querySelectorAll('a.radio.button'), 0);
|
|
|
|
var editBgImageButtons =
|
|
Array.slice($('edit-options').querySelectorAll('a.bgimage.button'), 0);
|
|
|
|
var editAspectMoreButtons =
|
|
Array.slice($('aspect-ratio-options-view').querySelectorAll('button'), 0);
|
|
|
|
// Edit mode event handlers
|
|
$('edit-exposure-button').onclick = setEditTool.bind(null, 'exposure');
|
|
$('edit-crop-button').onclick = setEditTool.bind(null, 'crop');
|
|
$('edit-effect-button').onclick = setEditTool.bind(null, 'effect');
|
|
$('edit-enhance-button').onclick = setEditTool.bind(null, 'enhance');
|
|
$('edit-header').addEventListener('action', onCancelButton);
|
|
$('edit-tool-apply-button').onclick = applyEditTool;
|
|
$('edit-save-button').onclick = saveEditedImage;
|
|
|
|
editOptionButtons.forEach(function(b) { b.onclick = editOptionsHandler; });
|
|
editAspectMoreButtons.forEach(function(b) {b.onclick = editAspectMoreHandler;});
|
|
|
|
/*
|
|
* These arrays represent matricies for image processing effects. They
|
|
* are passed to the ImageProcessor.setMatrix() method to specify color
|
|
* transformations of an image. The property names in this object are
|
|
* linked to the Gallery editor UX: if they are changed here, they also
|
|
* must be changed in the HTML.
|
|
*/
|
|
var Effects = {
|
|
none: null,
|
|
bw: [
|
|
0.65, 0.25, 0.10, 0,
|
|
0.65, 0.25, 0.10, 0,
|
|
0.65, 0.25, 0.10, 0
|
|
],
|
|
sepia: [
|
|
0.393, 0.769, 0.189, 0,
|
|
0.349, 0.686, 0.168, 0,
|
|
0.272, 0.534, 0.131, 0
|
|
],
|
|
bluesteel: [
|
|
1, 0.25, 0.65, 0,
|
|
0.1, 1, 0.65, 0,
|
|
0.1, 0.25, 1, 0.1
|
|
],
|
|
faded: [
|
|
1, 0.2, 0.2, 0.03,
|
|
0.2, 0.7, 0.2, 0.05,
|
|
0.1, 0, 0.8, 0
|
|
]
|
|
};
|
|
|
|
// Ensure there is enough space to store an edited copy of photo n
|
|
// and if there is, call editPhoto to do so
|
|
function editPhotoIfCardNotFull(n) {
|
|
var fileinfo = files[n];
|
|
var imagesize = fileinfo.size;
|
|
photodb.freeSpace(function(freespace) {
|
|
// the edited image might take up more space on the disk, but
|
|
// not all that much more
|
|
if (freespace > imagesize * 2) {
|
|
editPhoto(n);
|
|
}
|
|
else {
|
|
document.l10n.formatValue('memorycardfull').then(alert);
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* This is the exposure slider component for edit tool. This ought to be
|
|
* converted into a reusable slider module, but for now this is a
|
|
* custom version that hardcodes things like the -3 to +3 range of values.
|
|
*/
|
|
var exposureSlider = (function() {
|
|
var slider = document.getElementById('exposure-slider');
|
|
var bar = document.getElementById('sliderline');
|
|
var thumb = document.getElementById('sliderthumb');
|
|
var currentExposure = 0;
|
|
var dragStart = null;
|
|
// prepare gesture detector for slider
|
|
var gestureDetector = new GestureDetector(thumb);
|
|
gestureDetector.startDetecting();
|
|
|
|
thumb.addEventListener('pan', function(e) {
|
|
// Handle delta so that slider moves correct way
|
|
// when user drags it for RTL locales
|
|
var delta = document.documentElement.dir === 'ltr' ?
|
|
e.detail.absolute.dx : - e.detail.absolute.dx;
|
|
|
|
var exposureDelta = delta / parseInt(bar.clientWidth, 10) * 6;
|
|
// For the firt time of pan event triggered
|
|
// set start value to current value.
|
|
if (!dragStart) {
|
|
dragStart = currentExposure;
|
|
}
|
|
|
|
setExposure(dragStart + exposureDelta);
|
|
e.preventDefault();
|
|
});
|
|
thumb.addEventListener('swipe', function(e) {
|
|
// when stopping we init the dragStart to be null
|
|
// this way we avoid a 'panstart' event
|
|
dragStart = null;
|
|
e.preventDefault();
|
|
});
|
|
thumb.addEventListener('touchstart', function(e) {
|
|
thumb.classList.add('active');
|
|
});
|
|
thumb.addEventListener('touchend', function(e) {
|
|
thumb.classList.remove('active');
|
|
});
|
|
slider.addEventListener('keypress', function(e) {
|
|
// screen reader sends key arrow up/down events for adjusting the slider.
|
|
if (e.keyCode == KeyEvent.DOM_VK_DOWN) {
|
|
setExposure(currentExposure - 0.25);
|
|
} else if (e.keyCode == KeyEvent.DOM_VK_UP) {
|
|
setExposure(currentExposure + 0.25);
|
|
}
|
|
});
|
|
|
|
function resize() {
|
|
forceSetExposure(currentExposure);
|
|
}
|
|
|
|
// Set the thumb position between -3 and +3
|
|
function setExposure(exposure) {
|
|
// Make sure it is not out of bounds
|
|
if (exposure < -3) {
|
|
exposure = -3;
|
|
} else if (exposure > 3) {
|
|
exposure = 3;
|
|
}
|
|
|
|
// Round to the closest sixteenth
|
|
exposure = Math.round(exposure * 16) / 16;
|
|
|
|
if (exposure === currentExposure) {
|
|
return;
|
|
}
|
|
|
|
forceSetExposure(exposure);
|
|
}
|
|
|
|
function forceSetExposure(exposure) {
|
|
// Remove left/right 4 pixels of margin
|
|
var barWidth = parseInt(bar.clientWidth, 10) - 4 * 2;
|
|
var thumbWidth = parseInt(thumb.clientWidth, 10);
|
|
var offset = bar.offsetLeft + 4;
|
|
|
|
// Convert exposure value to a unit coefficient position of thumb center
|
|
var unitCoef = (exposure + 3) / 6;
|
|
|
|
// Convert unitCoef to pixel position of thumb center
|
|
var pixel = offset + barWidth * unitCoef;
|
|
|
|
// Compute pixel position of left edge of thumb
|
|
pixel -= thumbWidth / 2;
|
|
|
|
// Move the thumb to that position
|
|
thumb.style.MozMarginStart = pixel + 'px';
|
|
|
|
// Display exposure value in thumb
|
|
thumb.textContent = exposure;
|
|
|
|
// Don't need to update the currentExposure and dispatch the event if they
|
|
// are the same.
|
|
if (currentExposure === exposure) {
|
|
return;
|
|
}
|
|
// Remember the new exposure value
|
|
currentExposure = exposure;
|
|
|
|
// Set value for ARIA widget
|
|
slider.setAttribute('aria-valuenow', exposure);
|
|
|
|
// Dispatch an event to actually change the image
|
|
slider.dispatchEvent(new Event('change', {bubbles: true}));
|
|
}
|
|
|
|
return {
|
|
resize: resize,
|
|
forceSetExposure: forceSetExposure,
|
|
setExposure: setExposure,
|
|
getExposure: function() { return currentExposure; }
|
|
};
|
|
})();
|
|
|
|
function resizeHandler() {
|
|
exposureSlider.resize();
|
|
imageEditor.resize();
|
|
}
|
|
|
|
function localizeHandler() {
|
|
// If the locale changes while user is using exposure edit option,
|
|
// then we need to re-position slider thumb for respective locale.
|
|
exposureSlider.forceSetExposure(exposureSlider.getExposure());
|
|
}
|
|
|
|
// Handle changes on the slider and turn them into calls to edit the image
|
|
(function() {
|
|
var requestId = null;
|
|
|
|
$('exposure-slider').onchange = function() {
|
|
if (!imageEditor) { // If it has not been created yet
|
|
return;
|
|
}
|
|
|
|
// If there is already a pending request, it will take care of this change
|
|
// and we don't have to request a new one.
|
|
if (requestId !== null) {
|
|
return;
|
|
}
|
|
|
|
requestId = requestAnimationFrame(function() {
|
|
requestId = null;
|
|
var stops = exposureSlider.getExposure();
|
|
|
|
// Convert the exposure compensation stops gamma correction value.
|
|
var factor = -1; // Adjust this factor to get something reasonable.
|
|
var gamma = Math.pow(2, stops * factor);
|
|
editSettings.exposure.gamma = gamma;
|
|
editSettings.exposure.sliderThumbPos = stops;
|
|
enableSaveAndApplyButtons();
|
|
imageEditor.edit();
|
|
});
|
|
};
|
|
}());
|
|
|
|
function editPhoto(n) {
|
|
editedPhotoIndex = n;
|
|
var metadata = files[n].metadata;
|
|
|
|
editSettings = {
|
|
crop: {
|
|
x: 0, y: 0, w: metadata.width, h: metadata.height,
|
|
cropModeId: 'edit-crop-aspect-free' // selected crop mode element Id
|
|
},
|
|
exposure: {
|
|
sliderThumbPos: 0, // Slider thumb position
|
|
gamma: 1
|
|
},
|
|
effect: {
|
|
effectId: 'edit-effect-none', // selected effect mode element id
|
|
matrix: null
|
|
},
|
|
enhance: {
|
|
autoLevels: null
|
|
},
|
|
isImageEdited: false // flag to store if first edit has occurred
|
|
};
|
|
|
|
// Start looking up the image file
|
|
photodb.getFile(files[n].name, gotFile);
|
|
|
|
function gotFile(file) {
|
|
// The image editor does not handle EXIF rotation, so if the image has
|
|
// EXIF orientation flags, we alter the image in place before starting
|
|
// to edit it. Similarly, if the image is too big for us to decode at
|
|
// full size we need to create a downsampled version that is editable.
|
|
// For low-memory devices like Tarako, CONFIG_MAX_EDIT_PIXEL_SIZE will
|
|
// be set to a non-zero value, and this may cause us to downsample the
|
|
// image even further than we would otherwise.
|
|
var imagesize = metadata.width * metadata.height;
|
|
var maxsize = CONFIG_MAX_EDIT_PIXEL_SIZE || CONFIG_MAX_IMAGE_PIXEL_SIZE;
|
|
|
|
if (file.type === 'image/jpeg') {
|
|
LazyLoader.load(['shared/js/media/jpeg-exif.js'], () => {
|
|
JPEGParser.readExifMetaData(file, (error, metaData) => {
|
|
editSettings.EXIF = metaData || {};
|
|
});
|
|
});
|
|
}
|
|
|
|
if (metadata.rotation || metadata.mirrored || imagesize > maxsize) {
|
|
Spinner.show();
|
|
cropResizeRotate(file, null, maxsize || null,
|
|
null, metadata,
|
|
function(error, rotatedBlob) {
|
|
Spinner.hide();
|
|
if (error) {
|
|
console.error('Error while rotating image:', error);
|
|
rotatedBlob = file;
|
|
}
|
|
startEdit(rotatedBlob);
|
|
});
|
|
}
|
|
else {
|
|
startEdit(file);
|
|
}
|
|
}
|
|
|
|
// This will be called with the file the user is editing or with a rotated
|
|
// version of that file
|
|
function startEdit(blob) {
|
|
// Create the image editor object
|
|
// This has to come after setView or the canvas size is wrong.
|
|
imageEditor = new ImageEditor(blob,
|
|
$('edit-preview-area'),
|
|
editSettings);
|
|
|
|
// Show editor screen and hide edit tool screen
|
|
hideEditToolView();
|
|
currentEditTool = null;
|
|
|
|
// Set auto enhance icon to default off state
|
|
var editEnhanceButton = $('edit-enhance-button');
|
|
editEnhanceButton.setAttribute('aria-pressed', false);
|
|
editEnhanceButton.classList.remove('on');
|
|
// Set edit screen header title
|
|
$('edit-title').setAttribute('data-l10n-id', 'edit');
|
|
// Disable save and edit tool apply button until an edit is applied
|
|
$('edit-save-button').disabled = true;
|
|
$('edit-tool-apply-button').disabled = true;
|
|
|
|
window.addEventListener('resize', resizeHandler);
|
|
window.addEventListener('localized', localizeHandler);
|
|
|
|
// Set the background for all of the image buttons
|
|
editedPhotoURL = URL.createObjectURL(blob);
|
|
|
|
// Use #-moz-samplesize media fragment to downsample images
|
|
// so the resulting images are smaller and fits 5 image buttons
|
|
// Here we assume image buttons are 50px high
|
|
var scale = window.innerWidth / 5 * window.devicePixelRatio *
|
|
window.devicePixelRatio * 50 /
|
|
(metadata.width * metadata.height);
|
|
var sampleSize = Downsample.areaNoMoreThan(scale);
|
|
|
|
var backgroundImage = 'url(' + editedPhotoURL + sampleSize + ')';
|
|
editBgImageButtons.forEach(function(b) {
|
|
b.style.backgroundImage = backgroundImage;
|
|
});
|
|
}
|
|
|
|
// Display the edit screen
|
|
setView(LAYOUT_MODE.edit);
|
|
|
|
// Set the default option buttons to correspond to those edits
|
|
editOptionButtons.forEach(function(b) { selectEditOption(b, false); });
|
|
selectEditOption($('edit-crop-aspect-free'), true);
|
|
selectEditOption($('edit-effect-none'), true);
|
|
}
|
|
|
|
function selectEditOption(radio, selected) {
|
|
radio.classList.toggle('selected', selected);
|
|
radio.setAttribute('aria-checked', selected);
|
|
}
|
|
|
|
// Stores last selected aspect ratio in crop options. This variable
|
|
// is used to handle cancel in aspect more options view.
|
|
var editCropSelectedAspectRatio;
|
|
|
|
// Crop and Effect buttons call this
|
|
function editOptionsHandler() {
|
|
/* jshint validthis: true */
|
|
// First, unhighlight all buttons in this group and then
|
|
// highlight the button that has just been chosen. These
|
|
// buttons have radio behavior
|
|
var parent = this.parentNode;
|
|
var buttons = parent.querySelectorAll('a.radio.button');
|
|
Array.forEach(buttons, function(b) { selectEditOption(b, false); });
|
|
selectEditOption(this, true);
|
|
|
|
// Set selected effect or cropMode elementId in editSettings object
|
|
// to initialize UI on re-enter of an editMode
|
|
if (this.dataset.effect) {
|
|
editSettings.effect.effectId =
|
|
$('edit-effect-options').getElementsByClassName('selected')[0].id;
|
|
} else {
|
|
editCropSelectedAspectRatio = editSettings.crop.cropModeId;
|
|
editSettings.crop.cropModeId =
|
|
$('edit-crop-options').getElementsByClassName('selected')[0].id;
|
|
}
|
|
|
|
if (this === $('edit-crop-aspect-free')) {
|
|
imageEditor.setCropAspectRatio();
|
|
} else if (this === $('edit-crop-aspect-portrait')) {
|
|
imageEditor.setCropAspectRatio(2, 3);
|
|
} else if (this === $('edit-crop-aspect-landscape')) {
|
|
imageEditor.setCropAspectRatio(3, 2);
|
|
} else if (this === $('edit-crop-aspect-square')) {
|
|
imageEditor.setCropAspectRatio(1, 1);
|
|
} else if (this === $('edit-crop-aspect-more')) {
|
|
$('aspect-ratio-options-view').show();
|
|
}
|
|
else if (this.dataset.effect) {
|
|
editSettings.effect.matrix = Effects[this.dataset.effect];
|
|
imageEditor.edit();
|
|
}
|
|
enableSaveAndApplyButtons();
|
|
}
|
|
|
|
function editAspectMoreHandler() {
|
|
/* jshint validthis: true */
|
|
if (this === $('edit-crop-aspect-3x4')) {
|
|
imageEditor.setCropAspectRatio(3, 4);
|
|
} else if (this === $('edit-crop-aspect-4x3')) {
|
|
imageEditor.setCropAspectRatio(4, 3);
|
|
} else if (this === $('edit-crop-aspect-16x9')) {
|
|
imageEditor.setCropAspectRatio(16, 9);
|
|
} else if (this === $('aspect-ratio-options-cancel')) {
|
|
// On cancel in aspect more options view, update selected crop mode id
|
|
// in editSettings object and highlight previous selected option node.
|
|
selectEditOption($('edit-crop-aspect-more'), false);
|
|
selectEditOption($(editCropSelectedAspectRatio), true);
|
|
editSettings.crop.cropModeId = editCropSelectedAspectRatio;
|
|
}
|
|
enableSaveAndApplyButtons();
|
|
$('aspect-ratio-options-view').hide();
|
|
}
|
|
|
|
// Set editTool to respective edit modes on click
|
|
// of crop, exposure, effect, enhance.
|
|
function setEditTool(editTool) {
|
|
// Hide all options
|
|
var options = $('edit-options').querySelectorAll('div.edit-options-bar');
|
|
Array.forEach(options, function(o) { o.classList.add('hidden'); });
|
|
|
|
// Hide auto enhance message banner before entering
|
|
// an edit tool or cancel of edit screens.
|
|
$('edit-enhance-banner').hidden = true;
|
|
|
|
// Make a copy of editSettings when you enter edit tool
|
|
savedEditSettings = clone(editSettings);
|
|
|
|
// Now select and show the correct UI based on editTool
|
|
switch (editTool) {
|
|
case 'exposure':
|
|
// Show exposure EditTool and hide main edit screen
|
|
showEditToolView();
|
|
$('exposure-slider').classList.remove('hidden');
|
|
$('edit-title').setAttribute('data-l10n-id', 'exposure');
|
|
// Set exposure slider thumb position
|
|
exposureSlider.forceSetExposure(editSettings.exposure.sliderThumbPos);
|
|
break;
|
|
case 'crop':
|
|
showEditToolView();
|
|
$('edit-crop-options').classList.remove('hidden');
|
|
$('edit-title').setAttribute('data-l10n-id', 'crop');
|
|
// Initialize the source rectangle to full size of the original image
|
|
imageEditor.resetCropRegion();
|
|
imageEditor.resetPreview();
|
|
// Show screen with crop overlay on entering crop edit tool
|
|
imageEditor.edit(function() {
|
|
// Use saved crop region in editSettings to show crop overlay
|
|
imageEditor.showCropOverlay(
|
|
imageEditor.getCropOverlayRegion(savedEditSettings.crop));
|
|
});
|
|
break;
|
|
case 'effect':
|
|
showEditToolView();
|
|
$('edit-effect-options').classList.remove('hidden');
|
|
$('edit-title').setAttribute('data-l10n-id', 'filters');
|
|
break;
|
|
case 'enhance':
|
|
imageEditor.autoEnhancement(setAutoEnhanceState);
|
|
enableSaveAndApplyButtons();
|
|
break;
|
|
}
|
|
// Remember the new edit mode
|
|
currentEditTool = editTool;
|
|
}
|
|
|
|
function cancelEditTool() {
|
|
// Do any necessary cleanup of the currentEditTool view we're exiting.
|
|
switch (currentEditTool) {
|
|
case 'exposure':
|
|
// On cancel in exposure EditTool restore exposure state
|
|
// inside editsettings object.
|
|
editSettings.exposure = savedEditSettings.exposure;
|
|
break;
|
|
case 'crop':
|
|
// On cancel in Crop Edit Tool, restore crop state in editSettings object
|
|
// and revert selected option node.
|
|
selectEditOption($(editSettings.crop.cropModeId), false);
|
|
selectEditOption($(savedEditSettings.crop.cropModeId), true);
|
|
|
|
editSettings.crop = savedEditSettings.crop;
|
|
// Set source rectangle size from saved editSettings
|
|
// to go back to previous cropped size preview.
|
|
imageEditor.forceSetCropRegion();
|
|
// Discard changes and revert to previous crop sizes
|
|
imageEditor.resetPreview();
|
|
break;
|
|
case 'effect':
|
|
// On cancel in Effect Edit Tool, restore effect state
|
|
// in editSettings object and revert selected option node.
|
|
selectEditOption($(editSettings.effect.effectId), false);
|
|
selectEditOption($(savedEditSettings.effect.effectId), true);
|
|
editSettings.effect = savedEditSettings.effect;
|
|
break;
|
|
}
|
|
// Revert to main edit screen on edit tool cancel
|
|
// Update preview image with editSettings object
|
|
imageEditor.edit(function() {
|
|
imageEditor.hideCropOverlay();
|
|
});
|
|
$('edit-title').setAttribute('data-l10n-id', 'edit');
|
|
// Hide Edit Tool and show main edit screen
|
|
hideEditToolView();
|
|
currentEditTool = null;
|
|
}
|
|
|
|
// Apply changes made in crop edit tool and go back to edit screen
|
|
// Other edit tools - exposure, effects, enhance apply their edits
|
|
// as the user interacts with the buttons or slider
|
|
function applyEditTool() {
|
|
switch (currentEditTool) {
|
|
case 'crop':
|
|
imageEditor.cropImage(function() {
|
|
imageEditor.hideCropOverlay();
|
|
});
|
|
break;
|
|
}
|
|
$('edit-title').setAttribute('data-l10n-id', 'edit');
|
|
// Hide Edit tool screen
|
|
hideEditToolView();
|
|
// Reset current edit tool and editSettings saved state to null
|
|
currentEditTool = null;
|
|
savedEditSettings = null;
|
|
}
|
|
|
|
function onCancelButton() {
|
|
// Check for currentEditTool to find if user is cancelling out from
|
|
// edit tool screen - crop, effect, exposure or main edit screen
|
|
if (currentEditTool === null || currentEditTool === 'enhance') {
|
|
currentEditTool = null;
|
|
// Exit main edit screen withot saving edited image
|
|
exitEdit(false);
|
|
} else {
|
|
// Cancel edit tool and go back to main edit screen
|
|
cancelEditTool();
|
|
}
|
|
}
|
|
|
|
function enableSaveAndApplyButtons() {
|
|
// Enable Apply and Save buttons if it's first edit and isImageEdited
|
|
// is false. For subesquent edits apply and save button stays enabled.
|
|
if (!editSettings || editSettings.isImageEdited) {
|
|
return;
|
|
}
|
|
if (imageEditor.hasBeenCropped() ||
|
|
editSettings.effect.matrix !== null ||
|
|
editSettings.enhance.autoLevels !== null ||
|
|
editSettings.exposure.gamma !== 1) {
|
|
editSettings.isImageEdited = true;
|
|
$('edit-tool-apply-button').disabled = false;
|
|
$('edit-save-button').disabled = false;
|
|
}
|
|
}
|
|
|
|
// Create deep copy of an object
|
|
function clone(obj) {
|
|
return JSON.parse(JSON.stringify(obj));
|
|
}
|
|
|
|
// Toggle the auto enhancement on/off.
|
|
function setAutoEnhanceState(isEnhanced) {
|
|
var statusLabel = $('edit-enhance-status');
|
|
var enhanceButton = $('edit-enhance-button');
|
|
var banner = $('edit-enhance-banner');
|
|
enhanceButton.setAttribute('aria-pressed', isEnhanced);
|
|
if (isEnhanced) {
|
|
showStatus('enhance-on');
|
|
enhanceButton.classList.add('on');
|
|
} else {
|
|
showStatus('enhance-off');
|
|
enhanceButton.classList.remove('on');
|
|
}
|
|
|
|
function showStatus(msgId) {
|
|
document.l10n.setAttributes(statusLabel, msgId);
|
|
banner.hidden = false;
|
|
setTimeout(function() {
|
|
banner.hidden = true;
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
// Handle show and hide of edit tool screen
|
|
function showEditToolView() {
|
|
// Hide Editor toolbar and save button
|
|
$('edit-toolbar').classList.add('hidden');
|
|
$('edit-save-button').hidden = true;
|
|
|
|
// Show Edit Tool options and apply button
|
|
$('edit-options').classList.remove('hidden');
|
|
$('edit-tool-apply-button').hidden = false;
|
|
}
|
|
|
|
function hideEditToolView() {
|
|
// Show Editor toolbar and save button
|
|
$('edit-toolbar').classList.remove('hidden');
|
|
$('edit-save-button').hidden = false;
|
|
|
|
// Hide Edit Tool options and apply button
|
|
$('edit-options').classList.add('hidden');
|
|
$('edit-tool-apply-button').hidden = true;
|
|
}
|
|
|
|
function exitEdit(saved) {
|
|
// Revoke the blob URL we've been using
|
|
URL.revokeObjectURL(editedPhotoURL);
|
|
editedPhotoURL = null;
|
|
|
|
// Check for imageEditor and close the editor object
|
|
if (imageEditor) {
|
|
imageEditor.destroy();
|
|
imageEditor = null;
|
|
}
|
|
savedEditSettings = null;
|
|
editSettings = null;
|
|
|
|
window.removeEventListener('resize', resizeHandler);
|
|
window.removeEventListener('localized', localizeHandler);
|
|
|
|
// we want the slider re-zeroed for the next time we come into the editor
|
|
exposureSlider.forceSetExposure(0.0);
|
|
|
|
// If exitEdit is called without saved argument e.g. when device storage
|
|
// is unavailable, exit now and do not check for saved.
|
|
if (saved === undefined) {
|
|
return;
|
|
}
|
|
|
|
// We came in to edit mode from fullscreenView. If the user cancels the edit
|
|
// go back to fullscreenView to original image. Otherwise, if the user saves
|
|
// the photo, we should go back to fullscreenView to view the edited photo.
|
|
if (saved) {
|
|
// After we sucessfully save a picture, we need to make sure that the
|
|
// current file will point to it. We need a flag for fileCreated(),
|
|
// so that the currentFileIndex will stay at 0 which is the newest one.
|
|
justSavedEditedImage = true;
|
|
// After insert sucessfully, db will call file created and setFile to
|
|
// latest file, then we go to fullscreen mode to see the edited picture.
|
|
setView(LAYOUT_MODE.fullscreen);
|
|
} else {
|
|
setView(LAYOUT_MODE.fullscreen);
|
|
showFile(currentFileIndex);
|
|
}
|
|
}
|
|
|
|
// When the user clicks the save button, we produce a full-size version
|
|
// of the edited image, save it into the media database and return to
|
|
// photo view mode.
|
|
// XXX: figure out what the image number of the edited photo is or will be
|
|
// and return to viewing that one. Ideally, edited photos would be grouped
|
|
// with the original, rather than by date, but I'm not sure I can
|
|
// do that sort order. Ideally, I'd like the mediadb to not generate a
|
|
// change event when we manually add something to it or at least have that
|
|
// option
|
|
function saveEditedImage() {
|
|
// Save button disabled to prevent the user triggering multiple
|
|
// save operations
|
|
$('edit-save-button').disabled = true;
|
|
|
|
// Check for imageEditor before continuing with save
|
|
if (!imageEditor) {
|
|
return;
|
|
}
|
|
|
|
var progressBar = $('save-progress');
|
|
// Show progressbar when start to save.
|
|
progressBar.classList.remove('hidden');
|
|
progressBar.value = 0;
|
|
progressBar.max = 100;
|
|
|
|
imageEditor.getFullSizeBlob('image/jpeg', gotBlob, onProgress);
|
|
|
|
function onProgress(p, error) {
|
|
// If error occurs, reset the save button for user.
|
|
if (error) {
|
|
$('edit-save-button').disabled = false;
|
|
progressBar.value = 0;
|
|
return;
|
|
}
|
|
|
|
progressBar.value = Math.floor(p * 100);
|
|
}
|
|
|
|
function gotBlob(blob) {
|
|
// Hide progressbar when saved.
|
|
progressBar.classList.add('hidden');
|
|
var original = files[editedPhotoIndex].name;
|
|
var basename, extension, filename;
|
|
var version = 1;
|
|
var p = original.lastIndexOf('.');
|
|
if (p === -1) {
|
|
basename = original;
|
|
extension = '';
|
|
}
|
|
else {
|
|
basename = original.substring(0, p);
|
|
extension = original.substring(p);
|
|
}
|
|
|
|
// Create a filename for the edited image. Loop if necessary and
|
|
// increment the version number until we find a version a name that
|
|
// is not in use.
|
|
// XXX: this loop is O(n^2) and slow if the user saves many edits
|
|
// of the same image.
|
|
filename = basename + '.edit' + version + extension;
|
|
/* jshint loopfunc: true */
|
|
while (files.some(function(i) { return i.name === filename; })) {
|
|
version++;
|
|
filename = basename + '.edit' + version + extension;
|
|
}
|
|
|
|
// Now that we have a filename, save the file This will send a
|
|
// change event, which will cause us to rebuild our thumbnails.
|
|
// For now, the edited image will become the first thumbnail since
|
|
// it si the most recent one. Ideally, I'd like a more
|
|
// sophisticated sort order that put edited sets of photos next to
|
|
// each other.
|
|
photodb.addFile(filename, blob);
|
|
|
|
// We're done.
|
|
exitEdit(true);
|
|
progressBar.value = 0;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* ImageEditor.js: simple image editing and previews in a <canvas> element.
|
|
*
|
|
* Display an edited version of the specified image in a <canvas> element
|
|
* inside the specified container element. The image (or cropped region of
|
|
* the image) will be displayed as large as possible within the container's
|
|
* area. Edits is an object that specifies the edits to apply to the image.
|
|
* The edits object may include these properties:
|
|
*
|
|
* gamma: a float specifying gamma correction
|
|
* matrix: a 3x4 matrix that represents a transformation of each rgba pixel.
|
|
* this can be used to convert to bw or sepia, for example.
|
|
*
|
|
* In addition to previewing the image, this class also defines a
|
|
* getFullSizeBlob() function that creates a full-size version of the
|
|
* edited image.
|
|
*
|
|
* This class also handles cropping. See:
|
|
*
|
|
* showCropOverlay()
|
|
* hideCropOverlay()
|
|
* cropImage()
|
|
* undoCrop()
|
|
*
|
|
* The stylesheet includes static styles to position those dyanamic elements.
|
|
*/
|
|
function ImageEditor(imageBlob, container, edits, ready, croponly) {
|
|
this.imageBlob = imageBlob;
|
|
this.imageURL = URL.createObjectURL(imageBlob);
|
|
this.container = container;
|
|
this.edits = edits || {};
|
|
this.croponly = !!croponly;
|
|
|
|
// The source rectangle (crop region) of the image. Image pixels.
|
|
this.source = {};
|
|
// The destination (preview) rectangle of canvas. Device pixels.
|
|
this.dest = {};
|
|
// Region displayed in crop overlay during drags. CSS pixels.
|
|
this.cropOverlayRegion = {};
|
|
|
|
// The canvas that displays the preview
|
|
this.previewCanvas = document.createElement('canvas');
|
|
this.previewCanvas.id = 'edit-preview-canvas'; // for stylesheet
|
|
this.previewCanvas.setAttribute('data-l10n-id', 'editImagePreview');
|
|
this.previewCanvas.setAttribute('role', 'img');
|
|
this.container.appendChild(this.previewCanvas);
|
|
|
|
// Make sure the canvas is size for device pixels, not css pixels
|
|
var dpr = window.devicePixelRatio;
|
|
this.previewCanvas.width = Math.ceil(this.previewCanvas.clientWidth * dpr);
|
|
this.previewCanvas.height = Math.ceil(this.previewCanvas.clientHeight * dpr);
|
|
|
|
// The raw pixels that we edit and then display in the preview canvas
|
|
this.previewImageData = null;
|
|
|
|
// Auto enhancement parameters generated from the preview image data
|
|
this.autoLevels = null;
|
|
|
|
// prepare gesture detector for ImageEditor
|
|
this.gestureDetector = new GestureDetector(container, { panThreshold: 3 });
|
|
this.gestureDetector.startDetecting();
|
|
|
|
// preset the scale to something useful in case resize() gets called
|
|
// before generateNewPreview()
|
|
this.scale = 1.0;
|
|
|
|
var self = this;
|
|
if (this.croponly) {
|
|
this.original = null;
|
|
this.preview = new Image();
|
|
this.preview.src = this.imageURL;
|
|
this.preview.onload = function() {
|
|
self.displayCropOnlyPreview();
|
|
if (ready) {
|
|
ready();
|
|
}
|
|
};
|
|
}
|
|
else {
|
|
// Start loading the image into a full-size offscreen image
|
|
this.original = new Image();
|
|
this.original.src = this.imageURL;
|
|
|
|
// When the image loads display it
|
|
this.original.onload = function() {
|
|
// Initialize the crop region to the full size of the original image
|
|
self.resetCropRegion();
|
|
self.resetPreview();
|
|
|
|
// Display an edited preview of it
|
|
self.edit(function() {
|
|
// If the constructor had a ready callback argument, call it now
|
|
if (ready) {
|
|
ready();
|
|
}
|
|
});
|
|
};
|
|
}
|
|
}
|
|
|
|
ImageEditor.prototype.displayCropOnlyPreview = function() {
|
|
var previewContext = this.previewCanvas.getContext('2d');
|
|
|
|
var scalex = this.previewCanvas.width / this.preview.width;
|
|
var scaley = this.previewCanvas.height / this.preview.height;
|
|
var scale = Math.min(Math.min(scalex, scaley), 1);
|
|
|
|
var previewWidth = Math.floor(this.preview.width * scale);
|
|
var previewHeight = Math.floor(this.preview.height * scale);
|
|
var previewX = Math.floor((this.previewCanvas.width - previewWidth) / 2);
|
|
var previewY =
|
|
Math.floor((this.previewCanvas.height - previewHeight) / 2);
|
|
|
|
previewContext.drawImage(this.preview,
|
|
previewX, previewY,
|
|
previewWidth, previewHeight);
|
|
|
|
this.scale = scale;
|
|
this.dest.x = previewX;
|
|
this.dest.y = previewY;
|
|
this.dest.width = previewWidth;
|
|
this.dest.height = previewHeight;
|
|
};
|
|
|
|
ImageEditor.prototype.generateNewPreview = function(callback) {
|
|
// infer the previewHeight such that the aspect ratio stays the same
|
|
var scalex = this.previewCanvas.width / this.source.width;
|
|
var scaley = this.previewCanvas.height / this.source.height;
|
|
this.scale = Math.min(Math.min(scalex, scaley), 1);
|
|
|
|
var previewWidth = Math.floor(this.source.width * this.scale);
|
|
var previewHeight = Math.floor(this.source.height * this.scale);
|
|
|
|
// Update edits with new dimensions
|
|
this.edits.crop.x = this.source.x;
|
|
this.edits.crop.y = this.source.y;
|
|
this.edits.crop.w = this.source.width;
|
|
this.edits.crop.h = this.source.height;
|
|
|
|
// Update the destination rectangle with the new dimensions, too
|
|
this.dest.x = Math.floor((this.previewCanvas.width - previewWidth) / 2);
|
|
this.dest.y = Math.floor((this.previewCanvas.height - previewHeight) / 2);
|
|
this.dest.width = previewWidth;
|
|
this.dest.height = previewHeight;
|
|
|
|
// Create a preview image
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = previewWidth;
|
|
canvas.height = previewHeight;
|
|
// In this case, we only need graphic 2d to do the scaling. The
|
|
// willReadFrequently option makes canvas use software graphic 2d. This can
|
|
// skip a bug of GPU version, bug 960276.
|
|
var context = canvas.getContext('2d', { willReadFrequently: true });
|
|
|
|
// Draw that region of the image into the canvas, scaling it down
|
|
context.drawImage(this.original, this.source.x, this.source.y,
|
|
this.source.width, this.source.height,
|
|
0, 0, previewWidth, previewHeight);
|
|
|
|
this.previewImageData = context.getImageData(0, 0,
|
|
previewWidth, previewHeight);
|
|
|
|
// If we don't have an image processor yet, or if the processor
|
|
// can't handle an image of this size, create one now.
|
|
if (!this.processor ||
|
|
this.processor.maxPixels < previewWidth * previewHeight)
|
|
{
|
|
// If we already had a processor, destroy it
|
|
if (this.processor) {
|
|
this.processor.destroy();
|
|
}
|
|
|
|
this.processor = new ImageProcessorThread(previewWidth * previewHeight);
|
|
}
|
|
|
|
if (callback) {
|
|
callback();
|
|
}
|
|
};
|
|
|
|
ImageEditor.prototype.resetPreview = function() {
|
|
if (this.previewImageData) {
|
|
this.previewImageData = null;
|
|
this.autoLevels = null;
|
|
}
|
|
};
|
|
|
|
ImageEditor.prototype.resize = function() {
|
|
var self = this;
|
|
var canvas = this.previewCanvas;
|
|
var dpr = window.devicePixelRatio;
|
|
canvas.width = Math.ceil(canvas.clientWidth * dpr);
|
|
canvas.height = Math.ceil(canvas.clientHeight * dpr);
|
|
|
|
// Save the crop region (scaled up to full image dimensions)
|
|
var savedCropRegion = {};
|
|
var hadCropOverlay = this.isCropOverlayShown();
|
|
if (hadCropOverlay) {
|
|
savedCropRegion.left = this.cropOverlayRegion.left / this.scale;
|
|
savedCropRegion.top = this.cropOverlayRegion.top / this.scale;
|
|
savedCropRegion.right = this.cropOverlayRegion.right / this.scale;
|
|
savedCropRegion.bottom = this.cropOverlayRegion.bottom / this.scale;
|
|
this.hideCropOverlay();
|
|
}
|
|
|
|
// Now update the preview image. This is done differently in the
|
|
// croponly case and in the regular image editing case
|
|
if (this.croponly) {
|
|
this.displayCropOnlyPreview();
|
|
restoreCropRegion();
|
|
}
|
|
else {
|
|
this.resetPreview();
|
|
this.edit(restoreCropRegion);
|
|
}
|
|
|
|
// Restore the crop region to what it was before the resize
|
|
function restoreCropRegion() {
|
|
if (hadCropOverlay) {
|
|
// showCropOverlay normally resets cropRegion to the full extent,
|
|
// so we need to pass in a new crop region to use
|
|
var newRegion = {};
|
|
newRegion.left = Math.floor(savedCropRegion.left * self.scale);
|
|
newRegion.top = Math.floor(savedCropRegion.top * self.scale);
|
|
newRegion.right = Math.floor(savedCropRegion.right * self.scale);
|
|
newRegion.bottom = Math.floor(savedCropRegion.bottom * self.scale);
|
|
self.showCropOverlay(newRegion);
|
|
}
|
|
}
|
|
};
|
|
|
|
ImageEditor.prototype.destroy = function() {
|
|
this.needsAnotherEdit = false;
|
|
|
|
if (this.original) {
|
|
this.original.src = '';
|
|
this.original = null;
|
|
}
|
|
|
|
this.previewImageData = null;
|
|
|
|
URL.revokeObjectURL(this.imageURL);
|
|
|
|
if (this.previewCanvas) {
|
|
this.container.removeChild(this.previewCanvas);
|
|
// Setting the canvas size to 0 causes a WebGL error so we don't do it
|
|
// this.previewCanvas.width = 0;
|
|
this.previewCanvas = null;
|
|
}
|
|
this.hideCropOverlay();
|
|
this.gestureDetector.stopDetecting();
|
|
this.gestureDetector = null;
|
|
};
|
|
|
|
// Preview the image with the specified edits applied. If edit is omitted,
|
|
// displays the original image. Clients should call this function when the
|
|
// desired edits change or when the size of the container changes (on
|
|
// orientation change events, for example)
|
|
ImageEditor.prototype.edit = function(callback) {
|
|
// A queued preview may finish after the user has left the editor.
|
|
if (!this.previewCanvas) {
|
|
return;
|
|
}
|
|
// If we are already editing the image, then we can't edit again now
|
|
// so instead we'll just set a flag to indicate that the image needs
|
|
// to be editing again. When the current edit ends, we'll check this
|
|
// flag and re-edit as needed. Using this flag means that it is okay
|
|
// to call edit() more quickly than the ImageProcessor can actually
|
|
// process the preview image. If the user drags the exposure slider
|
|
// quickly, for example, we will end up skipping some intermediate
|
|
// gamma values and will not do more image processing than the device
|
|
// can handle.
|
|
if (!callback && this.editing) {
|
|
this.needsAnotherEdit = true;
|
|
return;
|
|
}
|
|
this.editing = true;
|
|
|
|
if (!this.previewImageData) {
|
|
var self = this;
|
|
this.generateNewPreview(function() {
|
|
// If we got a new preview image it may be smaller than
|
|
// the old one so we have to clear the canvas before drawing it
|
|
self.finishEdit(callback, true);
|
|
});
|
|
} else {
|
|
// If we're using the same preview image we don't have to clear
|
|
// first because we will set all of the pixels that are already set.
|
|
this.finishEdit(callback, false);
|
|
}
|
|
};
|
|
|
|
ImageEditor.prototype.finishEdit = function(callback, eraseCanvas) {
|
|
var self = this;
|
|
var canvas = this.previewCanvas;
|
|
var previewContext = canvas.getContext('2d');
|
|
var imagedata = this.previewImageData;
|
|
var left = Math.floor((canvas.width - imagedata.width) / 2);
|
|
var top = Math.floor((canvas.height - imagedata.height) / 2);
|
|
|
|
this.dest.x = left;
|
|
this.dest.y = top;
|
|
this.dest.width = imagedata.width;
|
|
this.dest.height = imagedata.height;
|
|
|
|
this.processor.setGamma(this.edits.exposure.gamma);
|
|
this.processor.setMatrix(this.edits.effect.matrix);
|
|
this.processor.setLevels(this.edits.enhance.autoLevels);
|
|
|
|
var copiedPixels = new Uint8Array(imagedata.data);
|
|
|
|
this.processor.processImage(copiedPixels.buffer)
|
|
.then(function(processedPixels) {
|
|
var processedImageData =
|
|
new ImageData(new Uint8ClampedArray(processedPixels),
|
|
imagedata.width, imagedata.height);
|
|
requestAnimationFrame(function() {
|
|
if (!self.previewCanvas) {
|
|
return;
|
|
}
|
|
if (eraseCanvas) {
|
|
previewContext.clearRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
previewContext.putImageData(processedImageData, left, top);
|
|
|
|
if (callback) {
|
|
callback();
|
|
}
|
|
|
|
self.editing = false;
|
|
if (self.needsAnotherEdit) {
|
|
self.needsAnotherEdit = false;
|
|
self.edit();
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
// Apply the edits offscreen and pass the full-size edited image as a blob
|
|
// to the specified callback function. The code here is much like the
|
|
// code above in edit().
|
|
//
|
|
// This function releases the this.original image so you should only call
|
|
// it to get the edited image when you are done with the ImageEditor.
|
|
//
|
|
ImageEditor.prototype.getFullSizeBlob = function(type, done, progress) {
|
|
//
|
|
// We can tune this algorithm by varying these constants.
|
|
//
|
|
// In theory, using more threads will allow the save to be faster on
|
|
// multi-core CPUs. But testing with 5mp images on Flame and Nexus 5
|
|
// I find that using 2 threds is only marginally faster than using 1,
|
|
// and using more than 2 slows things down. I suspect that a 5mp image
|
|
// is just not enough work for a fast device like nexus 5. Maybe with
|
|
// larger images more threads will have more of an impact.
|
|
//
|
|
// The more tiles, the smoother the progress bar animation will be but
|
|
// the longer the overall save will take.
|
|
//
|
|
const NUM_THREADS = 2;
|
|
const NUM_TILES = 24;
|
|
|
|
if (progress) { // Get the progress bar to appear right away
|
|
progress(0.01);
|
|
}
|
|
|
|
// Create an offscreen canvas and copy the image into it
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = this.source.width; // "full size" is cropped image size
|
|
canvas.height = this.source.height;
|
|
var context = canvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(this.original,
|
|
this.source.x, this.source.y,
|
|
this.source.width, this.source.height,
|
|
0, 0, this.source.width, this.source.height);
|
|
|
|
// As soon as we've copied the original image into the canvas we are
|
|
// done with the original and should release it to reduce memory usage.
|
|
this.original.src = '';
|
|
|
|
// How many pixels do we have to process?
|
|
var total_pixels = canvas.width * canvas.height;
|
|
|
|
// How many pixels have we processed so far?
|
|
var processed_pixels = 0;
|
|
|
|
function makeTileList(imageWidth, imageHeight, tileWidth, tileHeight) {
|
|
var tiles = [];
|
|
var x = 0, y = 0;
|
|
while (y < imageHeight) {
|
|
x = 0;
|
|
while (x < imageWidth) {
|
|
var tile = {x: x, y: y, w: tileWidth, h: tileHeight};
|
|
if (x + tileWidth > imageWidth) {
|
|
tile.w = imageWidth - x;
|
|
}
|
|
if (y + tileHeight > imageHeight) {
|
|
tile.h = imageHeight - y;
|
|
}
|
|
tiles.push(tile);
|
|
x += tileWidth;
|
|
}
|
|
y += tileHeight;
|
|
}
|
|
return tiles;
|
|
}
|
|
|
|
// Divide the image into a set of tiled rectangles
|
|
var tileHeight = Math.max(Math.round(this.source.height / NUM_TILES), 32);
|
|
var rectangles = makeTileList(this.source.width, this.source.height,
|
|
this.source.width, tileHeight);
|
|
|
|
// Create and initialize image processors to do the work.
|
|
// Each one has its own worker thread.
|
|
var processors = [];
|
|
for (var i = 0; i < NUM_THREADS; i++) {
|
|
processors[i] = new ImageProcessorThread(this.source.width * tileHeight);
|
|
processors[i].setGamma(this.edits.exposure.gamma);
|
|
processors[i].setMatrix(this.edits.effect.matrix);
|
|
processors[i].setLevels(this.edits.enhance.autoLevels);
|
|
}
|
|
|
|
// After this first synchronous portion, including the big drawImage()
|
|
// we'll guess that we're about 5% complete.
|
|
if (progress) {
|
|
progress(0.05);
|
|
}
|
|
|
|
// Loop through all the rectangles, get the image data for each, send
|
|
// it to a processor and collect the promises we get.
|
|
//
|
|
// XXX: this might be more efficient if we didn't get all the image data
|
|
// at once and instead waited for the processors to finish their tiles
|
|
// before getting more data for them.
|
|
//
|
|
var promises = [];
|
|
rectangles.forEach(function(rect, index) {
|
|
// Get the input pixels for this tile
|
|
var tile = context.getImageData(rect.x, rect.y, rect.w, rect.h);
|
|
// Choose a processor to handle this tile
|
|
var processor = processors[index % processors.length];
|
|
var promise = processor.processImage(tile.data.buffer)
|
|
.then(function(editedBuffer) {
|
|
// Copy the edited pixels from the tile back to the canvas
|
|
var editedTile =
|
|
new ImageData(new Uint8ClampedArray(editedBuffer), rect.w, rect.h);
|
|
context.putImageData(editedTile, rect.x, rect.y);
|
|
// Update the progress bar
|
|
processed_pixels += rect.w * rect.h;
|
|
if (progress) {
|
|
// Processing the pixels takes 85% of our time (as a rough guess)
|
|
progress(0.05 + 0.85 * processed_pixels / total_pixels);
|
|
}
|
|
});
|
|
promises.push(promise);
|
|
});
|
|
|
|
// Now wait for all the editing promises to be complete and save
|
|
// the edited image
|
|
Promise.all(promises).then(function() {
|
|
// We're done processing the image, so we can free the processors
|
|
// This should kill the workers and their memory a little faster
|
|
// than they might otherwise be garbage collected.
|
|
processors.forEach(function(processor) { processor.destroy(); });
|
|
|
|
function gotEncodedBlob(blob) {
|
|
// Move the progress bar to the end
|
|
if (progress) {
|
|
progress(1.0);
|
|
}
|
|
|
|
// Pass the blob to the callback.
|
|
// setTimeout is just so that the user can see the progress bar change.
|
|
setTimeout(function() {
|
|
done(blob);
|
|
});
|
|
}
|
|
|
|
// we need to save these for Exif rewrite.
|
|
var canvasW = canvas.width;
|
|
var canvasH = canvas.height;
|
|
|
|
function updateExif(blob, metaData, callback) {
|
|
metaData.Orientation = 1;
|
|
if (metaData.PixelXDimension) {
|
|
metaData.PixelXDimension = canvasW;
|
|
}
|
|
if (metaData.PixelYDimension) {
|
|
metaData.PixelYDimension = canvasH;
|
|
}
|
|
JPEGParser.writeExifMetaData(
|
|
blob, metaData,
|
|
(error, modifiedBlob) => {
|
|
if (error) {
|
|
console.error('Error' + error);
|
|
}
|
|
// Process modified file
|
|
callback(modifiedBlob);
|
|
});
|
|
}
|
|
|
|
var originalBlob = this.imageBlob;
|
|
var originalExif = this.edits.EXIF;
|
|
|
|
// Finally, convert the edited image to a blob and pass to the callback.
|
|
canvas.toBlob(function(blob) {
|
|
// Now that we've got the blob, we don't need the canvas anymore
|
|
context = null;
|
|
canvas.width = canvas.height = 0;
|
|
canvas = null;
|
|
progress(0.95);
|
|
|
|
if (type == 'image/jpeg') {
|
|
// we are most likely to have originalExif here.
|
|
if (originalExif) {
|
|
updateExif(blob, originalExif, gotEncodedBlob);
|
|
} else {
|
|
LazyLoader.load(['shared/js/media/jpeg-exif.js'], () => {
|
|
JPEGParser.readExifMetaData(originalBlob, (error, metaData) => {
|
|
if (error || !metaData) {
|
|
gotEncodedBlob(blob);
|
|
return;
|
|
}
|
|
updateExif(blob, metaData, gotEncodedBlob);
|
|
});
|
|
});
|
|
}
|
|
} else {
|
|
gotEncodedBlob(blob);
|
|
}
|
|
}, type);
|
|
}.bind(this));
|
|
};
|
|
|
|
ImageEditor.prototype.isCropOverlayShown = function() {
|
|
return this.cropCanvas;
|
|
};
|
|
|
|
// Returns true if the crop region is anything different than the
|
|
// entire dest rectangle. If this method returns false, there is no
|
|
// need to call getCroppedRegionBlob().
|
|
ImageEditor.prototype.hasBeenCropped = function() {
|
|
var dpr = window.devicePixelRatio;
|
|
return (this.cropOverlayRegion.left !== 0 ||
|
|
this.cropOverlayRegion.top !== 0 ||
|
|
this.cropOverlayRegion.right < Math.floor(this.dest.width / dpr) ||
|
|
this.cropOverlayRegion.bottom < Math.floor(this.dest.height / dpr));
|
|
};
|
|
|
|
// Display cropping controls
|
|
// XXX: have to handle rotate/resize
|
|
ImageEditor.prototype.showCropOverlay = function showCropOverlay(newRegion) {
|
|
var self = this;
|
|
|
|
// Create a canvas to display the crop overlay.
|
|
// Ignore the device pixel ratio and use CSS pixels for this canvas.
|
|
var canvas = this.cropCanvas = document.createElement('canvas');
|
|
canvas.id = 'edit-crop-canvas'; // for stylesheet
|
|
this.container.appendChild(canvas);
|
|
canvas.width = canvas.clientWidth;
|
|
canvas.height = canvas.clientHeight;
|
|
var context = this.cropContext = canvas.getContext('2d');
|
|
|
|
// Crop handle styles
|
|
context.translate(25, 15);
|
|
context.lineCap = 'round';
|
|
// XXX
|
|
// Please turn on the followig line when Bug 937529 is fixed. This is an
|
|
// workaround to have active handles drawn.
|
|
// context.lineJoin = 'round';
|
|
context.strokeStyle = 'rgba(255,255,255,1)';
|
|
|
|
// Start off with a crop region that is the one passed in, if it is not null.
|
|
// Otherwise, it should be the entire preview canvas
|
|
var region;
|
|
if (newRegion) {
|
|
region = this.cropOverlayRegion;
|
|
region.left = newRegion.left;
|
|
region.top = newRegion.top;
|
|
region.right = newRegion.right;
|
|
region.bottom = newRegion.bottom;
|
|
} else {
|
|
region = this.cropOverlayRegion;
|
|
var dpr = window.devicePixelRatio;
|
|
region.left = 0;
|
|
region.top = 0;
|
|
region.right = Math.floor(this.dest.width / dpr);
|
|
region.bottom = Math.floor(this.dest.height / dpr);
|
|
}
|
|
this.drawCropControls();
|
|
|
|
var isCropping = false;
|
|
this.cropCanvas.addEventListener('pan', function(ev) {
|
|
if (!isCropping) {
|
|
self.cropStart(ev);
|
|
isCropping = true;
|
|
}
|
|
});
|
|
this.cropCanvas.addEventListener('swipe', function() {
|
|
isCropping = false;
|
|
});
|
|
};
|
|
|
|
ImageEditor.prototype.hideCropOverlay = function hideCropOverlay() {
|
|
if (this.isCropOverlayShown()) {
|
|
this.container.removeChild(this.cropCanvas);
|
|
this.cropCanvas.width = 0;
|
|
this.cropCanvas = this.cropContext = null;
|
|
}
|
|
};
|
|
|
|
// Force set source size to crop sizes in edit settings
|
|
ImageEditor.prototype.forceSetCropRegion = function forceSetCropRegion() {
|
|
this.source.x = this.edits.crop.x;
|
|
this.source.y = this.edits.crop.y;
|
|
this.source.width = this.edits.crop.w;
|
|
this.source.height = this.edits.crop.h;
|
|
};
|
|
|
|
// Reset image to full original size
|
|
ImageEditor.prototype.resetCropRegion = function resetCropRegion() {
|
|
this.source.x = 0;
|
|
this.source.y = 0;
|
|
this.source.width = this.original.width;
|
|
this.source.height = this.original.height;
|
|
};
|
|
|
|
ImageEditor.prototype.drawCropControls = function(handle) {
|
|
var canvas = this.cropCanvas;
|
|
var context = this.cropContext;
|
|
var region = this.cropOverlayRegion;
|
|
var dpr = window.devicePixelRatio;
|
|
var dest = {
|
|
x: Math.floor(this.dest.x / dpr),
|
|
y: Math.floor(this.dest.y / dpr),
|
|
width: Math.floor(this.dest.width / dpr),
|
|
height: Math.floor(this.dest.height / dpr)
|
|
};
|
|
var left = region.left + dest.x;
|
|
var top = region.top + dest.y;
|
|
var right = region.right + dest.x;
|
|
var bottom = region.bottom + dest.y;
|
|
var centerX = (left + right) / 2;
|
|
var centerY = (top + bottom) / 2;
|
|
var width = right - left;
|
|
var height = bottom - top;
|
|
|
|
// Erase everything
|
|
context.clearRect(-25, -15, canvas.width, canvas.height);
|
|
|
|
// Overlay the preview canvas with translucent gray
|
|
context.fillStyle = 'rgba(0, 0, 0, .5)';
|
|
context.fillRect(dest.x, dest.y, dest.width, dest.height);
|
|
|
|
// Clear a rectangle so interior of the crop region shows through
|
|
context.clearRect(left, top, width, height);
|
|
|
|
// Draw a border around the crop region
|
|
context.lineWidth = 1;
|
|
context.strokeRect(left, top, width, height);
|
|
|
|
// Draw the drag handles in the corners of the crop region
|
|
context.lineWidth = 5;
|
|
context.beginPath();
|
|
|
|
// N
|
|
context.moveTo(centerX - 23, top + 1);
|
|
context.lineTo(centerX + 23, top + 1);
|
|
|
|
// E
|
|
context.moveTo(right - 1, centerY - 23);
|
|
context.lineTo(right - 1, centerY + 23);
|
|
|
|
// S
|
|
context.moveTo(centerX - 23, bottom - 1);
|
|
context.lineTo(centerX + 23, bottom - 1);
|
|
|
|
// W
|
|
context.moveTo(left + 1, centerY - 23);
|
|
context.lineTo(left + 1, centerY + 23);
|
|
|
|
// Don't draw the corner handles if there is an aspect ratio we're maintaining
|
|
if (!this.cropAspectWidth) {
|
|
// NE
|
|
context.moveTo(right - 23, top + 1);
|
|
context.lineTo(right - 1, top + 1);
|
|
context.lineTo(right - 1, top + 23);
|
|
|
|
// SE
|
|
context.moveTo(right - 1, bottom - 23);
|
|
context.lineTo(right - 1, bottom - 1);
|
|
context.lineTo(right - 23, bottom - 1);
|
|
|
|
// SW
|
|
context.moveTo(left + 23, bottom - 1);
|
|
context.lineTo(left + 1, bottom - 1);
|
|
context.lineTo(left + 1, bottom - 23);
|
|
|
|
// NW
|
|
context.moveTo(left + 1, top + 23);
|
|
context.lineTo(left + 1, top + 1);
|
|
context.lineTo(left + 23, top + 1);
|
|
}
|
|
|
|
// Draw all the handles at once
|
|
context.stroke();
|
|
|
|
// If one of the handles is being used, highlight it
|
|
if (handle) {
|
|
var cx, cy;
|
|
switch (handle) {
|
|
case 'n':
|
|
cx = centerX;
|
|
cy = top;
|
|
break;
|
|
case 'ne':
|
|
cx = right;
|
|
cy = top;
|
|
break;
|
|
case 'e':
|
|
cx = right;
|
|
cy = centerY;
|
|
break;
|
|
case 'se':
|
|
cx = right;
|
|
cy = bottom;
|
|
break;
|
|
case 's':
|
|
cx = centerX;
|
|
cy = bottom;
|
|
break;
|
|
case 'sw':
|
|
cx = left;
|
|
cy = bottom;
|
|
break;
|
|
case 'w':
|
|
cx = left;
|
|
cy = centerY;
|
|
break;
|
|
case 'nw':
|
|
cx = left;
|
|
cy = top;
|
|
break;
|
|
}
|
|
|
|
context.beginPath();
|
|
context.arc(cx, cy, 25, 0, 2 * Math.PI);
|
|
context.fillStyle = 'rgba(255,255,255,.5)';
|
|
context.lineWidth = 1;
|
|
context.fill();
|
|
}
|
|
};
|
|
|
|
// Called when the first pan event comes in on the crop canvas
|
|
ImageEditor.prototype.cropStart = function(ev) {
|
|
var self = this;
|
|
var region = this.cropOverlayRegion;
|
|
var dpr = window.devicePixelRatio;
|
|
var dest = {
|
|
x: Math.floor(this.dest.x / dpr),
|
|
y: Math.floor(this.dest.y / dpr),
|
|
width: Math.floor(this.dest.width / dpr),
|
|
height: Math.floor(this.dest.height / dpr)
|
|
};
|
|
var rect = this.previewCanvas.getBoundingClientRect();
|
|
var x0 = ev.detail.position.screenX - rect.left - dest.x;
|
|
var y0 = ev.detail.position.screenY - rect.top - dest.y;
|
|
var left = region.left;
|
|
var top = region.top;
|
|
var right = region.right;
|
|
var bottom = region.bottom;
|
|
var aspectRatio = this.cropAspectWidth ?
|
|
this.cropAspectWidth / this.cropAspectHeight :
|
|
0;
|
|
var centerX = (region.left + region.right) / 2;
|
|
var centerY = (region.top + region.bottom) / 2;
|
|
|
|
// Return true if (x0,y0) is within 25 pixels of (x,y)
|
|
function hit(x, y) {
|
|
return (x0 > x - 25 && x0 < x + 25 &&
|
|
y0 > y - 25 && y0 < y + 25);
|
|
}
|
|
|
|
if (hit((left + right) / 2, top)) {
|
|
drag('n');
|
|
} else if (hit(right, (top + bottom) / 2)) {
|
|
drag('e');
|
|
} else if (hit((left + right) / 2, bottom)) {
|
|
drag('s');
|
|
} else if (hit(left, (top + bottom) / 2)) {
|
|
drag('w');
|
|
} else if (!aspectRatio) {
|
|
if (hit(right, top)) {
|
|
drag('ne');
|
|
} else if (hit(right, bottom)) {
|
|
drag('se');
|
|
} else if (hit(left, bottom)) {
|
|
drag('sw');
|
|
} else if (hit(left, top)) {
|
|
drag('nw');
|
|
} else {
|
|
drag(); // with no argument, do a pan instead of a drag
|
|
}
|
|
} else {
|
|
drag(); // pan
|
|
}
|
|
|
|
function drag(handle) {
|
|
window.addEventListener('pan', move, true);
|
|
window.addEventListener('swipe', up, true);
|
|
|
|
self.drawCropControls(handle); // highlight drag handle
|
|
|
|
function move(e) {
|
|
var dx = e.detail.absolute.dx;
|
|
var dy = e.detail.absolute.dy;
|
|
|
|
var newleft = region.left;
|
|
var newright = region.right;
|
|
var newtop = region.top;
|
|
var newbottom = region.bottom;
|
|
|
|
if (!handle) {
|
|
pan(dx, dy);
|
|
return;
|
|
}
|
|
|
|
switch (handle) {
|
|
case 'n':
|
|
newtop = top + dy;
|
|
break;
|
|
case 'ne':
|
|
newright = right + dx;
|
|
newtop = top + dy;
|
|
break;
|
|
case 'e':
|
|
newright = right + dx;
|
|
break;
|
|
case 'se':
|
|
newright = right + dx;
|
|
newbottom = bottom + dy;
|
|
break;
|
|
case 's':
|
|
newbottom = bottom + dy;
|
|
break;
|
|
case 'sw':
|
|
newleft = left + dx;
|
|
newbottom = bottom + dy;
|
|
break;
|
|
case 'w':
|
|
newleft = left + dx;
|
|
break;
|
|
case 'nw':
|
|
newleft = left + dx;
|
|
newtop = top + dy;
|
|
break;
|
|
}
|
|
|
|
// If there is an aspect ratio, make sure we maintain it.
|
|
// Note that if there is an aspect ratio we won't display
|
|
// the corner drag handles, so we don't have to handle those.
|
|
if (aspectRatio) {
|
|
var width, height;
|
|
switch (handle) {
|
|
case 'n':
|
|
case 's':
|
|
// change width to match the new height, keeping the center still
|
|
height = newbottom - newtop;
|
|
width = height * aspectRatio;
|
|
newleft = Math.floor(centerX - Math.floor(width / 2));
|
|
newright = Math.ceil(centerX + Math.ceil(width / 2));
|
|
break;
|
|
case 'e':
|
|
case 'w':
|
|
// Change height to match new width, keeping center still
|
|
width = newright - newleft;
|
|
height = width / aspectRatio;
|
|
newtop = Math.floor(centerY - Math.floor(height / 2));
|
|
newbottom = Math.ceil(centerY + Math.ceil(height / 2));
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Now if the new region is out of bounds then bail out without
|
|
// changing the region at all and ignore this move event
|
|
if (newtop < 0 || newleft < 0 ||
|
|
newright > dest.width || newbottom > dest.height) {
|
|
return;
|
|
}
|
|
|
|
// Don't let the crop region become smaller than 100x100. If it does
|
|
// then the sensitive regions of the crop handles start to intersect.
|
|
// If there is a cropping aspect ratio, then the minimum size in
|
|
// one dimension will be 100 and will be larger in the other.
|
|
var minWidth = 100, minHeight = 100;
|
|
if (aspectRatio) {
|
|
if (aspectRatio > 1) {
|
|
minWidth = Math.round(minWidth * aspectRatio);
|
|
} else if (aspectRatio < 1) {
|
|
minHeight = Math.round(minHeight / aspectRatio);
|
|
}
|
|
}
|
|
|
|
// if the width is less than the minimum allowed (due to orientation
|
|
// change), only allow the crop region to get bigger
|
|
var newWidth = newright - newleft;
|
|
if ((newWidth < (region.right - region.left)) && (newWidth < minWidth)) {
|
|
return;
|
|
}
|
|
var newHeight = newbottom - newtop;
|
|
if ((newHeight < (region.bottom - region.top)) &&
|
|
(newHeight < minHeight)) {
|
|
return;
|
|
}
|
|
|
|
// Otherwise, all is well, so update the crop region and redraw
|
|
region.left = newleft;
|
|
region.right = newright;
|
|
region.top = newtop;
|
|
region.bottom = newbottom;
|
|
|
|
self.drawCropControls(handle);
|
|
}
|
|
|
|
function pan(dx, dy) {
|
|
if (dx > 0) {
|
|
dx = Math.min(dx, dest.width - right);
|
|
}
|
|
if (dx < 0) {
|
|
dx = Math.max(dx, -left);
|
|
}
|
|
if (dy > 0) {
|
|
dy = Math.min(dy, dest.height - bottom);
|
|
}
|
|
if (dy < 0) {
|
|
dy = Math.max(dy, -top);
|
|
}
|
|
|
|
region.left = left + dx;
|
|
region.right = right + dx;
|
|
region.top = top + dy;
|
|
region.bottom = bottom + dy;
|
|
|
|
self.drawCropControls();
|
|
}
|
|
|
|
function up(e) {
|
|
// Enable Apply and Save button if it's first edit.
|
|
if (enableSaveAndApplyButtons) {
|
|
enableSaveAndApplyButtons();
|
|
}
|
|
window.removeEventListener('pan', move, true);
|
|
window.removeEventListener('swipe', up, true);
|
|
self.drawCropControls(); // erase drag handle highlight
|
|
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
};
|
|
|
|
// If the crop overlay is displayed, use the current position of the
|
|
// overlaid crop region to actually set the crop region of the original image
|
|
ImageEditor.prototype.cropImage = function(callback) {
|
|
if (!this.isCropOverlayShown()) {
|
|
if (callback) {
|
|
callback();
|
|
}
|
|
return;
|
|
}
|
|
|
|
var region = this.cropOverlayRegion;
|
|
var dpr = window.devicePixelRatio;
|
|
var dest = {
|
|
x: Math.floor(this.dest.x / dpr),
|
|
y: Math.floor(this.dest.y / dpr),
|
|
width: Math.floor(this.dest.width / dpr),
|
|
height: Math.floor(this.dest.height / dpr)
|
|
};
|
|
|
|
// Convert the preview crop region to fractions
|
|
var left = Math.min(region.left / dest.width, 1.0);
|
|
var right = Math.min(region.right / dest.width, 1.0);
|
|
var top = Math.min(region.top / dest.height, 1.0);
|
|
var bottom = Math.min(region.bottom / dest.height, 1.0);
|
|
|
|
// Now convert those fractions to pixels in the original image
|
|
left = Math.floor(left * this.source.width);
|
|
right = Math.ceil(right * this.source.width);
|
|
top = Math.floor(top * this.source.height);
|
|
bottom = Math.ceil(bottom * this.source.height);
|
|
|
|
// XXX: tweak these to make sure we still have the right aspect ratio
|
|
// after rounding to pixels
|
|
console.error('Maintain aspect ratio precisely!!!');
|
|
|
|
// And update the real crop region
|
|
this.source.x += left;
|
|
this.source.y += top;
|
|
this.source.width = right - left;
|
|
this.source.height = bottom - top;
|
|
|
|
this.resetPreview();
|
|
this.edit(callback);
|
|
};
|
|
|
|
// Pass no arguments for freeform 1,1 for square,
|
|
// 2,3 for portrait, 3,2 for landscape.
|
|
ImageEditor.prototype.setCropAspectRatio = function(ratioWidth, ratioHeight) {
|
|
var region = this.cropOverlayRegion;
|
|
var dpr = window.devicePixelRatio;
|
|
var dest = {
|
|
x: Math.floor(this.dest.x / dpr),
|
|
y: Math.floor(this.dest.y / dpr),
|
|
width: Math.floor(this.dest.width / dpr),
|
|
height: Math.floor(this.dest.height / dpr)
|
|
};
|
|
|
|
this.cropAspectWidth = ratioWidth || 0;
|
|
this.cropAspectHeight = ratioHeight || 0;
|
|
|
|
if (ratioWidth && ratioHeight) {
|
|
// Constrained cropping, centered on image
|
|
var centerX = dest.width / 2;
|
|
var centerY = dest.height / 2;
|
|
|
|
var scaleX = dest.width / ratioWidth;
|
|
var scaleY = dest.height / ratioHeight;
|
|
var scale = Math.min(scaleX, scaleY);
|
|
|
|
var width = Math.floor(scale * ratioWidth);
|
|
var height = Math.floor(scale * ratioHeight);
|
|
|
|
region.left = centerX - width / 2;
|
|
region.right = centerX + width / 2;
|
|
region.top = centerY - height / 2;
|
|
region.bottom = centerY + height / 2;
|
|
}
|
|
else {
|
|
// Freeform cropping
|
|
region.left = 0;
|
|
region.top = 0;
|
|
region.right = dest.width;
|
|
region.bottom = dest.height;
|
|
}
|
|
this.drawCropControls();
|
|
};
|
|
|
|
// Return the crop region as an object with left, top, width and height
|
|
// properties. The values of these properties are numbers between 0 and 1
|
|
// representing fractions of the full image width and height.
|
|
ImageEditor.prototype.getCropRegion = function() {
|
|
var region = this.cropOverlayRegion;
|
|
var previewRect = this.dest;
|
|
var dpr = window.devicePixelRatio;
|
|
|
|
// Convert the preview crop region to fractions
|
|
var left = dpr * region.left / previewRect.width;
|
|
var right = dpr * region.right / previewRect.width;
|
|
var top = dpr * region.top / previewRect.height;
|
|
var bottom = dpr * region.bottom / previewRect.height;
|
|
|
|
return {
|
|
left: left,
|
|
top: top,
|
|
width: Math.min(right - left, 1.0),
|
|
height: Math.min(bottom - top, 1.0)
|
|
};
|
|
};
|
|
|
|
// Covert the cropped region in image pixels to object with left, right, top,
|
|
// bottom properties in CSS Pixel to be used to show crop overlay
|
|
ImageEditor.prototype.getCropOverlayRegion = function(cropSettings) {
|
|
var dpr = window.devicePixelRatio;
|
|
|
|
var cropOverlay = {};
|
|
cropOverlay.left = Math.floor((cropSettings.x * this.scale) / dpr);
|
|
cropOverlay.top = Math.floor((cropSettings.y * this.scale) / dpr);
|
|
cropOverlay.right = Math.floor(((cropSettings.x + cropSettings.w) *
|
|
this.scale) / dpr);
|
|
cropOverlay.bottom = Math.floor(((cropSettings.y + cropSettings.h) *
|
|
this.scale) / dpr);
|
|
return cropOverlay;
|
|
};
|
|
|
|
ImageEditor.prototype.autoEnhancement = function(callback) {
|
|
var self = this;
|
|
if (!this.edits.enhance.autoLevels) { // if not already enhanced
|
|
this.getAutoLevels().then(setEnhancement);
|
|
} else {
|
|
setEnhancement(null);
|
|
}
|
|
|
|
function setEnhancement(levels) {
|
|
self.edits.enhance.autoLevels = levels;
|
|
self.edit();
|
|
if (callback) {
|
|
callback(levels !== null);
|
|
}
|
|
}
|
|
};
|
|
|
|
/*
|
|
* Compute red, green, and blue min and max levels for auto enhancement
|
|
* based on the image histogram returned by computeHistogram().
|
|
*
|
|
* The levels computed here are used by ImageProcessor to do simple
|
|
* linear equalization. For each channel, we find the lowest pixel and
|
|
* the highest, and then do a linear transformation so that the lowest
|
|
* go to 0 and the highest go to 255. A couple of refinements: instead
|
|
* of finding the lowest and highest pixels, we find the level of the
|
|
* bottom 1% and top 1% of pixels and use those. This allows us to
|
|
* deal with small amounts of noise in the image. Also, if the min-max
|
|
* range isn't very wide, ImageProcessor doesn't modify that channel
|
|
* at all. This is for non-photographic images that have solid colors
|
|
* in them: we don't want to wash those out.
|
|
*
|
|
* I also tried implementing "histogram equalization" based on the post at
|
|
* http://www.generation5.org/content/2004/histogramEqualization.asp
|
|
* This gave really vivid color enhancement, probably too vivid.
|
|
* Also, it enhanced noise and overexposed regions in a bad way, and it
|
|
* didn't deal with non-photo images that were mostly the same color.
|
|
* Black text on light blue turned into black on white. Wikipedia lists
|
|
* some of the shortcomings of histogram equalization here:
|
|
* http://en.wikipedia.org/wiki/Histogram_equalization#Overview
|
|
*
|
|
* In the future, we could try histogram equalization with the
|
|
* clipping technique described here:
|
|
* http://en.wikipedia.org/wiki/Adaptive_histogram_equalization#Contrast_Limited_AHE
|
|
* By aggressively clipping the peaks of the histogram before
|
|
* computing the cumulative histogram maybe we can get the best of the
|
|
* the linear and histogram techniques.
|
|
*
|
|
* Also, maybe we'd get better results if we only computed a histogram
|
|
* for the center of the image and then enhanced the entire image
|
|
* based on the middle. Or, more generally, we could weight the
|
|
* histogram so that pixels near the middle count for more than pixels
|
|
* near the edge. All pixels get 1 count. Then those more than 1/8th
|
|
* from any edge get another count (that's the center 9/16ths of the
|
|
* pixels). Then all the pixels that are more than 1/4 from the edge
|
|
* (the center 1/4 of the pixels) get a third count.
|
|
*/
|
|
|
|
// Return a Promise for auto enhancment parameters for the current preview.
|
|
// If they have already been computed, then the promise will resolve
|
|
// immediately. Otherwise, the promise will resolve when they are computed.
|
|
// This calls the computeHistogram() method of ImageProcessor to compute
|
|
// a histogram of the current preview, if that has not already been computed.
|
|
ImageEditor.prototype.getAutoLevels = function() {
|
|
var self = this;
|
|
|
|
if (this.autoLevels) {
|
|
return Promise.resolve(this.autoLevels);
|
|
}
|
|
|
|
if (!this.previewImageData) {
|
|
// this should not happen
|
|
return Promise.reject(new Error('called getAutoLevels when no preview' +
|
|
'image was defined'));
|
|
}
|
|
|
|
var copiedPixels = new Uint8Array(this.previewImageData.data);
|
|
|
|
return this.processor.computeHistogram(copiedPixels.buffer)
|
|
.then(function(histogram) {
|
|
var redHistogram = new Uint32Array(histogram, 0, 256);
|
|
var greenHistogram = new Uint32Array(histogram, 256 * 4, 256);
|
|
var blueHistogram = new Uint32Array(histogram, 256 * 8, 256);
|
|
|
|
self.autoLevels = {
|
|
red: getLevels(redHistogram, 1),
|
|
green: getLevels(greenHistogram, 1),
|
|
blue: getLevels(blueHistogram, 1)
|
|
};
|
|
|
|
return self.autoLevels;
|
|
});
|
|
|
|
function getLevels(histogram, thresholdPercent) {
|
|
var total, threshold, min = 255, max = 0, i;
|
|
|
|
// How many total pixels?
|
|
// If we do weighted histograms, this may be different than
|
|
// the size of the image.
|
|
total = 0;
|
|
for (i = 0; i < 256; i++) {
|
|
total += histogram[i];
|
|
}
|
|
|
|
// How many pixels do we need to see to count as the minimum or maximum
|
|
threshold = thresholdPercent / 100 * total;
|
|
|
|
// Find the minimum level
|
|
total = 0;
|
|
for (i = 0; i < 256; i++) {
|
|
total += histogram[i];
|
|
if (total > threshold) {
|
|
min = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Find the maximum level
|
|
total = 0;
|
|
for (i = 255; i >= 0; i--) {
|
|
total += histogram[i];
|
|
if (total > threshold) {
|
|
max = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
min: min,
|
|
max: max
|
|
};
|
|
}
|
|
};
|