Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
1977 lines
64 KiB
JavaScript
1977 lines
64 KiB
JavaScript
// 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
|
|
};
|
|
}
|
|
};
|