src/schemas/Image.js
const fs = require("fs");
const path = require("path");
const farmhash = require("farmhash");
const imageinfo = require("imageinfo");
let gm = require("gm");
const async = require("async");
const versioner = require("mongoose-version");
const record = require("../lib/record");
const models = require("../lib/models");
const db = require("../lib/db");
const similar = require("../lib/similar");
const config = require("../lib/config");
const options = require("../lib/options");
const urls = require("../lib/urls")(options);
// Add the ability to provide an explicit bath to the GM binary
/* istanbul ignore if */
if (config.GM_PATH) {
gm = gm.subClass({appPath: config.GM_PATH});
}
const Image = new db.schema({
// An ID for the image in the form: SOURCE/IMAGENAME
_id: String,
// The date that this item was created
created: {
type: Date,
default: Date.now,
},
// The date that this item was updated
modified: {
type: Date,
},
// The most recent batch in which the image was uploaded
// NOTE(jeresig): This is not required as the image could have
// been uploaded for use in a search.
batch: {
type: String,
ref: "ImageImport",
},
// The source that the image is associated with
source: {
type: String,
required: true,
},
// The name of the original file (e.g. `foo.jpg`)
fileName: {
type: String,
required: true,
},
// Full URL of where the image came.
url: String,
// The hashed contents of the image
hash: {
type: String,
required: true,
},
// The width of the image
width: {
type: Number,
required: true,
min: 1,
},
// The height of the image
height: {
type: Number,
required: true,
min: 1,
},
// Keep track of if the image needs to index its image similarity
needsSimilarIndex: {
type: Boolean,
default: false,
},
// Keep track of if the image needs to update its image similarity
needsSimilarUpdate: {
type: Boolean,
default: false,
},
// Similar images (as determined by image similarity)
similarImages: [
{
// The ID of the visually similar image
_id: {
type: String,
required: true,
},
// The similarity score between the images
score: {
type: Number,
required: true,
min: 1,
},
},
],
});
Image.methods = {
getFilePath() {
return path.resolve(
this.getSource().getDirBase(),
`images/${this.hash}.jpg`,
);
},
_getURL(type) {
return this.source === "uploads"
? urls.genUpload(`/${this.source}/${type}/${this.hash}.jpg`)
: urls.genData(`/${this.source}/${type}/${this.hash}.jpg`);
},
getOriginalURL() {
return this._getURL("images");
},
getScaledURL() {
return this._getURL("scaled");
},
getThumbURL() {
return this._getURL("thumbs");
},
getSource() {
if (this.source !== "uploads") {
return models("Source").getSource(this.source);
}
},
relatedRecords(callback) {
async.map(
Object.keys(options.types),
(type, callback) => {
record(type).find({images: this._id}, callback);
},
(err, recordsList) => {
if (err) {
return callback(err);
}
callback(
null,
recordsList.reduce((all, records) => all.concat(records)),
);
},
);
},
canIndex() {
return this.width >= 150 && this.height >= 150;
},
updateSimilarity(callback) {
// Skip small images
if (!this.canIndex()) {
return process.nextTick(callback);
}
similar.similar(this.hash, (err, matches) => {
if (err || !matches) {
return callback(err);
}
let maxScore = 0;
// Turn the scores into a % of the # of hits in the original
// image (this gives a more useful number for display/analysis)
const matchPercent = score =>
maxScore
? Math.max(Math.round((score / maxScore) * 100), 1)
: 100;
// Ignore records with too many matches
if (matches.length > 50) {
return callback();
}
async.mapLimit(
matches,
4,
(match, callback) => {
// Skip matches for the image itself
if (match.id === this.hash) {
maxScore = match.score;
return callback();
}
models("Image").findOne(
{
hash: match.id,
},
(err, image) => {
if (err || !image) {
return callback();
}
callback(null, {
image,
score: matchPercent(match.score),
});
},
);
},
(err, matchingImages) => {
let matches = matchingImages.filter(match => match);
if (options.filterImageSimilarity && matches.length > 0) {
matches = options.filterImageSimilarity(this, matches);
}
this.similarImages = matches.map(({image, score}) => ({
_id: image._id,
score,
}));
// Sync the similarity with other images
this.syncSimiliarity(matches, callback);
},
);
});
},
syncSimiliarity(similarImages, callback) {
async.eachLimit(
similarImages,
4,
({image, score}, callback) => {
const hasSimilarity = image.similarImages.some(
({_id}) => this._id === _id,
);
if (hasSimilarity) {
return process.nextTick(callback);
}
image.similarImages.push({
_id: this._id,
score,
});
image.save(callback);
},
callback,
);
},
indexSimilarity(callback) {
similar.idIndexed(this.hash, (err, indexed) => {
/* istanbul ignore if */
if (err) {
return callback(err);
} else if (indexed) {
return callback(null, true);
}
const file = this.getFilePath();
similar.add(file, this.hash, err => {
// Ignore small images, we just won't index them
/* istanbul ignore if */
if (err && err.type !== "IMAGE_SIZE_TOO_SMALL") {
return callback(err);
} else if (err) {
return callback();
}
return callback(null, true);
});
});
},
markRelatedRecordsForUpdate(callback) {
this.relatedRecords((err, records) => {
/* istanbul ignore if */
if (err) {
return callback(err);
}
async.eachLimit(
records,
1,
(record, callback) => {
record.needsSimilarUpdate = true;
record.save(callback);
},
callback,
);
});
},
linkToRecords(callback) {
const imageId = this._id;
async.eachSeries(
Object.keys(options.types),
(type, callback) => {
record(type).find({missingImages: imageId}, (err, records) => {
async.eachLimit(
records,
4,
(record, callback) => {
record.images.push(imageId);
record.missingImages.remove(imageId);
record.save(callback);
},
callback,
);
});
},
callback,
);
},
canView(user) {
const source = this.getSource();
if (!source) {
return true;
}
return source.canView(user);
},
};
Image.statics = {
fromFile(batch, file, callback) {
const Image = models("Image");
const Source = models("Source");
const filePath = file.path || file;
const fileName = file.name || path.basename(filePath);
const source = batch.source;
const _id = `${source}/${fileName}`;
const sourceDir = Source.getSource(source).getDirBase();
const warnings = [];
this.findById(_id, (err, image) => {
/* istanbul ignore if */
if (err) {
return callback(new Error("ERROR_RETRIEVING"));
}
const creating = !image;
this.processImage(filePath, sourceDir, (err, hash) => {
if (err) {
return callback(new Error("MALFORMED_IMAGE"));
}
// The same image was uploaded, we can just skip the rest
if (!creating && hash === image.hash) {
return callback(null, image, warnings);
}
this.getSize(filePath, (err, size) => {
/* istanbul ignore if */
if (err) {
return callback(new Error("MALFORMED_IMAGE"));
}
const width = size.width;
const height = size.height;
if (width <= 1 || height <= 1) {
return callback(new Error("EMPTY_IMAGE"));
}
const data = {
_id,
batch: batch._id,
source,
fileName,
hash,
width,
height,
};
let model = image;
if (creating) {
model = new Image(data);
} else {
warnings.push("NEW_VERSION");
model.set(data);
}
if (!model.canIndex()) {
warnings.push("TOO_SMALL");
}
model.validate(err => {
/* istanbul ignore if */
if (err) {
return callback(new Error("ERROR_SAVING"));
}
callback(null, model, warnings);
});
});
});
});
},
processImage(sourceFile, baseDir, callback) {
return images.processImage(sourceFile, baseDir, callback);
},
getSize(fileName, callback) {
return images.getSize(fileName, callback);
},
indexSimilarity(callback) {
models("Image").findOne(
{
needsSimilarIndex: true,
},
(err, image) => {
if (err || !image) {
return callback(err);
}
console.log("Indexing Similarity", image._id);
image.indexSimilarity(err => {
/* istanbul ignore if */
if (err) {
console.error(err);
return callback(err);
}
image.needsSimilarIndex = false;
image.needsSimilarUpdate = true;
image.save(err => callback(err, true));
});
},
);
},
updateSimilarity(callback) {
models("Image").findOne(
{
needsSimilarUpdate: true,
},
(err, image) => {
if (err || !image) {
return callback(err);
}
/* istanbul ignore if */
if (config.NODE_ENV !== "test") {
console.log("Updating Image Similarity", image._id);
}
image.updateSimilarity(err => {
/* istanbul ignore if */
if (err && err.type !== "IMAGE_NOT_FOUND") {
console.error(err);
return callback(err);
}
image.needsSimilarUpdate = false;
image.save(err => {
/* istanbul ignore if */
if (err) {
console.error(err);
return callback(err);
}
image.markRelatedRecordsForUpdate(err => {
/* istanbul ignore if */
if (err) {
return callback(err);
}
callback(null, true);
});
});
});
},
);
},
queueBatchSimilarityIndex(batchID, callback) {
this.update(
{batch: batchID},
{needsSimilarIndex: true, needsSimilarUpdate: true},
{multi: true},
callback,
);
},
queueBatchSimilarityUpdate(sourceID, callback) {
this.update(
{source: sourceID},
{needsSimilarUpdate: true},
{multi: true},
callback,
);
},
};
/* istanbul ignore next */
Image.pre("save", function(next) {
// Always updated the modified time on every save
this.modified = new Date();
next();
});
const images = {
convert(inputStream, outputFile, config, callback) {
let stream = gm(inputStream).autoOrient();
if (config) {
stream = config(stream);
}
stream
.stream("jpg")
.on("error", err => {
callback(new Error(`Error converting file to JPEG: ${err}`));
})
.pipe(fs.createWriteStream(outputFile))
.on("finish", () => {
callback(null, outputFile);
});
},
parseSize(size) {
const parts = size.split("x");
return {
width: parseFloat(parts[0]),
height: parseFloat(parts[0]),
};
},
getSize(fileName, callback) {
fs.readFile(fileName, (err, data) => {
/* istanbul ignore if */
if (err) {
return callback(err);
}
const info = imageinfo(data);
callback(null, {
width: info.width,
height: info.height,
});
});
},
makeThumb(baseDir, fileName, callback) {
const imageFile = path.resolve(baseDir, "images", fileName);
const thumbFile = path.resolve(baseDir, "thumbs", fileName);
const size = this.parseSize(options.imageThumbSize);
this.convert(
fs.createReadStream(imageFile),
thumbFile,
img => {
return img.resize(size.width, size.height);
},
callback,
);
},
makeScaled(baseDir, fileName, callback) {
const imageFile = path.resolve(baseDir, "images", fileName);
const scaledFile = path.resolve(baseDir, "scaled", fileName);
const scaled = this.parseSize(options.imageScaledSize);
this.convert(
fs.createReadStream(imageFile),
scaledFile,
img => {
return img.resize(scaled.width, scaled.height, ">");
},
callback,
);
},
makeThumbs(fullPath, callback) {
const baseDir = path.resolve(path.dirname(fullPath), "..");
const fileName = path.basename(fullPath);
async.series(
[
callback => this.makeThumb(baseDir, fileName, callback),
callback => this.makeScaled(baseDir, fileName, callback),
],
err => {
/* istanbul ignore if */
if (err) {
return callback(
new Error(`Error converting thumbnails: ${err}`),
);
}
callback(null, [
path.resolve(baseDir, "thumbs", fileName),
path.resolve(baseDir, "scaled", fileName),
]);
},
);
},
hashImage(sourceFile, callback) {
fs.readFile(sourceFile, (err, buffer) => {
/* istanbul ignore if */
if (err) {
return callback(err);
}
callback(null, farmhash.hash32(buffer).toString());
});
},
processImage(sourceFile, baseDir, callback) {
let hash;
let imageFile;
const existsError = new Error("Already exists.");
async.series(
[
// Generate a hash for the incoming image file
callback => {
this.hashImage(sourceFile, (err, imageHash) => {
/* istanbul ignore if */
if (err) {
return callback(err);
}
hash = imageHash;
imageFile = path.resolve(
baseDir,
"images",
`${hash}.jpg`,
);
// Avoid doing the rest of this if it already exists
fs.stat(imageFile, (err, stats) => {
callback(stats ? existsError : null);
});
});
},
// Convert the image into our standard format
callback =>
this.convert(
fs.createReadStream(sourceFile),
imageFile,
null,
callback,
),
// Generate thumbnails based on the image
callback => this.makeThumbs(imageFile, callback),
],
err => {
callback(err === existsError ? null : err, hash);
},
);
},
};
Image.plugin(versioner, {
collection: `image_versions`,
suppressVersionIncrement: false,
suppressRefIdIndex: false,
refIdType: String,
removeVersions: false,
strategy: "collection",
mongoose: db.mongoose,
});
module.exports = Image;