#include "image_processor.h"
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <limits>
#include <list>
#include <string>
#include <string_view>
#include <system_error>
#include <Magick++.h>
#include <spdlog/spdlog.h>
namespace
{
inline constexpr std::uint32_t MAX_IMAGE_SIDE = 2048;
struct ImageFormat
{
std::string extension;
std::string coder;
};
mw::E<ImageFormat> sniffFormat(const std::filesystem::path& input)
{
std::array<unsigned char, 32> bytes{};
std::ifstream stream(input, std::ios::binary);
if(!stream)
{
return std::unexpected(
mw::runtimeError("The uploaded image could not be opened"));
}
stream.read(
reinterpret_cast<char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
const std::size_t size = static_cast<std::size_t>(stream.gcount());
const std::array<unsigned char, 8> png = {
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a};
if(size >= png.size() &&
std::equal(png.begin(), png.end(), bytes.begin()))
{
return ImageFormat{"png", "PNG"};
}
if(size >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 &&
bytes[2] == 0xff)
{
return ImageFormat{"jpg", "JPEG"};
}
if(size >= 12 &&
std::string_view(
reinterpret_cast<const char*>(bytes.data()), 4) == "RIFF" &&
std::string_view(
reinterpret_cast<const char*>(bytes.data() + 8), 4) == "WEBP")
{
return ImageFormat{"webp", "WEBP"};
}
if(size >= 16 &&
std::string_view(
reinterpret_cast<const char*>(bytes.data() + 4), 4) == "ftyp")
{
for(std::size_t offset = 8; offset + 4 <= size; offset += 4)
{
const std::string_view brand(
reinterpret_cast<const char*>(bytes.data() + offset), 4);
if(brand == "avif" || brand == "avis")
{
return ImageFormat{"avif", "AVIF"};
}
}
}
return std::unexpected(mw::httpError(
415,
"Card images must be JPEG, PNG, WebP, or AVIF files"));
}
std::string basename(CardAssetType type)
{
switch(type)
{
case CardAssetType::FRONT_ART:
return "front-art";
case CardAssetType::FOIL_CONTROL:
return "foil";
case CardAssetType::THUMBNAIL:
return "thumb";
}
return "image";
}
mw::E<Magick::Image> decodeSingleImage(
const std::filesystem::path& input,
const ImageFormat& format)
{
try
{
std::list<Magick::Image> images;
Magick::readImages(
&images, format.coder + ":" + input.string());
if(images.size() != 1)
{
return std::unexpected(mw::httpError(
422,
"Animated and multi-frame images are not supported"));
}
Magick::Image image = std::move(images.front());
if(image.magick() != format.coder)
{
return std::unexpected(mw::httpError(
415,
"The uploaded image does not match its file signature"));
}
if(image.columns() == 0 || image.rows() == 0 ||
image.columns() > MAX_IMAGE_SIDE ||
image.rows() > MAX_IMAGE_SIDE)
{
return std::unexpected(mw::httpError(
422,
"Card images must be at most 2048 pixels on each side"));
}
return image;
}
catch(const Magick::Exception& error)
{
spdlog::warn("ImageMagick rejected an upload: {}", error.what());
return std::unexpected(mw::httpError(
422,
"The uploaded image could not be decoded"));
}
}
mw::E<void> renameImage(
const std::filesystem::path& input,
const std::filesystem::path& output)
{
std::error_code filesystem_error;
std::filesystem::rename(input, output, filesystem_error);
if(filesystem_error)
{
return std::unexpected(mw::runtimeError(
"Failed to normalize an uploaded image: " +
filesystem_error.message()));
}
return {};
}
} // namespace
ImageProcessor::ImageProcessor(
int avif_quality,
std::uint32_t thumbnail_long_side)
: avif_quality_(avif_quality),
thumbnail_long_side_(thumbnail_long_side)
{}
mw::E<ProcessedImage> ImageProcessor::process(
const std::filesystem::path& input,
CardAssetType type) const
{
auto format = sniffFormat(input);
if(!format)
{
return std::unexpected(std::move(format.error()));
}
auto decoded = decodeSingleImage(input, *format);
if(!decoded)
{
return std::unexpected(std::move(decoded.error()));
}
const bool convert_to_avif = format->extension == "png";
const std::string extension = convert_to_avif
? "avif"
: format->extension;
const std::filesystem::path output =
input.parent_path() / (basename(type) + "." + extension);
if(convert_to_avif)
{
try
{
decoded->autoOrient();
decoded->colorSpace(Magick::sRGBColorspace);
decoded->strip();
decoded->quality(static_cast<std::size_t>(avif_quality_));
decoded->write("AVIF:" + output.string());
}
catch(const Magick::Exception& error)
{
std::error_code remove_error;
std::filesystem::remove(output, remove_error);
spdlog::error(
"ImageMagick failed to convert an uploaded PNG: {}",
error.what());
return std::unexpected(mw::runtimeError(
"Failed to process the uploaded image"));
}
std::error_code remove_error;
if(!std::filesystem::remove(input, remove_error) || remove_error)
{
std::filesystem::remove(output, remove_error);
return std::unexpected(mw::runtimeError(
"Failed to replace the converted upload"));
}
}
else if(input != output)
{
auto renamed = renameImage(input, output);
if(!renamed)
{
return std::unexpected(std::move(renamed.error()));
}
}
return ProcessedImage{
output,
extension,
static_cast<std::uint32_t>(decoded->columns()),
static_cast<std::uint32_t>(decoded->rows()),
decoded->alpha(),
};
}
mw::E<ProcessedImage> ImageProcessor::generatePlainThumbnail(
const ProcessedImage& artwork,
const std::filesystem::path& output) const
{
auto format = sniffFormat(artwork.path);
if(!format)
{
return std::unexpected(std::move(format.error()));
}
auto decoded = decodeSingleImage(artwork.path, *format);
if(!decoded)
{
return std::unexpected(std::move(decoded.error()));
}
const std::uint64_t scaled_width =
static_cast<std::uint64_t>(thumbnail_long_side_) * 5 + 3;
const std::uint32_t width = static_cast<std::uint32_t>(scaled_width / 7);
try
{
decoded->autoOrient();
Magick::Geometry thumbnail_geometry(width, thumbnail_long_side_);
thumbnail_geometry.aspect(true);
decoded->resize(thumbnail_geometry);
decoded->colorSpace(Magick::sRGBColorspace);
decoded->strip();
decoded->quality(static_cast<std::size_t>(avif_quality_));
decoded->write("AVIF:" + output.string());
}
catch(const Magick::Exception& error)
{
std::error_code remove_error;
std::filesystem::remove(output, remove_error);
spdlog::error(
"ImageMagick failed to generate a card thumbnail: {}",
error.what());
return std::unexpected(mw::runtimeError(
"Failed to generate the card thumbnail"));
}
return ProcessedImage{
output,
"avif",
width,
thumbnail_long_side_,
decoded->alpha(),
};
}