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 && (
+
+ )}
+
+ >
+ );
+}
+
+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;