Skip to content

Instantly share code, notes, and snippets.

@twolfson
Last active October 21, 2016 09:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save twolfson/2745867438113ed97ad5a39b7a2a410e to your computer and use it in GitHub Desktop.
Save twolfson/2745867438113ed97ad5a39b7a2a410e to your computer and use it in GitHub Desktop.
Proof of concept to explore fancy conflict resolution for images
// Load in our dependencies
var server = require('./server');
// Define our configuration
exports.rootUrl = 'http://' + server.LISTEN_HOSTNAME + ':' + server.LISTEN_PORT;
exports.browsers = {
Chrome: {
desiredCapabilities: {
browserName: 'chrome'
},
// Default to large screen as our window size
windowSize: '1024x1600',
// Restrict to 1 suite per session to prevent issues like mouse down sticking
suitesPerSession: 1
}
};
# Node.js/npm files
node_modules/
npm-debug.log
# Gemini screenshots and report
gemini/
gemini-report/

gist-gemini-fancy

Proof of concept to explore fancy conflict resolution for images

Getting started

To reuse our proof of concept, run the following steps:

# Clone our repo
git clone https://gist.github.com/2745867438113ed97ad5a39b7a2a410e.git gist-gemini-fancy
cd gist-gemini-fancy

# Install our dependencies (including Selenium)
npm install
npm run webdriver-update

# Start our server
npm start

# In another tab, start our Selenium server
npm run webdriver-start

# In yet another tab:
# Capture our normal Gemini images
ENV=normal npm run gemini-update

# Capture our alternate Gemini images (causing a diff)
ENV=alt npm run gemini-test

