Как добавить водяной знак (watermark) на загруженные multer картинки?
Отправляю фото с фронта и получаю как массив файлов.
Как правильно реализовать добавления водяного знака (логотипа) на фото? Или лучше при загрузке сделать это на фронте?
Использую Jimp.
Фронт:
const uploadImage = async () => {
const imgArr = [...images] /// массив с input type file [file,file]
const formData = new FormData()
imgArr.map(img => {
formData.append('image', img)
})
const result = await Promise.resolve(axios.post(`${config.serverUrl}/api/images`,formData))
setImgId(result.data)
}
Ниже бекенд:
const { Router } = require('express');
const ImgRouter = Router();
const ImageModel = require('../models/ImageModel');
const multer = require('multer')
const path = require('path')
const watermark = require('jimp-watermark');
const UPLOAD_PATH = path.resolve(__dirname, 'path/to/uploadedFiles')
const fs = require('fs')
const upload = multer({
dest: UPLOAD_PATH,
limits: {fileSize: 1000000, files: 5}
})
// upload image
ImgRouter.post('/', upload.array('image', 5),async (req, res, next) => {
const images = req.files.map((file) => {
const waterImg = watermark.addWatermark(file.path,`${__dirname}/../assets/logo.png`) /// вот тут просто сохраняет отдельно файл, но не записывает в базу данных
return {
filename: file.filename,
originalname: file.originalname
}
})
ImageModel.insertMany(images, (err, result) => {
if (err) return res.sendStatus(404)
res.json(result)
})
})
// get image with id
ImgRouter.get('/:id', (req, res, next) => {
ImageModel.findOne({_id: req.params.id}, (err, image) => {
if (err) return res.sendStatus(404)
fs.createReadStream(path.resolve(UPLOAD_PATH, image.filename)).pipe(res)
})
})
module.exports = ImgRouter
Ответы (2 шт):
Если нет проблем с использованием библиотек в проекте, то можно воспользоваться чем-то, вроде jimp. Чтобы сделать задачу ещё проще есть jimp-watermark, который использует jimp и предоставляет API для накладывания водных знаков на изображения.
Вводная: у нас есть загрузка файлов реализованная использованием multer и мы хотим реализовать эффективное нанесение watermark-а. Эффективное - значит оно должно выполняться в момент загрузки и не добавлять лишней записи на диск.
Решения, которые предлагает en.so/medium и Igor Inozemtsev не отвечают критериям эффективности так как изменяют файл (картинку) уже после записи её на диск. Нужно думать над своим решением, отвечающим критериям эффективности.
Чтобы выполнять манипуляции с загружаемой картинкой "в момент загрузки" мы должны иметь доступ в readable stream или к буферу, с содержимым файла. Хотя, stream сам по себе нам не очень нужен, ну, понятно, что пока мы не загрузим всю последовательность чанков нам нечего изменять и измерять. Открываем документацию multer и ищем слова "stream" и "buffer". Не находим, зато в разделе Storage видим, что multer позволяет нам, кроме DiskStorage, использовать MemoryStorage - а вот и доступ к buffer. Но, ещё в документации есть раздел "Собственные движки для сохранения файлов" со ссылкой на гайд по написанию своего движка сохранения файла на диск. Как раз это нам и нужно!
Читаем Multer Storage Engine.
Данные файла будут переданы вам в виде потока (file.stream). Вы должны передать эти данные куда-нибудь, а когда закончите, вызовите
cbи сообщите некоторую информацию о файле.
Открываем исходник DiskStorage и пишем свой движок, который будет перед записью файла на диск накладывать на картинку watermark с помощью jimp:
/* eslint-disable class-methods-use-this, no-underscore-dangle */
// @ts-check
const Jimp = require('jimp');
const multer = require('multer');
const { promisify } = require('util');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');
const getStream = require('get-stream');
const FILESTORAGE = `${process.cwd()}/filestorage`;
const asyncCryptoRandomBytes = promisify(crypto.randomBytes);
/**
* @class
* @implements {multer.StorageEngine}
*/
class WatermarkInterceptorStorage {
/**
* @param {multer.DiskStorageOptions
* & {watermark: {
* path: string; margin_percentage?: number; opacity?: number;
* }}} opts
* @memberof WatermarkInterceptorStorage
*/
constructor(opts) {
if (opts.destination) {
this.destination = typeof opts.destination === 'function' ? promisify(opts.destination) : opts.destination;
}
if (opts.filename) {
this.filename = promisify(opts.filename);
}
if (!opts.watermark || !opts.watermark.path) {
throw new Error('UNDEF_WATERMARK');
}
this.watermark = Jimp.read(opts.watermark.path); // filepath or url
this.watermark_opts = opts.watermark;
}
async getDestination(req, file, cb) {
/** @type {string} */
let dirpath;
if (typeof this.destination === 'string') {
dirpath = this.destination;
} else if (typeof this.destination === 'function') {
dirpath = await this.destination(req, file);
} else {
dirpath = os.tmpdir();
}
if (dirpath === null || dirpath === undefined || !/\S/.test(dirpath)) {
throw new Error('DESTINATION_WRONG');
}
await fs.promises.mkdir(dirpath, { recursive: true, mode: '0775' });
if (cb) {
cb(null, dirpath);
}
return dirpath;
}
async getFilename(req, file, cb) {
/** @type {string} */
let filepath;
if (typeof this.filename === 'function') {
filepath = await this.filename(req, file);
} else {
filepath = (await asyncCryptoRandomBytes(16)).toString('hex');
}
if (cb) {
cb(null, filepath);
}
return filepath;
}
/**
*
*
* @param {Express.Request} req
* @param {Express.Multer.File} file
* @param {(error?: any, info?: Partial<Express.Multer.File>) => void} cb
* @returns
* @memberof WatermarkInterceptorStorage
*/
async _handleFile(req, file, cb) {
/** @type {string} */
let destination;
/** @type {string} */
let filename;
try {
[destination, filename] = await Promise.all([
this.getDestination(req, file),
this.getFilename(req, file)
]);
} catch (err) {
return cb(err);
}
const finalPath = path.join(destination, filename);
const buf = await getStream.buffer(file.stream);
const [image, watermark] = await Promise.all([
Jimp.read(buf),
this.watermark
]);
await watermark.resize(image.bitmap.width / 10, Jimp.AUTO);
const LOGO_MARGIN_PERCENTAGE = this.watermark_opts.margin_percentage || 2;
const xMargin = (image.bitmap.width * LOGO_MARGIN_PERCENTAGE) / 100;
const yMargin = (image.bitmap.width * LOGO_MARGIN_PERCENTAGE) / 100;
const X = image.bitmap.width - watermark.bitmap.width - xMargin;
const Y = image.bitmap.height - watermark.bitmap.height - yMargin;
await image.composite(
watermark,
X, Y,
{
mode: Jimp.BLEND_SCREEN,
opacitySource: this.watermark_opts.opacity || 1,
opacityDest: 1
}
);
await image.writeAsync(finalPath);
cb(null, {
destination,
filename,
path: finalPath,
size: (await image.getBufferAsync(file.mimetype)).length
});
}
_removeFile(req, file, cb) {
fs.unlink(file.path, cb);
}
}
Накладывание watermark делается на лету, без записи на диск. Накладывание watermark делается в _handleFile, можешь менять под свои нужды/параметры или вообще вставить текстовый watermark.
Создаём инстанс самого WatermarkInterceptorStorage:
const storage = new WatermarkInterceptorStorage({
destination: (req, file, cb) => {
const rand1 = `${Math.trunc(100 * Math.random())}`.padStart(2, '0');
const rand2 = `${Math.trunc(100 * Math.random())}`.padStart(2, '0');
const dirname = `${FILESTORAGE}/${rand1}/${rand2}/${Date.now()}`;
cb(null, dirname);
},
filename: (req, file, cb) => {
const filename = file.originalname || `${file.fieldname}.${file.mimetype.split('/')[1]}`;
const parsed = path.parse(filename);
cb(null, `${parsed.name}${parsed.ext.toLocaleLowerCase()}`);
},
watermark: {
path: `${process.cwd()}/logo-ru.png`,
margin_percentage: 3,
}
});
FILESTORAGE - константа в которой я храню путь к каталогу файлового хранилища, у меня это ПАПКА_ПРОЕКТА/filestorage.
destination - функция, которая вернёт каталог в который класть загружаемую картинку. мне не нравится подход, который предлагает multer, так что мой каталог будет выглядеть как /filestorage/35/90/1609878136753
filename - функция, которая сгененрирует с каким именем файл будет сохранён на диск. мне не нравится подход, который предлагает multer, так что моё имя файла будет равно оригинальному имени файла (а расширению файла сделаю lowercase, на всякий случай).
watermark.path - путь к watermark.
watermark.margin_percentage - сколько отступить от правого нижнего угла, в процентах
watermark.opacity - прозрачность watermark.
Создаём инстанс multer-овского загрузчика:
const upload = multer({
storage,
limits: {
fileSize: 50 * 1024 * 1024
}
});
и экспортируем этот upload из модуля
module.exports = {
upload
};
Применяем middleware(загрузчик) к роуту:
const { promisify } = require('util');
...
const formUpload = promisify(
upload.fields([
{ name: 'poster', maxCount: 1 },
{ name: 'slider' }
])
);
...
router.post(
'/',
async (req, res) => {
try {
await formUpload(req, res);
} catch (err) {
console.error(err);
return res.json({ error: 'invalid_file' });
}
const data = { ...req.body, ...req.files };
if (req.header('accept') === 'application/json') {
res.json(data);
} else {
res.render('index', { data });
}
}
);
Готово и проверено под nodejs v15.5.0
Загрузчик:
/* eslint-disable class-methods-use-this, no-underscore-dangle */
// @ts-check
const Jimp = require('jimp');
const multer = require('multer');
const { promisify } = require('util');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');
const getStream = require('get-stream');
const FILESTORAGE = `${process.cwd()}/filestorage`;
const asyncCryptoRandomBytes = promisify(crypto.randomBytes);
/**
* @class
* @implements {multer.StorageEngine}
*/
class WatermarkInterceptorStorage {
/**
* @param {multer.DiskStorageOptions
* & {watermark: {
* path: string; margin_percentage?: number; opacity?: number;
* }}} opts
* @memberof WatermarkInterceptorStorage
*/
constructor(opts) {
if (opts.destination) {
this.destination = typeof opts.destination === 'function' ? promisify(opts.destination) : opts.destination;
}
if (opts.filename) {
this.filename = promisify(opts.filename);
}
if (!opts.watermark || !opts.watermark.path) {
throw new Error('UNDEF_WATERMARK');
}
this.watermark = Jimp.read(opts.watermark.path); // filepath or url
this.watermark_opts = opts.watermark;
}
async getDestination(req, file, cb) {
/** @type {string} */
let dirpath;
if (typeof this.destination === 'string') {
dirpath = this.destination;
} else if (typeof this.destination === 'function') {
dirpath = await this.destination(req, file);
} else {
dirpath = os.tmpdir();
}
if (dirpath === null || dirpath === undefined || !/\S/.test(dirpath)) {
throw new Error('DESTINATION_WRONG');
}
await fs.promises.mkdir(dirpath, { recursive: true, mode: '0775' });
if (cb) {
cb(null, dirpath);
}
return dirpath;
}
async getFilename(req, file, cb) {
/** @type {string} */
let filepath;
if (typeof this.filename === 'function') {
filepath = await this.filename(req, file);
} else {
filepath = (await asyncCryptoRandomBytes(16)).toString('hex');
}
if (cb) {
cb(null, filepath);
}
return filepath;
}
/**
*
*
* @param {Express.Request} req
* @param {Express.Multer.File} file
* @param {(error?: any, info?: Partial<Express.Multer.File>) => void} cb
* @returns
* @memberof WatermarkInterceptorStorage
*/
async _handleFile(req, file, cb) {
/** @type {string} */
let destination;
/** @type {string} */
let filename;
try {
[destination, filename] = await Promise.all([
this.getDestination(req, file),
this.getFilename(req, file)
]);
} catch (err) {
return cb(err);
}
const finalPath = path.join(destination, filename);
const buf = await getStream.buffer(file.stream);
const [image, watermark] = await Promise.all([
Jimp.read(buf),
this.watermark
]);
await watermark.resize(image.bitmap.width / 10, Jimp.AUTO);
const LOGO_MARGIN_PERCENTAGE = this.watermark_opts.margin_percentage || 2;
const xMargin = (image.bitmap.width * LOGO_MARGIN_PERCENTAGE) / 100;
const yMargin = (image.bitmap.width * LOGO_MARGIN_PERCENTAGE) / 100;
const X = image.bitmap.width - watermark.bitmap.width - xMargin;
const Y = image.bitmap.height - watermark.bitmap.height - yMargin;
await image.composite(
watermark,
X, Y,
{
mode: Jimp.BLEND_SCREEN,
opacitySource: this.watermark_opts.opacity || 1,
opacityDest: 1
}
);
await image.writeAsync(finalPath);
cb(null, {
destination,
filename,
path: finalPath,
size: (await image.getBufferAsync(file.mimetype)).length
});
}
_removeFile(req, file, cb) {
fs.unlink(file.path, cb);
}
}
const storage = new WatermarkInterceptorStorage({
destination: async (req, file, cb) => {
const rand1 = `${Math.trunc(100 * Math.random())}`.padStart(2, '0');
const rand2 = `${Math.trunc(100 * Math.random())}`.padStart(2, '0');
const dirname = `${FILESTORAGE}/${rand1}/${rand2}/${Date.now()}`;
cb(null, dirname);
},
filename: (req, file, cb) => {
const filename = file.originalname || `${file.fieldname}.${file.mimetype.split('/')[1]}`;
const parsed = path.parse(filename);
cb(null, `${parsed.name}${parsed.ext.toLocaleLowerCase()}`);
},
watermark: {
path: `${process.cwd()}/logo-ru.png`,
margin_percentage: 3,
}
});
const upload = multer({
storage,
limits: {
fileSize: 50 * 1024 * 1024
}
});
module.exports = {
upload
};
Роут:
// @ts-check
const { promisify } = require('util');
const express = require('express');
const { upload } = require('../../../filestorage/upload.watermark');
const router = express.Router();
router.get(
'/',
async (req, res) => {
const data = undefined;
res.render('index', { data });
}
);
const formUpload = promisify(
upload.fields([
{ name: 'poster', maxCount: 1 },
{ name: 'slider' }
])
);
router.post(
'/',
async (req, res) => {
try {
await formUpload(req, res);
} catch (err) {
console.error(err);
return res.json({ error: 'invalid_file' });
}
const data = { ...req.body, ...req.files };
if (req.header('accept') === 'application/json') {
res.json(data);
} else {
res.render('index', { data });
}
}
);
module.exports = router;
На фронте, для общего понимания:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Multer Example Page</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
<style type="text/css">
html, body {
height: 100%;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js" integrity="sha512-bZS47S7sPOxkjU/4Bt0zrhEtWx0y0CRkhEp8IckzK+ltifIIE9EMIMTuT/mEzoIMewUINruDBIR/jJnbguonqQ==" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js" integrity="sha512-bLT0Qm9VnAYZDflyKcBaQ2gg0hSYNQrJ8RilYldYQ1FxQYoCLtUjuuRuZo+fjqhx/qtq/1itJ0C2ejDxltZVFg==" crossorigin="anonymous" defer></script>
<script type="text/javascript">
async function wrap(ev, cb) {
const btn = ev.target;
ev.preventDefault();
btn.setAttribute('disabled', true);
const formData = getFormData();
let json;
try {
json = await cb(formData);
} catch (err) {
console.error(err);
}
btn.removeAttribute('disabled');
document.getElementById('exampleConsole').innerText = JSON.stringify(json, null, 2);
}
function getFormData() {
const formEl = document.getElementById('exampleForm');
const formData = new FormData(formEl);
for (const key of formData.keys()) {
const val = formData.get(key);
if (val === undefined || val === null || (typeof val === 'string' && !/\S/.test(val))) {
formData.delete(key);
}
}
return formData;
}
async function submitXHR(ev) {
return wrap(ev, (formData) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', window.location.href || '/');
xhr.setRequestHeader('Accept', 'application/json');
xhr.onerror = (err) => reject('REQUEST_ERROR');
xhr.onload = () => {
if (xhr.status !== 200) {
return reject(`Ошибка ${xhr.status}: ${xhr.statusText}`);
}
try {
resolve(JSON.parse(xhr.responseText));
} catch (err) {
reject(err);
}
};
xhr.send(formData);
});
});
}
async function submitFetch(ev) {
return wrap(ev, async (formData) => {
const response = await fetch(
window.location.href || '/',
{
method: 'POST',
body: formData,
headers: {
'Accept': 'application/json'
}
}
);
return response.json();
});
}
async function submitAxios(ev) {
return wrap(ev, async (formData) => {
const { data } = await axios.post(
window.location.href || '/',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
'Accept': 'application/json'
}
}
);
return data;
});
}
async function submitJQuery(ev) {
return wrap(ev, async (formData) => {
return new Promise((resolve, reject) => {
let q = $.ajax({
async: true,
type: 'POST',
url: window.location.href || '/',
data: formData,
cache: false,
processData: false,
contentType: false,
headers: {
'Accept': 'application/json'
},
success: (data) => resolve(data),
error: (request, status, error) => reject(error || status)
});
});
});
}
</script>
</head>
<body>
<div class="h-100">
<div class="container h-100">
<div class="d-flex align-items-center justify-content-center h-100">
<div class="d-flex flex-column">
<div class="text align-self-center p-2">
<form method="post" enctype="multipart/form-data" id="exampleForm">
<div class="mb-3">
<label for="exampleName" class="form-label">Name</label>
<input type="text" name="name" class="form-control" id="exampleName">
</div>
<div class="mb-3">
<label for="examplePoster" class="form-label">Single file as poster</label>
<input type="file" name="poster" class="form-control-file" id="examplePoster" accept="image/*">
</div>
<div class="mb-5">
<label for="exampleSlider" class="form-label">Multiple files as slider</label>
<input type="file" name="slider" class="form-control-file" id="exampleSlider" accept="image/*" multiple>
</div>
<button type="submit" class="btn btn-outline-primary">Submit</button>
<button type="button" class="btn btn-outline-primary" onclick="submitXHR(event)">XHR</button>
<button type="button" class="btn btn-outline-primary" onclick="submitFetch(event)">Fetch</button>
<button type="button" class="btn btn-outline-primary" onclick="submitAxios(event)">Axios</button>
<button type="button" class="btn btn-outline-primary" onclick="submitJQuery(event)">jQuery</button>
</form>
</div>
</div>
<div class="d-flex flex-column">
<div class="text align-self-center p-2">
<pre style="font-size: 10px" class="mt-3" id="exampleConsole"><%= JSON.stringify(data, null, 2) %></pre>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
Должен сказать, что сам jimp делает composite очень медленно - у меня на тестовую картинку тратится около секунды.
Возможно позже я предложу более быстрый движок работы с картинками (имхо, старый добрый libgd должен быть быстрее на порядок или два. но я пока не понял можно ли ему подсунуть буфер).
