diff --git a/package-lock.json b/package-lock.json index 4a537ee1..02db0425 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7839,6 +7839,21 @@ "minimatch": "^3.0.4" } }, + "image-q": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-2.1.2.tgz", + "integrity": "sha1-husyiz8bK7RiP5WjTdHOTLbOz+k=", + "requires": { + "@types/node": "9.4.6" + }, + "dependencies": { + "@types/node": { + "version": "9.4.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-9.4.6.tgz", + "integrity": "sha512-CTUtLb6WqCCgp6P59QintjHWqzf4VL1uPA27bipLAPxFqrtK1gEYllePzTICGqQ8rYsCbpnsNypXjjDzGAAjEQ==" + } + } + }, "immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -11285,11 +11300,6 @@ "any-promise": "^1.3.0" } }, - "rgbquant": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/rgbquant/-/rgbquant-1.1.2.tgz", - "integrity": "sha1-2V6Imo/LHmwaT6LMyL46QaL80YU=" - }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", diff --git a/package.json b/package.json index cf7c00aa..b168fcdb 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "global": "^4.3.2", "hammerjs": "^2.0.8", "http-proxy-agent": "^4.0.1", + "image-q": "^2.1.2", "ip-address": "^6.3.0", "isomorphic-fetch": "^2.2.1", "js-file-download": "^0.4.12", @@ -72,7 +73,6 @@ "redux-logger": "^3.0.6", "redux-persist": "^6.0.0", "redux-thunk": "^2.2.0", - "rgbquant": "^1.1.2", "sequelize": "^5.21.7", "sharp": "^0.25.2", "startaudiocontext": "^1.2.1", diff --git a/src/components/Converter.jsx b/src/components/Converter.jsx index 691d99f7..307c0ad7 100644 --- a/src/components/Converter.jsx +++ b/src/components/Converter.jsx @@ -6,7 +6,7 @@ import React, { useState, useEffect } from 'react'; import { connect } from 'react-redux'; import fileDownload from 'js-file-download'; -import RgbQuant from 'rgbquant'; +import { utils, applyPalette } from 'image-q'; import printGIMPPalette from '../core/exportGPL'; import type { State } from '../reducers'; @@ -42,8 +42,7 @@ function downloadOutput() { function readFile( file, selectFile, - selectScaleWidth, - selectScaleHeight, + setScaleData, ) { if (!file) { return; @@ -52,8 +51,12 @@ function readFile( fr.onload = () => { const img = new Image(); img.onload = () => { - selectScaleWidth(img.width); - selectScaleHeight(img.height); + setScaleData({ + enabled: false, + width: img.width, + height: img.height, + aa: true, + }); selectFile(img); }; img.src = fr.result; @@ -106,82 +109,123 @@ function addGrid(img, lightGrid, offsetX, offsetY) { function scaleImage(img, width, height, doAA) { const can = document.createElement('canvas'); - const ctx = can.getContext('2d'); + const ctxo = can.getContext('2d'); can.width = width; can.height = height; + const scaleX = width / img.width; + const scaleY = height / img.height; if (doAA) { - ctx.imageSmoothingEnabled = true; - ctx.mozImageSmoothingEnabled = true; - ctx.webkitImageSmoothingEnabled = true; - ctx.msImageSmoothingEnabled = true; - } else { - ctx.imageSmoothingEnabled = false; - ctx.mozImageSmoothingEnabled = false; - ctx.webkitImageSmoothingEnabled = false; - ctx.msImageSmoothingEnabled = false; + // scale with canvas for antialiasing + ctxo.save(); + ctxo.scale(scaleX, scaleY); + ctxo.drawImage(img, 0, 0); + ctxo.restore(); + return can; } - ctx.save(); - ctx.scale(width / img.width, height / img.height); - ctx.drawImage(img, 0, 0); - ctx.restore(); + // scale manually + const imdo = ctxo.createImageData(width, height); + const { data: datao } = imdo; + const cani = document.createElement('canvas'); + cani.width = img.width; + cani.height = img.height; + const ctxi = cani.getContext('2d'); + ctxi.drawImage(img, 0, 0); + const imdi = ctxi.getImageData(0, 0, img.width, img.height); + const { data: datai } = imdi; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + let posi = (Math.round(x / scaleX) + Math.round(y / scaleY) + * img.width) * 4; + let poso = (x + y * width) * 4; + datao[poso++] = datai[posi++]; + datao[poso++] = datai[posi++]; + datao[poso++] = datai[posi++]; + datao[poso] = datai[posi]; + } + } + ctxo.putImageData(imdo, 0, 0); return can; } -function renderOutputImage( - colors, - selectedFile, - selectedStrategy, - selectedSerp, - selectedColorDist, - selectedDithDelta, - selectedAddGrid, - selectedLightGrid, - selectedGridOffsetX, - selectedGridOffsetY, - selectedDoScaling, - selectedScaleWidth, - selectedScaleHeight, - selectedScaleAA, -) { - if (!selectedFile) { +let newOpts = null; +let rendering = false; +async function renderOutputImage(opts) { + if (!opts.file) { return; } - let image = selectedFile; - if (selectedDoScaling) { - image = scaleImage( - image, - selectedScaleWidth, - selectedScaleHeight, - selectedScaleAA, - ); + if (rendering) { + newOpts = opts; + return; } - const rgbQuant = new RgbQuant({ - colors: colors.length, - dithKern: selectedStrategy, - dithDelta: selectedDithDelta / 100, - dithSerp: selectedSerp, - palette: colors, - reIndex: false, - useCache: false, - colorDist: selectedColorDist, - }); - rgbQuant.sample(image); - rgbQuant.palette(); - const pxls = rgbQuant.reduce(image); - image = drawPixels(pxls, image.width, image.height); - const output = document.getElementById('imgoutput'); - if (selectedAddGrid) { - image = addGrid( - image, - selectedLightGrid, - selectedGridOffsetX, - selectedGridOffsetY, - ); + rendering = true; + let renderOpts = opts; + while (renderOpts) { + newOpts = null; + const { + file, dither, grid, scaling, + } = renderOpts; + if (file) { + let image = file; + let pointContainer = null; + if (scaling.enabled) { + // scale + const { width, height, aa } = scaling; + image = scaleImage( + file, + width, + height, + aa, + ); + pointContainer = utils.PointContainer.fromHTMLCanvasElement(image); + } else { + pointContainer = utils.PointContainer.fromHTMLImageElement(image); + } + // dither + const { colors, strategy, colorDist } = dither; + const palette = new utils.Palette(); + palette.add(utils.Point.createByRGBA(0, 0, 0, 0)); + colors.forEach((clr) => { + const [r, g, b] = clr; + const point = utils.Point.createByRGBA(r, g, b, 255); + palette.add(point); + }); + const progEl = document.getElementById('qprog'); + progEl.style.display = 'block'; + // eslint-disable-next-line no-await-in-loop + pointContainer = await applyPalette(pointContainer, palette, { + colorDistanceFormula: colorDist, + imageQuantization: strategy, + onProgress: (progress) => { + progEl.innerHTML = `Loading... ${Math.round(progress)} %`; + }, + }); + progEl.style.display = 'none'; + image = drawPixels( + pointContainer.toUint8Array(), + image.width, + image.height, + ); + // grid + if (grid.enabled) { + const { light, offsetX, offsetY } = grid; + image = addGrid( + image, + light, + offsetX, + offsetY, + ); + } + // draw + const output = document.getElementById('imgoutput'); + output.width = image.width; + output.height = image.height; + const ctx = output.getContext('2d'); + ctx.drawImage(image, 0, 0); + } + // render again if requested in the meantime + renderOpts = newOpts; } - output.width = image.width; - output.height = image.height; - const ctx = output.getContext('2d'); - ctx.drawImage(image, 0, 0); + rendering = false; } @@ -191,41 +235,39 @@ function Converter({ }) { const [selectedCanvas, selectCanvas] = useState(canvasId); const [selectedFile, selectFile] = useState(null); - const [selectedStrategy, selectStrategy] = useState(''); - const [selectedSerp, selectSerp] = useState(false); + const [selectedStrategy, selectStrategy] = useState('nearest'); const [selectedColorDist, selectColorDist] = useState('euclidean'); - const [selectedDithDelta, selectDithDelta] = useState(0); - const [selectedAddGrid, selectAddGrid] = useState(true); - const [selectedLightGrid, selectLightGrid] = useState(false); - const [selectedGridOffsetX, selectGridOffsetX] = useState(0); - const [selectedGridOffsetY, selectGridOffsetY] = useState(0); - const [selectedDoScaling, selectDoScaling] = useState(false); - const [selectedScaleWidth, selectScaleWidth] = useState(0); - const [selectedScaleHeight, selectScaleHeight] = useState(0); const [selectedScaleKeepRatio, selectScaleKeepRatio] = useState(true); - const [selectedScaleAA, selectScaleAA] = useState(false); + const [scaleData, setScaleData] = useState({ + enabled: false, + width: 10, + height: 10, + aa: true, + }); + const [gridData, setGridData] = useState({ + enabled: true, + light: false, + offsetX: 0, + offsetY: 0, + }); const input = document.createElement('canvas'); useEffect(() => { if (selectedFile) { const canvas = canvases[selectedCanvas]; - renderOutputImage( - canvas.colors.slice(canvas.cli), - selectedFile, - selectedStrategy, - selectedSerp, - selectedColorDist, - selectedDithDelta, - selectedAddGrid, - selectedLightGrid, - selectedGridOffsetX, - selectedGridOffsetY, - selectedDoScaling, - selectedScaleWidth, - selectedScaleHeight, - selectedScaleAA, - ); + const dither = { + colors: canvas.colors.slice(canvas.cli), + strategy: selectedStrategy, + colorDist: selectedColorDist, + }; + renderOutputImage({ + file: selectedFile, + dither, + grid: gridData, + scaling: scaleData, + }); } else { + // draw gray rectanglue if no file selected const output = document.getElementById('imgoutput'); const ctx = output.getContext('2d'); output.width = 128; @@ -235,6 +277,19 @@ function Converter({ } }); + const { + enabled: gridEnabled, + light: gridLight, + offsetX: gridOffsetX, + offsetY: gridOffsetY, + } = gridData; + const { + enabled: scalingEnabled, + width: scalingWidth, + height: scalingHeight, + aa: scalingAA, + } = scaleData; + return (

Choose Canvas:  @@ -291,7 +346,7 @@ function Converter({ const fileSel = evt.target; const file = (!fileSel.files || !fileSel.files[0]) ? null : fileSel.files[0]; - readFile(file, selectFile, selectScaleWidth, selectScaleHeight); + readFile(file, selectFile, setScaleData); }} />

Choose Strategy:  @@ -301,20 +356,18 @@ function Converter({ selectStrategy(sel.options[sel.selectedIndex].value); }} > - { - ['FloydSteinberg', - 'Stucki', - 'Atkinson', - 'Jarvis', - 'Burkes', - 'Sierra', - 'TwoSierra', - 'SierraLite', - 'FalseFloydSteinberg'].map((strat) => ( + ['nearest', + 'riemersma', + 'floyd-steinberg', + 'false-floyd-steinberg', + 'stucki', + 'atkinson', + 'jarvis', + 'burkes', + 'sierra', + 'two-sierra', + 'sierra-lite'].map((strat) => (

- - Choose Color Mode:  + { - const colorDist = (e.target.checked) - ? 'euclidean' : 'manhatten'; - selectColorDist(colorDist); - }} - /> - Manhatten Color Distance - -

Dithering Delta:  - { - selectDithDelta(e.target.value); - }} - />% + > + { + ['cie94-textiles', + 'cie94-graphic-arts', + 'ciede2000', + 'color-metric', + 'euclidean', + 'euclidean-bt709-noalpha', + 'euclidean-bt709', + 'manhattan', + 'manhattan-bt709', + 'manhattan-nommyde', + 'pngquant'].map((strat) => ( + + )) + } +

{ - selectAddGrid(e.target.checked); + setGridData({ + ...gridData, + enabled: e.target.checked, + }); }} /> Add Grid (uncheck if you need a 1:1 template)

- {(selectedAddGrid) + {(gridEnabled) ? (
{ - selectLightGrid(e.target.checked); + setGridData({ + ...gridData, + light: e.target.checked, + }); }} /> Light Grid @@ -392,9 +446,12 @@ function Converter({ min="0" max="10" style={{ width: '2em' }} - value={selectedGridOffsetX} + value={gridOffsetX} onChange={(e) => { - selectGridOffsetX(e.target.value); + setGridData({ + ...gridData, + offsetX: e.target.value, + }); }} />% @@ -405,9 +462,12 @@ function Converter({ min="0" max="10" style={{ width: '2em' }} - value={selectedGridOffsetY} + value={gridOffsetY} onChange={(e) => { - selectGridOffsetY(e.target.value); + setGridData({ + ...gridData, + offsetY: e.target.value, + }); }} />% @@ -417,14 +477,17 @@ function Converter({

{ - selectDoScaling(e.target.checked); + setScaleData({ + ...scaleData, + enabled: e.target.checked, + }); }} /> Scale Image

- {(selectedDoScaling) + {(scalingEnabled) ? (
{ - const newWidth = e.target.value; + const newWidth = (e.target.value > 1024) + ? 1024 : e.target.value; if (!newWidth) return; if (selectedScaleKeepRatio && selectedFile) { const ratio = selectedFile.width / selectedFile.height; const newHeight = Math.round(newWidth / ratio); - selectScaleHeight(newHeight); + setScaleData({ + ...scaleData, + width: newWidth, + height: newHeight, + }); + return; } - selectScaleWidth(newWidth); + setScaleData({ + ...scaleData, + width: newWidth, + }); }} />% @@ -459,18 +531,27 @@ function Converter({ type="number" step="1" min="1" - max="16564" + max="1024" style={{ width: '3em' }} - value={selectedScaleHeight} + value={scalingHeight} onChange={(e) => { - const nuHeight = e.target.value; + const nuHeight = (e.target.value > 1024) + ? 1024 : e.target.value; if (!nuHeight) return; if (selectedScaleKeepRatio && selectedFile) { const ratio = selectedFile.width / selectedFile.height; const nuWidth = Math.round(ratio * nuHeight); - selectScaleWidth(nuWidth); + setScaleData({ + ...scaleData, + width: nuWidth, + height: nuHeight, + }); + return; } - selectScaleHeight(nuHeight); + setScaleData({ + ...scaleData, + height: nuHeight, + }); }} />% @@ -487,8 +568,12 @@ function Converter({

{ - selectScaleAA(e.target.checked); + setScaleData({ + ...scaleData, + aa: e.target.checked, + }); }} /> Anti Aliasing @@ -497,8 +582,11 @@ function Converter({ type="button" onClick={() => { if (selectedFile) { - selectScaleWidth(selectedFile.width); - selectScaleHeight(selectedFile.height); + setScaleData({ + ...scaleData, + width: selectedFile.width, + height: selectedFile.height, + }); } }} > @@ -507,6 +595,7 @@ function Converter({

) : null} +

{ + let fileContent = fs.readFileSync(file,'utf8'); + fileContent = fileContent.replace(regex, 'core-js/features/set-immediate'); + fs.writeFileSync(file, fileContent); + }); + } catch { + console.log('Error while patching image-q'); + } return new Promise((resolve, reject) => { webpack(webpackConfig).run((err, stats) => { if (err) {