From 88651e6e489a0c7e108c1c27eaeb992c0ea7ee41 Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Tue, 5 Feb 2019 14:15:32 -0500 Subject: [PATCH] Gif upload v1: sprites, costumes --- package.json | 1 + .../sprite-selector/sprite-selector.jsx | 2 +- .../stage-selector/stage-selector.jsx | 2 +- src/containers/costume-tab.jsx | 16 +++-- src/lib/file-uploader.js | 23 +++++-- src/lib/gif-decoder.js | 69 +++++++++++++++++++ 6 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 src/lib/gif-decoder.js diff --git a/package.json b/package.json index cbefa3954..8f94a51f1 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "lodash.throttle": "4.0.1", "minilog": "3.1.0", "mkdirp": "^0.5.1", + "omggif": "1.0.9", "papaparse": "4.6.2", "postcss-import": "^12.0.0", "postcss-loader": "^3.0.0", diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx index d834f43b0..08e18844e 100644 --- a/src/components/sprite-selector/sprite-selector.jsx +++ b/src/components/sprite-selector/sprite-selector.jsx @@ -120,7 +120,7 @@ const SpriteSelectorComponent = function (props) { title: intl.formatMessage(messages.addSpriteFromFile), img: fileUploadIcon, onClick: onFileUploadClick, - fileAccept: '.svg, .png, .jpg, .jpeg, .sprite2, .sprite3', + fileAccept: '.svg, .png, .jpg, .jpeg, .sprite2, .sprite3, .gif', fileChange: onSpriteUpload, fileInput: spriteFileInput, fileMultiple: true diff --git a/src/components/stage-selector/stage-selector.jsx b/src/components/stage-selector/stage-selector.jsx index db89a67c5..d7a4df672 100644 --- a/src/components/stage-selector/stage-selector.jsx +++ b/src/components/stage-selector/stage-selector.jsx @@ -102,7 +102,7 @@ const StageSelector = props => { title: intl.formatMessage(messages.addBackdropFromFile), img: fileUploadIcon, onClick: onBackdropFileUploadClick, - fileAccept: '.svg, .png, .jpg, .jpeg', // Bitmap coming soon + fileAccept: '.svg, .png, .jpg, .jpeg, .gif', fileChange: onBackdropFileUpload, fileInput: fileInputRef, fileMultiple: true diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index 3817c4f3c..85e5dedc5 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -158,11 +158,15 @@ class CostumeTab extends React.Component { downloadBlob(`${item.name}.${item.asset.dataFormat}`, blob); } handleNewCostume (costume, fromCostumeLibrary) { - if (fromCostumeLibrary) { - this.props.vm.addCostumeFromLibrary(costume.md5, costume); - } else { - this.props.vm.addCostume(costume.md5, costume); - } + const costumes = Array.isArray(costume) ? costume : [costume]; + + costumes.forEach(c => { + if (fromCostumeLibrary) { + this.props.vm.addCostumeFromLibrary(c.md5, c); + } else { + this.props.vm.addCostume(c.md5, c); + } + }); } handleNewBlankCostume () { const name = this.props.vm.editingTarget.isStage ? @@ -291,7 +295,7 @@ class CostumeTab extends React.Component { title: intl.formatMessage(addFileMessage), img: fileUploadIcon, onClick: this.handleFileUploadClick, - fileAccept: '.svg, .png, .jpg, .jpeg', + fileAccept: '.svg, .png, .jpg, .jpeg, .gif', fileChange: this.handleCostumeUpload, fileInput: this.setFileInput, fileMultiple: true diff --git a/src/lib/file-uploader.js b/src/lib/file-uploader.js index 483f31edf..463265d21 100644 --- a/src/lib/file-uploader.js +++ b/src/lib/file-uploader.js @@ -1,6 +1,7 @@ import {BitmapAdapter} from 'scratch-svg-renderer'; import log from './log.js'; import randomizeSpritePosition from './randomize-sprite-position.js'; +import gifDecoder from './gif-decoder'; /** * Extract the file name given a string of the form fileName + ext @@ -114,6 +115,19 @@ const costumeUpload = function (fileData, fileType, costumeName, storage, handle assetType = storage.AssetType.ImageBitmap; break; } + case 'image/gif': { + let costumes = []; + const onFrame = (frameNumber, dataUrl) => { + costumeUpload(dataUrl, 'image/png', `${costumeName}-${frameNumber}`, storage, costumes_ => { + costumes = costumes.concat(costumes_); + }); + }; + const onDone = () => { + handleCostume(costumes); + }; + gifDecoder(fileData, {onFrame, onDone}); + return; // Abandon this load, do not try to load gif itself + } default: log.warn(`Encountered unexpected file type: ${fileType}`); return; @@ -128,7 +142,7 @@ const costumeUpload = function (fileData, fileType, costumeName, storage, handle costumeFormat, dataBuffer ); - handleCostume(vmCostume); + handleCostume([vmCostume]); }; if (costumeFormat === storage.DataFormat.SVG) { @@ -197,9 +211,10 @@ const spriteUpload = function (fileData, fileType, spriteName, storage, handleSp } case 'image/svg+xml': case 'image/png': - case 'image/jpeg': { + case 'image/jpeg': + case 'image/gif': { // Make a sprite from an image by making it a costume first - costumeUpload(fileData, fileType, `${spriteName}-${costumeName}`, storage, (vmCostume => { + costumeUpload(fileData, fileType, `${spriteName}-${costumeName}`, storage, (vmCostumes => { const newSprite = { name: spriteName, isStage: false, @@ -213,7 +228,7 @@ const spriteUpload = function (fileData, fileType, spriteName, storage, handleSp currentCostume: 0, blocks: {}, variables: {}, - costumes: [vmCostume], + costumes: vmCostumes, sounds: [] // TODO are all of these necessary? }; randomizeSpritePosition(newSprite); diff --git a/src/lib/gif-decoder.js b/src/lib/gif-decoder.js new file mode 100644 index 000000000..c6cc50ece --- /dev/null +++ b/src/lib/gif-decoder.js @@ -0,0 +1,69 @@ +import {GifReader} from 'omggif'; + +export default (arrayBuffer, {onFrame, onDone}) => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const gifReader = new GifReader(new Uint8Array(arrayBuffer)); + canvas.width = gifReader.width; + canvas.height = gifReader.height; + + let imageData = ctx.createImageData(canvas.width, canvas.height); + let previousData = ctx.createImageData(canvas.width, canvas.height); + + const loadFrame = i => { + const framePixels = []; + gifReader.decodeAndBlitFrameRGBA(i, framePixels); + const {x, y, width, height, disposal} = gifReader.frameInfo(i); + + for (let row = 0; row < height; row++) { + for (let column = 0; column < width; column++) { + const indexOffset = 4 * (x + (y * canvas.width)); + const j = indexOffset + (4 * (column + (row * canvas.width))); + if (framePixels[j + 3]) { + imageData.data[j + 0] = framePixels[j + 0]; + imageData.data[j + 1] = framePixels[j + 1]; + imageData.data[j + 2] = framePixels[j + 2]; + imageData.data[j + 3] = framePixels[j + 3]; + } + } + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.putImageData(imageData, 0, 0); + + const dataUrl = canvas.toDataURL(); + + switch (disposal) { + case 0: + case 1: // Both are "keep previous frame" + previousData = ctx.getImageData(0, 0, canvas.width, canvas.height); + break; + case 2: // "Return to background", blank out the current frame + ctx.clearRect(x, y, width, height); + imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + break; + case 3: // "Restore to previous", copy previous data to current + imageData = ctx.createImageData(canvas.width, canvas.height); + imageData.data.set(previousData.data); + break; + default: + // Not defined, treat like do-not-dispose + previousData = ctx.getImageData(0, 0, canvas.width, canvas.height); + break; + + } + onFrame(i, dataUrl); + + if (i < gifReader.numFrames() - 1) { + setTimeout(() => { + loadFrame(i + 1); + }); + } else { + setTimeout(() => { + onDone(); + }); + } + }; + + loadFrame(0); +};