UI to generate transparent PNG!
There's an Upload button but it doesn't go anywhere yet
This commit is contained in:
parent
fce51875d9
commit
b3d12d0966
2 changed files with 404 additions and 0 deletions
|
@ -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>
|
||||
|
|
388
src/app/WardrobePage/support/ItemLayerSupportUploadModal.js
Normal file
388
src/app/WardrobePage/support/ItemLayerSupportUploadModal.js
Normal 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×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;
|
Loading…
Reference in a new issue