UI to generate transparent PNG!

There's an Upload button but it doesn't go anywhere yet
This commit is contained in:
Emi Matchu 2020-08-04 17:05:04 -07:00
parent fce51875d9
commit b3d12d0966
2 changed files with 404 additions and 0 deletions

View file

@ -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 <ExternalLinkIcon ml="1" />
</Button>
<Box flex="1 1 0" />
<Button
size="xs"
colorScheme="gray"
onClick={() => setUploadModalIsOpen(true)}
>
Upload PNG <ChevronRightIcon />
</Button>
<ItemLayerSupportUploadModal
item={item}
itemLayer={itemLayer}
isOpen={uploadModalIsOpen}
onClose={() => setUploadModalIsOpen(false)}
/>
</HStack>
</MetadataValue>
</Metadata>

View file

@ -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 (
<Modal
// HACK: The built-in `full` size also sets 100% height, which I don't
// want; and the docs suggest it will accept px values, but it
// doesn't. But I discovered that invalid size values are treated
// as 100% width and auto height, so, okay! ^_^` Probably a bug,
// but I intend to use it for now!
size="full-hack"
isOpen={isOpen}
onClose={onClose}
>
<ModalOverlay>
<ModalContent color="green.800">
<ModalHeader textAlign="center">
Upload PNG for {item.name}
</ModalHeader>
<ModalCloseButton />
<ModalBody
paddingBottom="2"
display="flex"
flexDirection="column"
alignItems="center"
textAlign="center"
>
{(step === 1 || step === 2) && (
<ItemLayerSupportScreenshotStep
itemLayer={itemLayer}
step={step}
onUpload={onUpload}
/>
)}
{step === 3 && (
<ItemLayerSupportReviewStep
imageWithAlphaUrl={imageWithAlphaUrl}
numWarnings={numWarnings}
/>
)}
</ModalBody>
<ModalFooter>
<Button colorScheme="red" onClick={() => setStep(1)}>
Restart
</Button>
<Box flex="1 1 0" />
<Button onClick={onClose}>Close</Button>
{step === 3 && (
<Button colorScheme="green" marginLeft="4">
Upload
</Button>
)}
</ModalFooter>
</ModalContent>
</ModalOverlay>
</Modal>
);
}
function ItemLayerSupportScreenshotStep({ itemLayer, step, onUpload }) {
return (
<>
<Box>
<b>Step {step}:</b> Take a screenshot of exactly the 600&times;600 Flash
region, then upload it below.
</Box>
<Box
display="flex"
alignItems="center"
maxWidth="600px"
width="100%"
marginTop="2"
>
<input key={step} type="file" accept="image/png" onChange={onUpload} />
<Box flex="1 1 0" />
<Button
as="a"
href="https://support.mozilla.org/en-US/kb/firefox-screenshots"
target="_blank"
size="xs"
marginLeft="1"
colorScheme="gray"
>
Firefox help <ExternalLinkIcon marginLeft="1" />
</Button>
<Button
as="a"
href="https://umaar.com/dev-tips/156-element-screenshot/"
target="_blank"
size="xs"
marginLeft="1"
colorScheme="gray"
>
Chrome help <ExternalLinkIcon marginLeft="1" />
</Button>
</Box>
<ItemLayerSupportFlashPlayer
swfUrl={itemLayer.swfUrl}
backgroundColor={step === 1 ? "black" : "white"}
/>
</>
);
}
function ItemLayerSupportReviewStep({ imageWithAlphaUrl, numWarnings }) {
if (imageWithAlphaUrl == null) {
return <Box>Generating image</Box>;
}
return (
<>
<Box>
<b>Step 3:</b> Does this look correct? If so, let's upload it!
</Box>
<Box fontSize="sm" color="gray.500">
(Generated with {numWarnings} warnings.)
</Box>
<Box
// Checkerboard pattern: https://stackoverflow.com/a/35362074/107415
backgroundImage="linear-gradient(45deg, #c0c0c0 25%, transparent 25%), linear-gradient(-45deg, #c0c0c0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #c0c0c0 75%), linear-gradient(-45deg, transparent 75%, #c0c0c0 75%)"
backgroundSize="20px 20px"
backgroundPosition="0 0, 0 10px, 10px -10px, -10px 0px"
marginTop="2"
>
{imageWithAlphaUrl && (
<img
src={imageWithAlphaUrl}
width={600}
height={600}
alt="Generated layer PNG, on a checkered background"
/>
)}
</Box>
</>
);
}
function ItemLayerSupportFlashPlayer({ swfUrl, backgroundColor }) {
return (
<Box
data-hint="No: Don't screenshot this node! Use the one below!"
borderWidth="3px"
borderStyle="dashed"
borderColor="green.400"
marginTop="4"
>
<Box
// In Chrome on macOS, I observe that I need to shift the SWF
// one pixel to the left in order to capture it correctly.
//
// So, in Chrome, who are using a DevTools procedure, we add a
// hint that this is the node to use.
//
// In Firefox, the GUI to target the SWF seems to work just
// fine. So, the margin hack and these hints don't matter!
data-hint="Yes: Screenshot this node! This is the one!"
backgroundColor={backgroundColor}
>
<Box
data-hint="No: Don't screenshot this node! Use the one above!"
width="600px"
height="600px"
// In Chrome on macOS, I observe that I need to shift the SWF
// one pixel to the left in order to capture it correctly.
//
// But this disrupts the Firefox capture! So here, we do a cheap
// browser detection, to shift left only in Chrome.
marginLeft={navigator.userAgent.includes("Chrome") ? "-1px" : "0"}
>
<object
type="application/x-shockwave-flash"
data={swfUrl}
width="100%"
height="100%"
>
<param name="wmode" value="transparent" />
</object>
</Box>
</Box>
</Box>
);
}
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;