diff --git a/src/app/WardrobePage/support/ItemLayerSupportModal.js b/src/app/WardrobePage/support/ItemLayerSupportModal.js index 15021e29..c08fea1b 100644 --- a/src/app/WardrobePage/support/ItemLayerSupportModal.js +++ b/src/app/WardrobePage/support/ItemLayerSupportModal.js @@ -23,6 +23,7 @@ import { } from "@chakra-ui/core"; import { ChevronRightIcon, ExternalLinkIcon } from "@chakra-ui/icons"; +import ItemLayerSupportUploadModal from "./ItemLayerSupportUploadModal"; import { OutfitLayers } from "../../components/OutfitPreview"; import SpeciesColorPicker from "../../components/SpeciesColorPicker"; import useOutfitAppearance, { @@ -48,6 +49,7 @@ function ItemLayerSupportModal({ pose: outfitState.pose, isValid: true, }); + const [uploadModalIsOpen, setUploadModalIsOpen] = React.useState(false); const supportSecret = useSupportSecret(); const toast = useToast(); @@ -175,6 +177,20 @@ function ItemLayerSupportModal({ > SWF + + + setUploadModalIsOpen(false)} + /> diff --git a/src/app/WardrobePage/support/ItemLayerSupportUploadModal.js b/src/app/WardrobePage/support/ItemLayerSupportUploadModal.js new file mode 100644 index 00000000..e9239600 --- /dev/null +++ b/src/app/WardrobePage/support/ItemLayerSupportUploadModal.js @@ -0,0 +1,388 @@ +import * as React from "react"; +import { + Button, + Box, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from "@chakra-ui/core"; +import { ExternalLinkIcon } from "@chakra-ui/icons"; + +/** + * ItemLayerSupportUploadModal helps Support users create and upload PNGs for + * broken appearance layers. Useful when the auto-converters are struggling, + * e.g. the SWF uses a color filter our server-side Flash player can't support! + */ +function ItemLayerSupportUploadModal({ item, itemLayer, isOpen, onClose }) { + const [step, setStep] = React.useState(1); + const [imageOnBlackUrl, setImageOnBlackUrl] = React.useState(null); + const [imageOnWhiteUrl, setImageOnWhiteUrl] = React.useState(null); + + const [imageWithAlphaUrl, setImageWithAlphaUrl] = React.useState(null); + const [numWarnings, setNumWarnings] = React.useState(null); + + const onUpload = React.useCallback( + (e) => { + const file = e.target.files[0]; + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = (re) => { + switch (step) { + case 1: + setImageOnBlackUrl(re.target.result); + setStep(2); + return; + case 2: + setImageOnWhiteUrl(re.target.result); + setStep(3); + return; + default: + throw new Error(`unexpected step ${step}`); + } + }; + reader.readAsDataURL(file); + }, + [step] + ); + + // Once both images are ready, merge them! + React.useEffect(() => { + if (!imageOnBlackUrl || !imageOnWhiteUrl) { + return; + } + + setImageWithAlphaUrl(null); + setNumWarnings(null); + + mergeIntoImageWithAlpha(imageOnBlackUrl, imageOnWhiteUrl).then( + ([url, numWarnings]) => { + setImageWithAlphaUrl(url); + setNumWarnings(numWarnings); + } + ); + }, [imageOnBlackUrl, imageOnWhiteUrl]); + + return ( + + + + + Upload PNG for {item.name} + + + + {(step === 1 || step === 2) && ( + + )} + {step === 3 && ( + + )} + + + + + + {step === 3 && ( + + )} + + + + + ); +} + +function ItemLayerSupportScreenshotStep({ itemLayer, step, onUpload }) { + return ( + <> + + Step {step}: Take a screenshot of exactly the 600×600 Flash + region, then upload it below. + + + + + + + + + + ); +} + +function ItemLayerSupportReviewStep({ imageWithAlphaUrl, numWarnings }) { + if (imageWithAlphaUrl == null) { + return Generating image…; + } + + return ( + <> + + Step 3: Does this look correct? If so, let's upload it! + + + (Generated with {numWarnings} warnings.) + + + {imageWithAlphaUrl && ( + Generated layer PNG, on a checkered background + )} + + + ); +} + +function ItemLayerSupportFlashPlayer({ swfUrl, backgroundColor }) { + return ( + + + + + + + + + + ); +} + +async function mergeIntoImageWithAlpha(imageOnBlackUrl, imageOnWhiteUrl) { + const [imageOnBlack, imageOnWhite] = await Promise.all([ + readImageDataFromUrl(imageOnBlackUrl), + readImageDataFromUrl(imageOnWhiteUrl), + ]); + + const [imageWithAlphaData, numWarnings] = mergeDataIntoImageWithAlpha( + imageOnBlack, + imageOnWhite + ); + const imageWithAlphaUrl = writeImageDataToUrl(imageWithAlphaData); + + return [imageWithAlphaUrl, numWarnings]; +} + +function mergeDataIntoImageWithAlpha(imageOnBlack, imageOnWhite) { + const imageWithAlpha = new ImageData(600, 600); + let numWarnings = 0; + + for (let x = 0; x < 600; x++) { + for (let y = 0; y < 600; y++) { + const pixelIndex = (600 * y + x) << 2; + + const rOnBlack = imageOnBlack.data[pixelIndex]; + const gOnBlack = imageOnBlack.data[pixelIndex + 1]; + const bOnBlack = imageOnBlack.data[pixelIndex + 2]; + const rOnWhite = imageOnWhite.data[pixelIndex]; + const gOnWhite = imageOnWhite.data[pixelIndex + 1]; + const bOnWhite = imageOnWhite.data[pixelIndex + 2]; + if (rOnWhite < rOnBlack || gOnWhite < gOnBlack || bOnWhite < bOnBlack) { + if (numWarnings < 100) { + console.warn( + `[${x}x${y}] color on white should be lighter than color on ` + + `black, see pixel ${x}x${y}: ` + + `#${rOnWhite.toString(16)}${bOnWhite.toString(16)}` + + `${gOnWhite.toString(16)}` + + ` vs ` + + `#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` + + `${gOnWhite.toString(16)}. ` + + `Falling back to the pixel on black, with alpha = 100%. ` + ); + } + imageWithAlpha.data[pixelIndex] = rOnBlack; + imageWithAlpha.data[pixelIndex + 1] = gOnBlack; + imageWithAlpha.data[pixelIndex + 2] = bOnBlack; + imageWithAlpha.data[pixelIndex + 3] = 255; + numWarnings++; + continue; + } + + // The true alpha is how close together the on-white and on-black colors + // are. If they're totally the same, it's 255 opacity. If they're totally + // different, it's 0 opacity. In between, it scales linearly with the + // difference! + const alpha = 255 - (rOnWhite - rOnBlack); + + // Check that the alpha derived from other channels makes sense too. + const alphaByB = 255 - (bOnWhite - bOnBlack); + const alphaByG = 255 - (gOnWhite - gOnBlack); + const highestAlpha = Math.max(Math.max(alpha, alphaByB), alphaByG); + const lowestAlpha = Math.min(Math.min(alpha, alphaByB, alphaByG)); + if (highestAlpha - lowestAlpha > 2) { + if (numWarnings < 100) { + console.warn( + `[${x}x${y}] derived alpha values don't match: ` + + `${alpha} vs ${alphaByB} vs ${alphaByG}. ` + + `Colors: #${rOnWhite.toString(16)}${bOnWhite.toString(16)}` + + `${gOnWhite.toString(16)}` + + ` vs ` + + `#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` + + `${gOnWhite.toString(16)}. ` + + `Falling back to the pixel on black, with alpha = 100%. ` + ); + } + imageWithAlpha.data[pixelIndex] = rOnBlack; + imageWithAlpha.data[pixelIndex + 1] = gOnBlack; + imageWithAlpha.data[pixelIndex + 2] = bOnBlack; + imageWithAlpha.data[pixelIndex + 3] = 255; + numWarnings++; + continue; + } + + // And the true color is the color on black, divided by the true alpha. + // We can derive this from the definition of the color on black, which is + // simply the true color times the true alpha. Divide to undo! + const alphaRatio = alpha / 255; + const rOnAlpha = Math.round(rOnBlack / alphaRatio); + const gOnAlpha = Math.round(gOnBlack / alphaRatio); + const bOnAlpha = Math.round(bOnBlack / alphaRatio); + + imageWithAlpha.data[pixelIndex] = rOnAlpha; + imageWithAlpha.data[pixelIndex + 1] = gOnAlpha; + imageWithAlpha.data[pixelIndex + 2] = bOnAlpha; + imageWithAlpha.data[pixelIndex + 3] = alpha; + } + } + + return [imageWithAlpha, numWarnings]; +} + +/** + * readImageDataFromUrl reads an image URL to ImageData, by drawing it on a + * canvas and reading ImageData back from it. + */ +async function readImageDataFromUrl(url) { + const image = new Image(); + + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + image.src = url; + }); + + const canvas = document.createElement("canvas"); + canvas.width = 600; + canvas.height = 600; + + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0, 600, 600); + return ctx.getImageData(0, 0, 600, 600); +} + +/** + * writeImageDataToUrl writes an ImageData to a data URL, by drawing it on a + * canvas and reading a URL back from it. + */ +function writeImageDataToUrl(imageData) { + const canvas = document.createElement("canvas"); + canvas.width = 600; + canvas.height = 600; + + const ctx = canvas.getContext("2d"); + ctx.putImageData(imageData, 0, 0); + return canvas.toDataURL("image/png"); +} + +export default ItemLayerSupportUploadModal;