# Open our prototype page in your browser
xdg-open http://localhost:3000/prototype
# or open our performance page
xdg-open http://localhost:3000/performance
// Load in our dependencies
var assert = require('assert');
// Verify we have an ENV environment variable set
var env = process.env.ENV;
assert(env, '`ENV` environment variable wasn\'t set. Please set it to `normal` or `alt`');
// Define our resize helpers
// DEV: These originally come from a `utils/gemini.js` file
// DEV: When using Firefox, we can set window size to a high value (e.g. 1600) and it auto-truncates
var geminiUtils = {
resizeLarge: function (actions, find) {
actions.setWindowSize(1024, 600);
},
resizeMedium: function (actions, find) {
actions.setWindowSize(640, 600);
},
resizeSmall: function (actions, find) {
actions.setWindowSize(340, 600);
}
};
// Define our visual tests
gemini.suite('root', function (suite) {
suite.setUrl('/?env=' + encodeURIComponent(env))
.setCaptureElements('body')
.capture('default-large', geminiUtils.resizeLarge)
.capture('default-medium', geminiUtils.resizeMedium)
.capture('default-small', geminiUtils.resizeSmall);
});
doctype html
html
head
title gist-gemini-fancy
style.
html, body {
height: 100%;
}
body {
margin: 0;
}
.half {
float: left;
width: 50%;
height: 100%;
text-align: center;
}
.half--left {
color: white;
background: navy;
}
.half--right {
background: black;
background: linen;
}
body
.half.half--left
if locals.env === 'alt'
| Left side
else
| Left half
.half.half--right
if locals.env === 'alt'
| Right side
else
| Right half
{
"name": "gist-gemini-fancy",
"version": "1.0.0",
"description": "Proof of concept to explore fancy conflict resolution for images",
"main": "index.js",
"scripts": {
"gemini-update": "npm run verify-webdriver-running && gemini update gemini.js",
"gemini-test": "npm run verify-webdriver-running && gemini test --reporter html --reporter flat gemini.js",
"test": "echo \"Error: no test specified\" && exit 1",
"verify-webdriver-running": "curl --silent http://localhost:4444/ > /dev/null || (echo \"Webdriver isn't running, please start it via \\`npm run webdriver-start\\`\" 1>&2 && exit 1)",
"webdriver-start": "xvfb-run webdriver-manager start",
"webdriver-update": "webdriver-manager update"
},
"author": "Todd Wolfson <todd@twolfson.com> (http://twolfson.com/)",
"license": "Unlicense",
"dependencies": {
"express": "~4.14.0",
"gemini": "~4.12.2",
"jade": "~1.11.0",
"webdriver-manager": "~10.2.4"
}
}
doctype html
html
head
title gist-gemini-fancy performance
style.
/* Perform nice resets */
body {
height: 100%;
margin: 0;
/* https://github.com/corysimmons/typographic/blob/2.9.3/scss/typographic.scss#L34 */
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif';
}
body
//- Provide info to the user
h1 gist-gemini-fancy performance
p
| This page is a proof of concept to verify we won't have performance issues with a lot of pages
//- TODO: When moving to repo, don't work on polishing overlay in separate repo yet
//- First: Make sure we can get approval fully working (i.e. gemini does a comparison + approval data with no fuss)
//- Second: Improve selection in this repo so we can work out any kinks that might change its API
//- This might break a rule of mine which is open source/break out first but technically it's already open source =/
//- Third: Figure out jawbone and magnification etc (maybe do something in a mockup tool)
//- Load an images directly from our report
table
- var imgHeight = '250';
tr
td Current:
td Diff:
td Ref:
tr
td
img(src="gemini-report/images/root/default-large/Chrome~current.png", height=imgHeight)
td
img#expected-diff-img(src="gemini-report/images/root/default-large/Chrome~diff.png", height=imgHeight)
td
img(src="gemini-report/images/root/default-large/Chrome~ref.png", height=imgHeight)
//- Load large set of images in hidden container
//- DEV: In reality, we would probably have something like Gemini's hidden selection
div(style="display: none")
- var i = 0;
while (i < 200)
//- Create set which has matching selection
div(data-compare-set=i)
img(data-compare-type="current", src="gemini-report/images/root/default-large/Chrome~current.png")
img(data-compare-type="diff", src="gemini-report/images/root/default-large/Chrome~diff.png")
img(data-compare-type="ref", src="gemini-report/images/root/default-large/Chrome~ref.png")
//- Create set without matching selection
div(data-compare-set=i + 1)
img(data-compare-type="current", src="gemini-report/images/root/default-large/Chrome~ref.png")
img(data-compare-type="diff", src="gemini-report/images/root/default-large/Chrome~ref.png")
img(data-compare-type="ref", src="gemini-report/images/root/default-large/Chrome~ref.png")
- i += 2
//- Define an output area for images
p(style="margin-bottom: 0") Results:
table#results(style="margin-left: 20px")
//- TODO: Consider scrollspy for update buttons
//- TODO: Consider buttons to expand row of images to full screen
//- TODO: Consider magnifying glass zoom on images (e.g. like in ecommerce sites)
//- TODO: Figure out how to make selection work, maybe normal GUI like Gemini but with jawbone effect for matching items
script(src="https://cdn.rawgit.com/jed/domo/13c45aba3e94dd2d1bc469ce3339bbc1e3a10314/lib/domo.js")
script.
document.addEventListener('DOMContentLoaded', function handleReady () {
// Simplifiy domo reference
var D = window.domo;
// Specify target area (this would be done via overlay selection)
// DEV: Target area gathered from `/prototype`
var targetArea = {left: 159, top: 0, width: 205, height: 63.133331298828125};
// TODO: Realizing we need to do matching on similar content (i.e. same width image + same diff in selection)
// It looks like Gemini's comparison library isn't built for browser
// https://github.com/gemini-testing/looks-same
// For now, use direct comparison with `get-pixels` and `ndarray`
// Actually, we can prob use a second canvas with negative placement for x/y and same width/neight
// This is more future-proof and dodges loading `ndarray` dependencies
// Although, it's likely less efficient since we have to extract image data and compare it
// Start our chain of methods
findSelectionMatches();
function findSelectionMatches() {
// Start our performance check (70ms for 200 1024x1600 images)
console.time('findSelectionMatches');
// Find our sets of images to update
var compareSetEls = document.querySelectorAll('[data-compare-set]');
// Convert image sets into objects so we can add metadata
var compareSets = Array.prototype.map.call(compareSetEls, function createCompareSet (compareSetEl, i) {
return {
currentImg: compareSetEl.querySelector('[data-compare-type=current]'),
diffImg: compareSetEl.querySelector('[data-compare-type=diff]'),
refImg: compareSetEl.querySelector('[data-compare-type=ref]'),
name: 'path/to/image/' + i + '/Chrome'
};
});
// Prepare canvas for images to match against
function getSelectionImageData(img) {
// Generate our canvas sized down to the selection
// https://github.com/scijs/get-pixels/blob/7c447cd979637b31e47e148f238a1e71611af481/dom-pixels.js#L14-L18
var canvasEl = document.createElement('canvas');
canvasEl.width = targetArea.width;
canvasEl.height = targetArea.height;
var context = canvasEl.getContext('2d');
// Draw a clip for safety (performanc), then our image
// DEV: We haven't tested if this improves performance but assume it should
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/rect
context.rect(0, 0, targetArea.width, targetArea.height);
context.clip();
context.drawImage(img, -1 * targetArea.left, -1 * targetArea.top);
// Return our generated canvas
// https://github.com/scijs/get-pixels/blob/7c447cd979637b31e47e148f238a1e71611af481/dom-pixels.js#L19-L20
return context.getImageData(0, 0, targetArea.width, targetArea.height).data;
}
var expectedDiffImg = document.querySelector('#expected-diff-img').cloneNode();
// Reset HTML/CSS overrides
delete expectedDiffImg.height; delete expectedDiffImg.width;
delete expectedDiffImg.style; delete expectedDiffImg.className;
var expectedImageData = getSelectionImageData(expectedDiffImg);
// Prepare deep equals helper
// DEV: This is bad for security as we short circuit (i.e. not time constant comparison)
function deepEquals(aArr, bArr) {
if (aArr.length !== bArr.length) {
return false;
}
var i = 0;
for (; i < aArr.length; i += 1) {
if (aArr[i] !== bArr[i]) {
return false;
}
}
return true;
}
// Filter image sets based on matching widths and selection
var matchingCompareSets = compareSets.filter(function matchCompareSet (compareSet) {
// If the images are different widths, return false
// TODO: Allow this to be a configurable heuristic
var actualDiffImg = compareSet.diffImg;
if (expectedDiffImg.width !== actualDiffImg.width) {
return false;
}
// If the selection is different, return false
// DEV: We current do an exact match but could move to other comparison script
// Unfortunately, Gemini's comparison seems to be Node.js only
// and an exact match is "good enough" for now
var actualImageData = getSelectionImageData(actualDiffImg);
if (!deepEquals(actualImageData, expectedImageData)) {
return false;
}
// Otherwise, approve match
return true;
});
// End our performance check
console.timeEnd('findSelectionMatches');
// Pass through matching sets to `bulkUpdateSelection`
bulkUpdateSelection(matchingCompareSets);
}
function bulkUpdateSelection(compareSets) {
// Start our performance check (620ms total for 100 1024x1600 images, 400ms seems to be first `drawImage`)
console.time('bulkUpdateSelection');
// Find our output targets
var resultsEl = document.querySelector('#results');
var resultsDocFrag = document.createDocumentFragment();
// Generate and updated ref image for each of our comparisons
compareSets.forEach(function generateUpdatedRef (compareSet) {
// Localize our references
var currentImg = compareSet.currentImg;
var refImg = compareSet.refImg;
// Create a canvas
// https://github.com/scijs/get-pixels/blob/7c447cd979637b31e47e148f238a1e71611af481/dom-pixels.js#L14-L18
var canvasEl = document.createElement('canvas');
canvasEl.width = refImg.width;
canvasEl.height = refImg.height;
var context = canvasEl.getContext('2d');
// Load our reference into the canvas
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
context.drawImage(refImg, 0, 0);
// Update our selected portion on reference and draw image in clipping
// DEV: This is probably the most efficient way (outside of web workers) because
// we would have to draw image twice no matter what
// Maybe there's double pixel updates but I don't thinks so
// DEV: Performance alternatives we thought of but aren't needed
// Extract image from 2nd canvas via `ndarray`
// Use web workers
// Requesting server do it via `get-pixels` and `save-pixels`
// DEV: Slowest part is drawing initial image above
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/rect
context.rect(targetArea.left, targetArea.top, targetArea.width, targetArea.height);
context.clip();
context.drawImage(currentImg, 0, 0);
// Duplicate reference image and shrink both canvas/image for output
var imgHeight = 250;
canvasEl.style.height = imgHeight + 'px';
var refImgClone = refImg.cloneNode();
refImgClone.style.height = imgHeight + 'px';
// Generate and append result content
// DEV: We use a document fragment to avoid `n` DOM edits -- instead it's 1
var resultGroupEl = D.DIV([
D.TR([
// TODO: Add collapse support like in `gemini-gui`
D.TD({colspan: 3}, D.B(compareSet.name))
]),
D.TR([
// TODO: Move style out of inline and to classes for more performance
D.TD({style: 'padding-right: 10px;'}, 'Save update:'),
D.TD('Original ref:'),
D.TD('Updated ref:')
]),
D.TR([
D.TD({
style: 'vertical-align: top;'
}, [
D.INPUT({type: 'checkbox', checked: true})
]),
D.TD([refImgClone]),
D.TD([canvasEl])
])
]);
resultsDocFrag.appendChild(resultGroupEl);
// TODO: When bulk update is accepted, send overwrite requests to server with new image as "Ref"
// and use `?1` trick to refresh images
// then re-run comparisons for all current vs ref
// DEV: We are realizing that Gemini likely does comparisons in Node.js so we should do the same on update
});
// Append aggregate content to DOM
resultsEl.appendChild(resultsDocFrag);
// End our performance check
console.timeEnd('bulkUpdateSelection');
}
});
doctype html
html
head
title gist-gemini-fancy prototype
style.
/* Guarantee overlays automatically include border in widths */
* {
box-sizing: border-box;
}
/* Perform nice resets */
body {
height: 100%;
margin: 0;
/* https://github.com/corysimmons/typographic/blob/2.9.3/scss/typographic.scss#L34 */
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif';
}
/* Overlay styles */
.overlay-bound {
/* http://chrisnager.github.io/cursors/ */
cursor: crosshair;
}
.overlay {
position: absolute;
width: 50px;
height: 50px;
background: #33CC33;
border: 2px solid #00FF00;
opacity: 0.5;
/* Allow click through for new selection */
/* TODO: Depending on configuration, this could be a drag action on the overlay */
pointer-events: none;
}
body
//- Provide info to the user
h1 gist-gemini-fancy prototype
p
| Click and drag over the image to generate an overlay
br
= "Overlay info: "
span
code#overlay-info null
//- Load an images directly from our report
//- img(src="gemini-report/images/root/default-large/Chrome~current.png")
img(src="gemini-report/images/root/default-large/Chrome~diff.png")
//- img(src="gemini-report/images/root/default-large/Chrome~ref.png")
//- Place an overflow element for scroll testing
div(style="width: 120vw; height: 100vh;") &nbsp;
//- Load in our dependencies for `unidragger`
script(src="https://cdn.rawgit.com/metafizzy/ev-emitter/v1.0.3/ev-emitter.js")
script(src="https://cdn.rawgit.com/metafizzy/unipointer/v2.1.0/unipointer.js")
script(src="https://cdn.rawgit.com/metafizzy/unidragger/v2.1.0/unidragger.js")
//- Integrate HTML/CSS overlay for selection
script.
document.addEventListener('DOMContentLoaded', function handleReady () {
// Find our image
var imgEl = document.querySelector('img');
// Define our overlay class
function Overlay(targetEl) {
// Bind our overlay to the element
this.handles = [targetEl];
this.bindHandles();
// Add an overlay binding class to our element
targetEl.className += ' overlay-bound';
// Calculate target dimensions
// TODO: For wider browser support, see what `draggabilly` does (prob uses `get-size`)
// bounds = {x, y, width, height, top, right, bottom, left}
// http://youmightnotneedjquery.com/#offset
// DEV: This is our poor man's `_.extend`
var _bounds = targetEl.getBoundingClientRect();
var bounds = this.bounds = {};
['x', 'y', 'width', 'height', 'top', 'right', 'bottom', 'left'].forEach(function copyKey (key) {
bounds[key] = _bounds[key];
});
this.bounds.top += document.documentElement.scrollTop + document.body.scrollTop;
this.bounds.left += document.documentElement.scrollLeft + document.body.scrollLeft;
}
// Inherit prototype from Unidragger
Overlay.prototype = Object.create(Unidragger.prototype);
Overlay.prototype.dragStart = function (evt, pointer) {
// If we don't have an overlay element, create one now
if (!this.overlayEl) {
this.overlayEl = document.createElement('div');
this.overlayEl.className = 'overlay';
document.body.appendChild(this.overlayEl);
}
// Update our box position
this.update(evt, pointer, {x: 0, y: 0});
};
Overlay.prototype.update = function (evt, pointer, moveVector) {
// If the move vector's X dimension is normal
// DEV: We use `pointerDownPoint` instead of `dragStartPoint` for better cursor positioning
var left, top, width, height;
if (moveVector.x >= 0) {
left = this.pointerDownPoint.x;
width = moveVector.x;
// Otherwise (inverted), use opposite parameters)
} else {
// DEV: Technically we are subtracting moveVector as it's a negative value
left = this.pointerDownPoint.x + moveVector.x;
width = (-1 * moveVector.x);
}
// Similar behavior for Y dimension
if (moveVector.y >= 0) {
top = this.pointerDownPoint.y;
height = moveVector.y;
} else {
top = this.pointerDownPoint.y + moveVector.y;
height = (-1 * moveVector.y);
}
// Restrict our dimensions so we don't go out of bounds
// DEV: We limit top/left first as height/width offsets are dependent
if (top < this.bounds.top) {
// Remove excess distance from height to account for overflow
// height += 100 - 200 /* higher on page - lower on page */;
height += top - this.bounds.top;
top = this.bounds.top;
}
if (left < this.bounds.left) {
width += left - this.bounds.left;
left = this.bounds.left;
}
// bottomEdgeFromTop = 100 + 800 /* 900 */
var bottomEdgeFromTop = this.bounds.top + this.bounds.height;
if (top + height > bottomEdgeFromTop) {
// height = 900 - 300 /* 600 */
height = bottomEdgeFromTop - top;
}
var rightEdgeFromLeft = this.bounds.left + this.bounds.width;
if (left + width > rightEdgeFromLeft) {
width = rightEdgeFromLeft - left;
}
// Update our element position
this.overlayEl.style.left = left + 'px';
this.overlayEl.style.width = width + 'px';
this.overlayEl.style.top = top + 'px';
this.overlayEl.style.height = height + 'px';
// Emit an update event
this.emitEvent('change:overlay', [evt, pointer, {
absolute: {
left: left,
width: width,
top: top,
height: height
},
relative: {
left: left - this.bounds.left,
width: width,
top: top - this.bounds.top,
height: height
}
}]);
};
Overlay.prototype.dragMove = function(evt, pointer, moveVector) {
this.update(evt, pointer, moveVector);
};
// Make image draggable
var imgOverlay = new Overlay(imgEl);
// When our overlay moves, update the coordinates
var overlayInfoEl = document.querySelector('#overlay-info');
imgOverlay.on('change:overlay', function handleChangeOverlay (evt, pointer, overlayInfo) {
var relativeInfo = overlayInfo.relative;
overlayInfoEl.textContent = 'left:' + relativeInfo.left + ', ' +
'top: ' + relativeInfo.top + ', ' +
'width: ' + relativeInfo.width + ', ' +
'height: ' + relativeInfo.height;
});
// TODO: Fake drag move for easier testing
// https://github.com/twolfson/mockdesk/blob/0.14.2/lib/js/scripts/drag-rectangle.js
});
// Load in our dependencies
var express = require('express');
// Define our constants
exports.LISTEN_PORT = 3000;
exports.LISTEN_HOSTNAME = 'localhost';
// Define our main funciton
function main() {
// Create our server
var app = express();
// Configure our views
// http://expressjs.com/en/guide/using-template-engines.html
app.set('views', __dirname);
app.set('view engine', 'jade');
// Define simple route
app.get('/', function rootShow (req, res, next) {
// Pass along query string variables directly as render data
res.render('index.jade', req.query);
});
// START: Prototype setup
// Host gemini images directly
app.use('/gemini-report', express.static(__dirname + '/gemini-report'));
// Define our routes
app.get('/prototype', function prototypeShow (req, res, next) {
res.render('prototype.jade');
});
app.get('/performance', function performanceShow (req, res, next) {
res.render('performance.jade');
});
// END: Prototype setup
// Listen on our port
app.listen(exports.LISTEN_PORT, exports.LISTEN_HOSTNAME);
}
// If we are the main script
if (require.main === module) {
// Start our server
main();
// Notify user of our server running
console.log('Server running at http://' + exports.LISTEN_HOSTNAME + ':' + exports.LISTEN_PORT + '/');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment