BareGit

Implement card upload and image processing

- Stream multipart uploads through private staging and publish assets
  atomically.
- Validate and normalize images, generate thumbnails, and persist loose cards
  transactionally.
- Prepare local and remote browser inputs and capture foil-rendered thumbnails.
- Accept opaque foil controls and wait for spectral rendering resources.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sat Aug 22 19:53:55 2026 -0700
Commit: 5055d1395f3e9a0bb1aba0c2856271677e888547

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0d42059..55e8d01 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -17,9 +17,13 @@ include(cmake/dependencies.cmake)
 add_executable(
     card_collection
     src/app.cpp
+    src/asset_store.cpp
+    src/card_service.cpp
     src/data.cpp
     src/data_sqlite.cpp
+    src/image_processor.cpp
     src/main.cpp
+    src/multipart_reader.cpp
     src/public_id.cpp
     src/startup.cpp
     src/url_builder.cpp
@@ -53,6 +57,8 @@ target_link_libraries(
     card_collection
     PRIVATE
         ImageMagick::Magick++
+        ImageMagick::MagickWand
+        ImageMagick::MagickCore
         MacroDown::MacroDown
         mw::http-server
         mw::mw
@@ -90,7 +96,11 @@ if(CARD_COLLECTION_BUILD_TESTS)
     add_executable(
         app_test
         src/app.cpp
+        src/asset_store.cpp
+        src/card_service.cpp
         src/data_fake.cpp
+        src/image_processor.cpp
+        src/multipart_reader.cpp
         src/public_id.cpp
         src/url_builder.cpp
         tests/app_test.cpp
@@ -107,6 +117,9 @@ if(CARD_COLLECTION_BUILD_TESTS)
         app_test
         PRIVATE
             GTest::gtest_main
+            ImageMagick::Magick++
+            ImageMagick::MagickWand
+            ImageMagick::MagickCore
             mw::http-server
             mw::url
             pantor::inja
@@ -164,4 +177,41 @@ if(CARD_COLLECTION_BUILD_TESTS)
             spdlog::spdlog
     )
     gtest_discover_tests(data_sqlite_test)
+
+    add_executable(
+        card_service_test
+        src/asset_store.cpp
+        src/card_service.cpp
+        src/data.cpp
+        src/data_sqlite.cpp
+        src/image_processor.cpp
+        src/multipart_reader.cpp
+        src/public_id.cpp
+        src/startup.cpp
+        tests/card_service_test.cpp
+    )
+    target_compile_features(card_service_test PRIVATE cxx_std_23)
+    set_target_properties(
+        card_service_test
+        PROPERTIES CXX_EXTENSIONS OFF
+    )
+    target_include_directories(
+        card_service_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_link_libraries(
+        card_service_test
+        PRIVATE
+            GTest::gtest_main
+            ImageMagick::Magick++
+            ImageMagick::MagickWand
+            ImageMagick::MagickCore
+            mw::http-server
+            mw::mw
+            mw::sqlite
+            spdlog::spdlog
+    )
+    gtest_discover_tests(card_service_test)
 endif()
diff --git a/cmake/dependencies.cmake b/cmake/dependencies.cmake
index 9c4d9ed..3483b88 100644
--- a/cmake/dependencies.cmake
+++ b/cmake/dependencies.cmake
@@ -87,4 +87,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
     FetchContent_MakeAvailable(googletest)
 endif()
 
-find_package(ImageMagick 7 REQUIRED COMPONENTS Magick++)
+find_package(
+    ImageMagick 7 REQUIRED
+    COMPONENTS Magick++ MagickWand MagickCore
+)
diff --git a/designs/design-0-prototype.md b/designs/design-0-prototype.md
index bce5f31..875e7fd 100644
--- a/designs/design-0-prototype.md
+++ b/designs/design-0-prototype.md
@@ -1022,7 +1022,7 @@ CREATE TABLE cards (
     front_extension TEXT NOT NULL
         CHECK(front_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
     foil_extension TEXT
-        CHECK(foil_extension IN ('webp', 'avif')),
+        CHECK(foil_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
     thumbnail_extension TEXT NOT NULL
         CHECK(thumbnail_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
     revision INTEGER NOT NULL DEFAULT 1 CHECK(revision >= 1),
@@ -1066,11 +1066,11 @@ CREATE TABLE card_series (
 ```
 
 PNG is absent from stored extensions because every accepted PNG is normalized
-to AVIF before publication. JPEG is absent only from foil extensions because
-foil-control textures require alpha. A nonfoil thumbnail is a resized artwork
-image and may be opaque, so JPEG is valid for thumbnails. The server
-canonicalizes `.jpeg` and `.jpg` input to one chosen extension, preferably
-`jpg`, before insertion.
+to AVIF before publication. Front artwork, foil-control textures, and
+thumbnails accept the same stored formats. WebGL treats a missing alpha
+channel in a foil-control texture as fully opaque. The server canonicalizes
+`.jpeg` and `.jpg` input to one chosen extension, preferably `jpg`, before
+insertion.
 
 SQLite partial unique indexes enforce the two identity namespaces without
 making null game values conflict. See
@@ -1324,8 +1324,7 @@ For every upload:
 6. Require exactly one decoded image. Reject animation and image sequences.
 7. Require the decoded `magick()` value to agree with the sniffed format.
 8. Read `columns()`, `rows()`, and alpha state from the decoded image. Reject
-   zero dimensions or a long side greater than 2048. Only the foil-control
-   role requires alpha.
+   zero dimensions or a long side greater than 2048.
 9. For PNG, transform the decoded image to sRGB and encode a single still AVIF
    using configured quality. Preserve its alpha channel.
 10. For JPEG, WebP, and AVIF, retain the original bytes after the successful
@@ -1337,14 +1336,13 @@ only a header would let malformed files reach browsers and reverse proxies.
 
 ### Format-specific rules
 
-- JPEG is valid for artwork and thumbnails. It is invalid for foil control
-  because it has no alpha.
+- JPEG is valid for artwork, foil controls, and thumbnails.
 - PNG is decoded by Magick++ and always converted to AVIF. An APNG produces
   more than one image and is rejected.
 - WebP is fully decoded by Magick++. Animated WebP produces more than one
-  image and is rejected. Only the foil-control role requires alpha.
+  image and is rejected.
 - AVIF is fully decoded by Magick++. An AVIF sequence produces more than one
-  image and is rejected. Only the foil-control role requires alpha.
+  image and is rejected.
 
 Decoded allocation sizes must be checked before multiplication. With a
 2048-pixel maximum on each dimension, an RGBA8 buffer is at most 16 MiB, but
@@ -1799,6 +1797,8 @@ Magick++, SQL, and filesystem errors go only to logs.
 - `front-art.jpeg` if retained as a supported canonical variant;
 - `front-art.webp`;
 - `front-art.avif`;
+- `foil.jpg`;
+- `foil.jpeg` if retained as a supported canonical variant;
 - `foil.webp`;
 - `foil.avif`;
 - `thumb.jpg`;
@@ -2149,10 +2149,10 @@ Use GoogleTest and temporary directories created per test. Tests must cover:
 - startup rejection when a required coder capability is unavailable through
   an injected capability-check adapter;
 - valid JPEG, PNG, WebP, and AVIF artwork;
-- valid PNG, WebP, and AVIF foil controls;
+- valid JPEG, PNG, WebP, and AVIF foil controls;
 - valid opaque JPEG, WebP, and AVIF thumbnails;
 - PNG-to-AVIF conversion and alpha preservation;
-- JPEG foil rejection;
+- acceptance of foil controls without alpha;
 - acceptance of thumbnails without alpha;
 - plain-thumbnail AVIF generation from each accepted artwork format;
 - exact 183-by-256 plain-thumbnail dimensions with the default setting;
diff --git a/src/app.cpp b/src/app.cpp
index af5f2ee..3c7b3e7 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -1,6 +1,7 @@
 #include "app.h"
 
 #include <algorithm>
+#include <charconv>
 #include <cctype>
 #include <cstddef>
 #include <filesystem>
@@ -8,6 +9,7 @@
 #include <memory>
 #include <stdexcept>
 #include <string>
+#include <string_view>
 #include <system_error>
 #include <unordered_map>
 #include <utility>
@@ -16,6 +18,7 @@
 #include <spdlog/spdlog.h>
 
 #include "public_id.h"
+#include "multipart_reader.h"
 
 namespace
 {
@@ -183,6 +186,85 @@ void respondNotFound(App::Response& response)
         "text/html; charset=utf-8");
 }
 
+void respondBadRequest(
+    App::Response& response,
+    const std::string& message)
+{
+    response.status = 400;
+    response.set_content(message + "\n", "text/plain; charset=utf-8");
+}
+
+void respondOperationError(
+    App::Response& response,
+    const mw::Error& error,
+    std::string_view operation)
+{
+    const mw::HTTPError* http_error = error.as<mw::HTTPError>();
+    if(http_error != nullptr && http_error->code < 500)
+    {
+        response.status = http_error->code;
+        response.set_content(
+            http_error->msg + "\n", "text/plain; charset=utf-8");
+        return;
+    }
+    spdlog::error("{}: {}", operation, error.msg());
+    respondInternalError(response);
+}
+
+std::string trimAscii(std::string value)
+{
+    const auto is_space = [](unsigned char character)
+    {
+        return std::isspace(character) != 0;
+    };
+    const auto begin = std::ranges::find_if_not(value, is_space);
+    const auto end = std::find_if_not(value.rbegin(), value.rend(), is_space)
+                         .base();
+    if(begin >= end)
+    {
+        return {};
+    }
+    return std::string(begin, end);
+}
+
+std::optional<std::string> optionalText(
+    const std::unordered_map<std::string, std::string>& fields,
+    const std::string& name)
+{
+    const auto position = fields.find(name);
+    if(position == fields.end())
+    {
+        return std::nullopt;
+    }
+    std::string value = trimAscii(position->second);
+    if(value.empty())
+    {
+        return std::nullopt;
+    }
+    return value;
+}
+
+mw::E<std::int64_t> parseRarity(
+    const std::unordered_map<std::string, std::string>& fields)
+{
+    const auto position = fields.find("rarity");
+    if(position == fields.end())
+    {
+        return std::unexpected(mw::runtimeError("Rarity is required"));
+    }
+    std::int64_t rarity = 0;
+    const std::string& text = position->second;
+    const auto result = std::from_chars(
+        text.data(), text.data() + text.size(), rarity);
+    if(text.empty() || result.ec != std::errc{} ||
+       result.ptr != text.data() + text.size() || rarity < 0)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Rarity must be a nonnegative integer"));
+    }
+    return rarity;
+}
+
 bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
 {
     std::error_code filesystem_error;
@@ -215,6 +297,11 @@ App::App(
         throw std::invalid_argument("App requires a data source");
     }
 
+    card_service_ = std::make_unique<CardService>(
+        *data_source_,
+        ImageProcessor(config_.avif_quality, config_.thumbnail_long_side),
+        AssetStore(config_.card_storage_root));
+
     templates_.set_html_autoescape(true);
     templates_.add_callback(
         "url_for",
@@ -288,6 +375,95 @@ void App::handleCardNew(
     }
 }
 
+void App::handleCardCreate(
+    const Request& request,
+    Response& response,
+    const ContentReader& content_reader)
+{
+    if(!request.is_multipart_form_data())
+    {
+        respondBadRequest(response, "Expected a multipart form upload");
+        return;
+    }
+
+    MultipartReader multipart_reader(config_.card_storage_root);
+    auto upload = multipart_reader.read(content_reader);
+    if(!upload)
+    {
+        respondOperationError(
+            response, upload.error(), "Failed to receive a card upload");
+        return;
+    }
+
+    const auto game = upload->fields.find("game");
+    if(game != upload->fields.end() && !trimAscii(game->second).empty())
+    {
+        respondBadRequest(
+            response, "Game-specific cards are not implemented yet");
+        return;
+    }
+    const auto source_mode = upload->fields.find("source_mode");
+    if(source_mode != upload->fields.end() &&
+       source_mode->second != "files" &&
+       source_mode->second != "urls")
+    {
+        respondBadRequest(
+            response, "Unknown image source mode");
+        return;
+    }
+    const auto name_position = upload->fields.find("name");
+    const std::string name = name_position == upload->fields.end()
+        ? std::string()
+        : trimAscii(name_position->second);
+    if(name.empty())
+    {
+        respondBadRequest(response, "Card name is required");
+        return;
+    }
+    if(name.size() > 200)
+    {
+        respondBadRequest(response, "Card name is too long");
+        return;
+    }
+    if(!upload->front)
+    {
+        respondBadRequest(response, "Front artwork is required");
+        return;
+    }
+    if(!upload->series_ids.empty())
+    {
+        respondBadRequest(
+            response, "Loose cards cannot belong to a series");
+        return;
+    }
+    auto rarity = parseRarity(upload->fields);
+    if(!rarity)
+    {
+        respondBadRequest(response, rarity.error().msg());
+        return;
+    }
+
+    auto created = card_service_->createLooseCard({
+        name,
+        optionalText(upload->fields, "short_description"),
+        optionalText(upload->fields, "long_description"),
+        *rarity,
+        upload->staging_directory,
+        *upload->front,
+        upload->foil,
+        upload->thumbnail,
+    });
+    if(!created)
+    {
+        respondOperationError(
+            response, created.error(), "Failed to create a card");
+        return;
+    }
+
+    response.status = 303;
+    response.set_header("Location", urlFor("card", {*created}));
+}
+
 void App::handleCardView(
     const Request& request,
     Response& response)
@@ -335,7 +511,7 @@ void App::handleCardView(
     const std::string& public_id = *public_id_result;
     const std::filesystem::path asset_root =
         config_.card_storage_root / "published" / public_id;
-    const std::string front_name = "front." + card.front_extension;
+    const std::string front_name = "front-art." + card.front_extension;
     const bool front_exists = isRegularFile(
         asset_root / front_name, card.id);
     const std::string front_url = front_exists
@@ -592,6 +768,9 @@ void App::setup()
     server.Get(
         getPath("card", {"id"}),
         std::bind_front(&App::handleCardView, this));
+    server.Post(
+        getPath("cards"),
+        std::bind_front(&App::handleCardCreate, this));
 }
 
 std::string App::getPath(
diff --git a/src/app.h b/src/app.h
index eb9ab3f..684cc25 100644
--- a/src/app.h
+++ b/src/app.h
@@ -7,6 +7,7 @@
 #include <inja/inja.hpp>
 #include <mw/http_server.hpp>
 
+#include "card_service.h"
 #include "config.h"
 #include "data.h"
 #include "url_builder.h"
@@ -21,6 +22,9 @@ public:
     /// HTTP response type supplied by libmw.
     using Response = mw::HTTPServer::Response;
 
+    /// Streaming request-body reader supplied by cpp-httplib.
+    using ContentReader = httplib::ContentReader;
+
     /// Disable construction without validated configuration.
     App() = delete;
 
@@ -41,6 +45,12 @@ public:
     /// Render the create-card form.
     void handleCardNew(const Request& request, Response& response);
 
+    /// Accept, process, and persist a new card upload.
+    void handleCardCreate(
+        const Request& request,
+        Response& response,
+        const ContentReader& content_reader);
+
     /// Render one read-only card page.
     void handleCardView(const Request& request, Response& response);
 
@@ -58,6 +68,7 @@ private:
 
     Config config_;
     std::unique_ptr<DataSourceInterface> data_source_;
+    std::unique_ptr<CardService> card_service_;
     UrlBuilder url_builder_;
     inja::Environment templates_;
     inja::Template card_form_template_;
diff --git a/src/asset_store.cpp b/src/asset_store.cpp
new file mode 100644
index 0000000..c560e8d
--- /dev/null
+++ b/src/asset_store.cpp
@@ -0,0 +1,65 @@
+#include "asset_store.h"
+
+#include <filesystem>
+#include <string>
+#include <system_error>
+#include <utility>
+
+#include <spdlog/spdlog.h>
+
+AssetStore::AssetStore(std::filesystem::path card_storage_root)
+        : published_root_(
+              std::move(card_storage_root) / "published")
+{}
+
+mw::E<void> AssetStore::publish(
+    const std::filesystem::path& staging_directory,
+    const std::string& public_id) const
+{
+    std::error_code filesystem_error;
+    std::filesystem::create_directories(
+        published_root_, filesystem_error);
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to create the published asset root: " +
+            filesystem_error.message()));
+    }
+
+    const std::filesystem::path destination = published_root_ / public_id;
+    if(std::filesystem::exists(destination, filesystem_error))
+    {
+        return std::unexpected(mw::runtimeError(
+            "The destination card asset directory already exists"));
+    }
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to inspect the published asset directory: " +
+            filesystem_error.message()));
+    }
+
+    std::filesystem::rename(
+        staging_directory, destination, filesystem_error);
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to publish the card assets: " +
+            filesystem_error.message()));
+    }
+    return {};
+}
+
+void AssetStore::removePublished(const std::string& public_id) const
+{
+    std::error_code filesystem_error;
+    std::filesystem::remove_all(
+        published_root_ / public_id, filesystem_error);
+    if(filesystem_error)
+    {
+        spdlog::error(
+            "Failed to remove rolled-back assets for card {}: {}",
+            public_id,
+            filesystem_error.message());
+    }
+}
diff --git a/src/asset_store.h b/src/asset_store.h
new file mode 100644
index 0000000..ea528a0
--- /dev/null
+++ b/src/asset_store.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#include <filesystem>
+#include <string>
+
+#include <mw/error.hpp>
+
+/// Atomically publish and remove complete card asset directories.
+class AssetStore
+{
+public:
+    /// Construct an asset store below the configured private root.
+    explicit AssetStore(std::filesystem::path card_storage_root);
+
+    /// Rename a complete staging directory into its public location.
+    mw::E<void> publish(
+        const std::filesystem::path& staging_directory,
+        const std::string& public_id) const;
+
+    /// Remove one precisely identified published directory after rollback.
+    void removePublished(const std::string& public_id) const;
+
+private:
+    std::filesystem::path published_root_;
+};
diff --git a/src/card.h b/src/card.h
index ab2fc31..a6cc3e2 100644
--- a/src/card.h
+++ b/src/card.h
@@ -54,7 +54,7 @@ enum class CardAssetType
     /// Full-size front artwork.
     FRONT_ART,
 
-    /// Optional alpha-bearing foil-control texture.
+    /// Optional foil-control texture.
     FOIL_CONTROL,
 
     /// Static card-index thumbnail.
diff --git a/src/card_service.cpp b/src/card_service.cpp
new file mode 100644
index 0000000..aa648bd
--- /dev/null
+++ b/src/card_service.cpp
@@ -0,0 +1,159 @@
+#include "card_service.h"
+
+#include <cstdint>
+#include <filesystem>
+#include <limits>
+#include <optional>
+#include <random>
+#include <string>
+#include <utility>
+
+#include "public_id.h"
+
+namespace
+{
+
+inline constexpr int MAX_ID_ATTEMPTS = 64;
+
+std::uint32_t randomLooseNumber()
+{
+    thread_local std::mt19937 generator(std::random_device{}());
+    thread_local std::uniform_int_distribution<std::uint32_t> distribution(
+        0, std::numeric_limits<std::uint32_t>::max());
+    return distribution(generator);
+}
+
+} // namespace
+
+CardService::CardService(
+    DataSourceInterface& data_source,
+    ImageProcessor image_processor,
+    AssetStore asset_store)
+        : data_source_(data_source),
+          image_processor_(std::move(image_processor)),
+          asset_store_(std::move(asset_store))
+{}
+
+mw::E<std::string> CardService::createLooseCard(
+    CreateLooseCardInput input)
+{
+    auto front = image_processor_.process(
+        input.front, CardAssetType::FRONT_ART);
+    if(!front)
+    {
+        return std::unexpected(std::move(front.error()));
+    }
+
+    std::optional<ProcessedImage> foil;
+    if(input.foil)
+    {
+        auto processed = image_processor_.process(
+            *input.foil, CardAssetType::FOIL_CONTROL);
+        if(!processed)
+        {
+            return std::unexpected(std::move(processed.error()));
+        }
+        foil = std::move(*processed);
+    }
+
+    ProcessedImage thumbnail;
+    if(foil)
+    {
+        if(!input.thumbnail)
+        {
+            return std::unexpected(mw::httpError(
+                422,
+                "A rendered thumbnail is required for a foil card"));
+        }
+        auto processed = image_processor_.process(
+            *input.thumbnail, CardAssetType::THUMBNAIL);
+        if(!processed)
+        {
+            return std::unexpected(std::move(processed.error()));
+        }
+        thumbnail = std::move(*processed);
+    }
+    else
+    {
+        if(input.thumbnail)
+        {
+            return std::unexpected(mw::httpError(
+                422,
+                "A plain card must not include an uploaded thumbnail"));
+        }
+        auto generated = image_processor_.generatePlainThumbnail(
+            *front,
+            input.staging_directory / "thumb.avif");
+        if(!generated)
+        {
+            return std::unexpected(std::move(generated.error()));
+        }
+        thumbnail = std::move(*generated);
+    }
+
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+
+    std::optional<std::uint32_t> number;
+    for(int attempt = 0; attempt < MAX_ID_ATTEMPTS; ++attempt)
+    {
+        const std::uint32_t candidate = randomLooseNumber();
+        auto exists = (*transaction)->looseNumberExists(candidate);
+        if(!exists)
+        {
+            return std::unexpected(std::move(exists.error()));
+        }
+        if(!*exists)
+        {
+            number = candidate;
+            break;
+        }
+    }
+    if(!number)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to allocate a unique loose-card number"));
+    }
+
+    Card card = {
+        0,
+        {std::nullopt, *number},
+        std::move(input.name),
+        std::move(input.short_description),
+        std::move(input.long_description),
+        input.rarity,
+        front->extension,
+        foil ? std::optional<std::string>(foil->extension) : std::nullopt,
+        thumbnail.extension,
+        1,
+    };
+    auto inserted = (*transaction)->insertCard(
+        card, nullptr, nullptr, {});
+    if(!inserted)
+    {
+        return std::unexpected(std::move(inserted.error()));
+    }
+
+    auto public_id = formatPublicId(card.identity);
+    if(!public_id)
+    {
+        return std::unexpected(std::move(public_id.error()));
+    }
+    auto published = asset_store_.publish(
+        input.staging_directory, *public_id);
+    if(!published)
+    {
+        return std::unexpected(std::move(published.error()));
+    }
+
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        asset_store_.removePublished(*public_id);
+        return std::unexpected(std::move(committed.error()));
+    }
+    return *public_id;
+}
diff --git a/src/card_service.h b/src/card_service.h
new file mode 100644
index 0000000..443fdfd
--- /dev/null
+++ b/src/card_service.h
@@ -0,0 +1,60 @@
+#pragma once
+
+#include <cstdint>
+#include <filesystem>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <mw/error.hpp>
+
+#include "asset_store.h"
+#include "data.h"
+#include "image_processor.h"
+
+/// Validated common fields and staged files for a new loose card.
+struct CreateLooseCardInput
+{
+    /// Human-readable card name.
+    std::string name;
+
+    /// Optional short Markdown description.
+    std::optional<std::string> short_description;
+
+    /// Optional long Markdown description.
+    std::optional<std::string> long_description;
+
+    /// Nonnegative rarity value.
+    std::int64_t rarity;
+
+    /// Complete private staging directory for this request.
+    std::filesystem::path staging_directory;
+
+    /// Staged front artwork path.
+    std::filesystem::path front;
+
+    /// Optional staged foil-control path.
+    std::optional<std::filesystem::path> foil;
+
+    /// Optional browser-rendered thumbnail for a foil card.
+    std::optional<std::filesystem::path> thumbnail;
+};
+
+/// Coordinate image processing, persistence, and asset publication.
+class CardService
+{
+public:
+    /// Construct a card creation service from its owned boundaries.
+    CardService(
+        DataSourceInterface& data_source,
+        ImageProcessor image_processor,
+        AssetStore asset_store);
+
+    /// Create a loose card and return its canonical public ID.
+    mw::E<std::string> createLooseCard(CreateLooseCardInput input);
+
+private:
+    DataSourceInterface& data_source_;
+    ImageProcessor image_processor_;
+    AssetStore asset_store_;
+};
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index 1120bb7..f1ca818 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -50,7 +50,8 @@ const std::array<std::string_view, 7> SCHEMA_VERSION_1_STATEMENTS = {
             front_extension TEXT NOT NULL
                 CHECK(front_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
             foil_extension TEXT
-                CHECK(foil_extension IN ('webp', 'avif')),
+                CHECK(foil_extension IN
+                      ('jpg', 'jpeg', 'webp', 'avif')),
             thumbnail_extension TEXT NOT NULL
                 CHECK(thumbnail_extension IN
                       ('jpg', 'jpeg', 'webp', 'avif')),
@@ -120,6 +121,287 @@ const std::array<std::string_view, 7> SCHEMA_VERSION_1_STATEMENTS = {
     )sql",
 };
 
+class DataSourceSQLiteTransaction final
+    : public DataSourceTransactionInterface
+{
+public:
+    /// Adopt an active immediate transaction and its connection lock.
+    DataSourceSQLiteTransaction(
+        mw::SQLite& connection,
+        std::unique_lock<std::mutex> lock)
+            : connection_(connection),
+              lock_(std::move(lock))
+    {}
+
+    /// Roll back an uncommitted transaction and release its connection lock.
+    ~DataSourceSQLiteTransaction() override
+    {
+        if(!committed_)
+        {
+            auto rollback = connection_.execute("ROLLBACK;");
+            if(!rollback)
+            {
+                spdlog::error(
+                    "Failed to roll back card transaction: {}",
+                    rollback.error().msg());
+            }
+        }
+    }
+
+    /// Prevent copying transaction ownership and its connection lock.
+    DataSourceSQLiteTransaction(
+        const DataSourceSQLiteTransaction&) = delete;
+
+    /// Prevent copy assignment of transaction ownership.
+    DataSourceSQLiteTransaction& operator=(
+        const DataSourceSQLiteTransaction&) = delete;
+
+    /// Allocate and persist the next never-reused number for one game.
+    mw::E<std::uint64_t> allocateGameNumber(
+        const std::string& game_short_name) override
+    {
+        auto statement = connection_.statementFromStr(
+            "UPDATE game_sequences "
+            "SET last_number = last_number + 1 "
+            "WHERE game_short_name = ? "
+            "RETURNING last_number;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind<std::string>(game_short_name);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto rows = connection_.eval<std::int64_t>(
+            std::move(*statement));
+        if(!rows)
+        {
+            return std::unexpected(std::move(rows.error()));
+        }
+        if(rows->size() != 1 || std::get<0>(rows->front()) <= 0)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Game sequence is missing or invalid"));
+        }
+        return static_cast<std::uint64_t>(
+            std::get<0>(rows->front()));
+    }
+
+    /// Ensure a registered game has a persistent sequence row.
+    mw::E<void> ensureGameSequence(
+        const std::string& game_short_name) override
+    {
+        auto statement = connection_.statementFromStr(
+            "INSERT OR IGNORE INTO game_sequences "
+            "(game_short_name, last_number) VALUES (?, 0);");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind<std::string>(game_short_name);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        return connection_.execute(std::move(*statement));
+    }
+
+    /// Return whether a loose-card number already exists.
+    mw::E<bool> looseNumberExists(std::uint32_t number) override
+    {
+        auto statement = connection_.statementFromStr(
+            "SELECT EXISTS("
+            "SELECT 1 FROM cards "
+            "WHERE game_short_name IS NULL AND card_number = ?);");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind<std::int64_t>(number);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto result = connection_.evalToValue<int>(
+            std::move(*statement));
+        if(!result)
+        {
+            return std::unexpected(std::move(result.error()));
+        }
+        return *result != 0;
+    }
+
+    /// Re-read a card while the transaction lock is held.
+    mw::E<std::optional<Card>> getCardForUpdate(
+        [[maybe_unused]] std::int64_t card_id) override
+    {
+        return std::unexpected(notImplemented("transactional card reads"));
+    }
+
+    /// Insert common and series-membership rows for a loose card.
+    mw::E<std::int64_t> insertCard(
+        const Card& card,
+        const GameDefinition* game,
+        const GameCardMetadata* metadata,
+        const std::vector<std::int64_t>& series_ids) override
+    {
+        if(card.id != 0)
+        {
+            return std::unexpected(mw::runtimeError(
+                "A new card cannot already have an internal ID"));
+        }
+        if((game == nullptr) != (metadata == nullptr))
+        {
+            return std::unexpected(mw::runtimeError(
+                "Game definition and metadata must be provided together"));
+        }
+        if(card.identity.game_short_name)
+        {
+            if(game == nullptr)
+            {
+                return std::unexpected(mw::runtimeError(
+                    "A game card requires compiled game metadata"));
+            }
+            return std::unexpected(notImplemented(
+                "game-specific card insertion"));
+        }
+        if(game != nullptr)
+        {
+            return std::unexpected(mw::runtimeError(
+                "A loose card cannot have compiled game metadata"));
+        }
+        if(card.identity.card_number >
+           std::numeric_limits<std::uint32_t>::max())
+        {
+            return std::unexpected(mw::runtimeError(
+                "Loose card number exceeds the 32-bit namespace"));
+        }
+
+        auto insert = connection_.statementFromStr(
+            "INSERT INTO cards ("
+            "game_short_name, card_number, name, short_description, "
+            "long_description, rarity, front_extension, foil_extension, "
+            "thumbnail_extension, revision) "
+            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);");
+        if(!insert)
+        {
+            return std::unexpected(std::move(insert.error()));
+        }
+        auto bind = insert->bind(
+            card.identity.game_short_name,
+            static_cast<std::int64_t>(card.identity.card_number),
+            card.name,
+            card.short_description,
+            card.long_description,
+            card.rarity,
+            card.front_extension,
+            card.foil_extension,
+            card.thumbnail_extension,
+            card.revision);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto execute = connection_.execute(std::move(*insert));
+        if(!execute)
+        {
+            return std::unexpected(std::move(execute.error()));
+        }
+        const std::int64_t card_id = connection_.lastInsertRowID();
+
+        for(std::int64_t series_id : series_ids)
+        {
+            auto membership = connection_.statementFromStr(
+                "INSERT INTO card_series (card_id, series_id) "
+                "VALUES (?, ?);");
+            if(!membership)
+            {
+                return std::unexpected(std::move(membership.error()));
+            }
+            auto membership_bind = membership->bind<std::int64_t,
+                                                    std::int64_t>(
+                card_id, series_id);
+            if(!membership_bind)
+            {
+                return std::unexpected(
+                    std::move(membership_bind.error()));
+            }
+            auto membership_execute = connection_.execute(
+                std::move(*membership));
+            if(!membership_execute)
+            {
+                return std::unexpected(
+                    std::move(membership_execute.error()));
+            }
+        }
+        return card_id;
+    }
+
+    /// Replace card rows after validation by the service layer.
+    mw::E<void> updateCard(
+        [[maybe_unused]] const Card& card,
+        [[maybe_unused]] const GameDefinition* game,
+        [[maybe_unused]] const GameCardMetadata* metadata,
+        [[maybe_unused]] const std::vector<std::int64_t>& series_ids)
+        override
+    {
+        return std::unexpected(notImplemented("card updates"));
+    }
+
+    /// Delete a card and its dependent database rows.
+    mw::E<void> deleteCard(
+        [[maybe_unused]] std::int64_t card_id) override
+    {
+        return std::unexpected(notImplemented("card deletion"));
+    }
+
+    /// Insert a series and return its internal ID.
+    mw::E<std::int64_t> insertSeries(
+        [[maybe_unused]] const Series& series) override
+    {
+        return std::unexpected(notImplemented("series insertion"));
+    }
+
+    /// Replace a series name and description without changing its game.
+    mw::E<void> updateSeries(
+        [[maybe_unused]] const Series& series) override
+    {
+        return std::unexpected(notImplemented("series updates"));
+    }
+
+    /// Delete a series and its membership rows.
+    mw::E<void> deleteSeries(
+        [[maybe_unused]] std::int64_t series_id) override
+    {
+        return std::unexpected(notImplemented("series deletion"));
+    }
+
+    /// Commit the transaction and release its connection lock.
+    mw::E<void> commit() override
+    {
+        if(committed_)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Transaction has already been committed"));
+        }
+        auto result = connection_.execute("COMMIT;");
+        if(!result)
+        {
+            return std::unexpected(std::move(result.error()));
+        }
+        committed_ = true;
+        lock_.unlock();
+        return {};
+    }
+
+private:
+    mw::SQLite& connection_;
+    std::unique_lock<std::mutex> lock_;
+    bool committed_ = false;
+};
+
 } // namespace
 
 DataSourceSQLite::DataSourceSQLite(
@@ -199,7 +481,14 @@ mw::E<void> DataSourceSQLite::migrateSchema0To1(
 mw::E<std::unique_ptr<DataSourceTransactionInterface>>
 DataSourceSQLite::beginTransaction()
 {
-    return std::unexpected(notImplemented("transactions"));
+    std::unique_lock lock(mutex_);
+    auto begin = connection_->execute("BEGIN IMMEDIATE;");
+    if(!begin)
+    {
+        return std::unexpected(std::move(begin.error()));
+    }
+    return std::unique_ptr<DataSourceTransactionInterface>(
+        new DataSourceSQLiteTransaction(*connection_, std::move(lock)));
 }
 
 mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
@@ -273,9 +562,75 @@ mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
 }
 
 mw::E<std::optional<Card>> DataSourceSQLite::getCard(
-    [[maybe_unused]] const CardIdentity& identity) const
+    const CardIdentity& identity) const
 {
-    return std::unexpected(notImplemented("card reads"));
+    std::lock_guard lock(mutex_);
+    auto statement = connection_->statementFromStr(
+        "SELECT id, game_short_name, card_number, name, "
+        "short_description, long_description, rarity, "
+        "front_extension, foil_extension, thumbnail_extension, "
+        "revision FROM cards WHERE "
+        "((? IS NULL AND game_short_name IS NULL) OR "
+        "game_short_name = ?) AND card_number = ?;");
+    if(!statement)
+    {
+        return std::unexpected(std::move(statement.error()));
+    }
+    auto bind = statement->bind(
+        identity.game_short_name,
+        identity.game_short_name,
+        static_cast<std::int64_t>(identity.card_number));
+    if(!bind)
+    {
+        return std::unexpected(std::move(bind.error()));
+    }
+    auto rows = connection_->eval<
+        std::int64_t,
+        std::optional<std::string>,
+        std::int64_t,
+        std::string,
+        std::optional<std::string>,
+        std::optional<std::string>,
+        std::int64_t,
+        std::string,
+        std::optional<std::string>,
+        std::string,
+        std::int64_t>(std::move(*statement));
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    if(rows->empty())
+    {
+        return std::optional<Card>{};
+    }
+
+    auto& [
+        id,
+        game_short_name,
+        card_number,
+        name,
+        short_description,
+        long_description,
+        rarity,
+        front_extension,
+        foil_extension,
+        thumbnail_extension,
+        revision] = rows->front();
+    Card card = {
+        id,
+        {std::move(game_short_name),
+         static_cast<std::uint64_t>(card_number)},
+        std::move(name),
+        std::move(short_description),
+        std::move(long_description),
+        rarity,
+        std::move(front_extension),
+        std::move(foil_extension),
+        std::move(thumbnail_extension),
+        revision,
+    };
+    return std::optional<Card>(std::move(card));
 }
 
 mw::E<std::vector<DisplayField>>
@@ -298,9 +653,33 @@ mw::E<std::optional<Series>> DataSourceSQLite::getSeries(
 }
 
 mw::E<std::vector<std::int64_t>> DataSourceSQLite::getCardSeries(
-    [[maybe_unused]] std::int64_t card_id) const
+    std::int64_t card_id) const
 {
-    return std::unexpected(notImplemented("card-series reads"));
+    std::lock_guard lock(mutex_);
+    auto statement = connection_->statementFromStr(
+        "SELECT series_id FROM card_series "
+        "WHERE card_id = ? ORDER BY series_id;");
+    if(!statement)
+    {
+        return std::unexpected(std::move(statement.error()));
+    }
+    auto bind = statement->bind<std::int64_t>(card_id);
+    if(!bind)
+    {
+        return std::unexpected(std::move(bind.error()));
+    }
+    auto rows = connection_->eval<std::int64_t>(std::move(*statement));
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    std::vector<std::int64_t> result;
+    result.reserve(rows->size());
+    for(auto& [series_id] : *rows)
+    {
+        result.push_back(series_id);
+    }
+    return result;
 }
 
 mw::E<std::vector<std::string>>
diff --git a/src/image_processor.cpp b/src/image_processor.cpp
new file mode 100644
index 0000000..e5c7686
--- /dev/null
+++ b/src/image_processor.cpp
@@ -0,0 +1,275 @@
+#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(),
+    };
+}
diff --git a/src/image_processor.h b/src/image_processor.h
new file mode 100644
index 0000000..0c362a7
--- /dev/null
+++ b/src/image_processor.h
@@ -0,0 +1,50 @@
+#pragma once
+
+#include <cstdint>
+#include <filesystem>
+#include <string>
+
+#include <mw/error.hpp>
+
+#include "card.h"
+
+/// Metadata for one validated, normalized card image.
+struct ProcessedImage
+{
+    /// Canonical path of the normalized image.
+    std::filesystem::path path;
+
+    /// Lowercase extension stored in the card row.
+    std::string extension;
+
+    /// Decoded image width in pixels.
+    std::uint32_t width;
+
+    /// Decoded image height in pixels.
+    std::uint32_t height;
+
+    /// Whether the decoded image contains an alpha channel.
+    bool has_alpha;
+};
+
+/// Validate and normalize untrusted card image uploads.
+class ImageProcessor
+{
+public:
+    /// Construct an image processor with output settings.
+    ImageProcessor(int avif_quality, std::uint32_t thumbnail_long_side);
+
+    /// Validate and normalize an uploaded image for its logical role.
+    mw::E<ProcessedImage> process(
+        const std::filesystem::path& input,
+        CardAssetType type) const;
+
+    /// Generate the fixed-size index thumbnail for a plain card.
+    mw::E<ProcessedImage> generatePlainThumbnail(
+        const ProcessedImage& artwork,
+        const std::filesystem::path& output) const;
+
+private:
+    int avif_quality_;
+    std::uint32_t thumbnail_long_side_;
+};
diff --git a/src/main.cpp b/src/main.cpp
index a145721..473b719 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,3 +1,4 @@
+#include <array>
 #include <chrono>
 #include <csignal>
 #include <filesystem>
@@ -5,6 +6,7 @@
 #include <thread>
 #include <utility>
 
+#include <Magick++.h>
 #include <spdlog/spdlog.h>
 
 #include "app.h"
@@ -25,6 +27,40 @@ void handleSignal([[maybe_unused]] int signal)
     STOP_REQUESTED = 1;
 }
 
+mw::E<void> prepareImageMagick()
+{
+    const std::array<std::string, 4> coders = {
+        "JPEG", "PNG", "WEBP", "AVIF"};
+    try
+    {
+        for(const std::string& name : coders)
+        {
+            const Magick::CoderInfo coder(name);
+            if(!coder.isReadable())
+            {
+                return std::unexpected(mw::runtimeError(
+                    "ImageMagick coder " + name + " is not readable"));
+            }
+            if(name == "AVIF" && !coder.isWritable())
+            {
+                return std::unexpected(mw::runtimeError(
+                    "ImageMagick coder AVIF is not writable"));
+            }
+        }
+    }
+    catch(const Magick::Exception& error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to inspect ImageMagick capabilities: " +
+            std::string(error.what())));
+    }
+
+    Magick::ResourceLimits::listLength(2);
+    Magick::ResourceLimits::width(2048);
+    Magick::ResourceLimits::height(2048);
+    return {};
+}
+
 mw::E<Config> makeDevelopmentConfig()
 {
     const std::string base_url_text =
@@ -61,8 +97,18 @@ mw::E<Config> makeDevelopmentConfig()
 } // namespace
 
 /// Start the Card Collection development server with its SQLite database.
-int main()
+int main(int argc, char** argv)
 {
+    Magick::InitializeMagick(argc > 0 ? argv[0] : nullptr);
+    auto image_magick = prepareImageMagick();
+    if(!image_magick)
+    {
+        spdlog::error(
+            "Failed to initialize image processing: {}",
+            image_magick.error().msg());
+        return 1;
+    }
+
     auto config = makeDevelopmentConfig();
     if(!config)
     {
diff --git a/src/multipart_reader.cpp b/src/multipart_reader.cpp
new file mode 100644
index 0000000..0a37ec9
--- /dev/null
+++ b/src/multipart_reader.cpp
@@ -0,0 +1,265 @@
+#include "multipart_reader.h"
+
+#include <atomic>
+#include <chrono>
+#include <cstddef>
+#include <cstdint>
+#include <filesystem>
+#include <fstream>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <system_error>
+#include <unordered_set>
+#include <utility>
+
+namespace
+{
+
+inline constexpr std::size_t MAX_TEXT_FIELD_SIZE = 1024 * 1024;
+inline constexpr std::size_t MAX_IMAGE_SIZE = 32 * 1024 * 1024;
+inline constexpr std::size_t MAX_TOTAL_IMAGE_SIZE = 72 * 1024 * 1024;
+
+std::atomic<std::uint64_t> NEXT_STAGING_ID = 0;
+
+bool isTextField(std::string_view name)
+{
+    static const std::unordered_set<std::string> names = {
+        "game",
+        "name",
+        "rarity",
+        "short_description",
+        "long_description",
+        "source_mode",
+        "front_url",
+        "foil_url",
+        "series_id",
+    };
+    return names.contains(std::string(name));
+}
+
+std::optional<std::string> stagedFilename(std::string_view name)
+{
+    if(name == "front")
+    {
+        return "upload_front";
+    }
+    if(name == "foil")
+    {
+        return "upload_foil";
+    }
+    if(name == "thumbnail")
+    {
+        return "upload_thumbnail";
+    }
+    return std::nullopt;
+}
+
+mw::E<std::filesystem::path> createStagingDirectory(
+    const std::filesystem::path& card_storage_root)
+{
+    const std::filesystem::path staging_root =
+        card_storage_root / ".staging";
+    std::error_code filesystem_error;
+    std::filesystem::create_directories(staging_root, filesystem_error);
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to create the upload staging root: " +
+            filesystem_error.message()));
+    }
+
+    for(int attempt = 0; attempt < 32; ++attempt)
+    {
+        const auto ticks = std::chrono::steady_clock::now()
+                               .time_since_epoch()
+                               .count();
+        const std::uint64_t sequence = NEXT_STAGING_ID.fetch_add(1);
+        const std::filesystem::path directory = staging_root /
+            (std::to_string(ticks) + "-" + std::to_string(sequence));
+        filesystem_error.clear();
+        if(std::filesystem::create_directory(directory, filesystem_error))
+        {
+            return directory;
+        }
+        if(filesystem_error &&
+           filesystem_error != std::errc::file_exists)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Failed to create an upload staging directory: " +
+                filesystem_error.message()));
+        }
+    }
+    return std::unexpected(mw::runtimeError(
+        "Failed to allocate an upload staging directory"));
+}
+
+} // namespace
+
+MultipartReader::MultipartReader(std::filesystem::path card_storage_root)
+        : card_storage_root_(std::move(card_storage_root))
+{}
+
+MultipartReader::~MultipartReader()
+{
+    if(staging_directory_.empty())
+    {
+        return;
+    }
+    std::error_code filesystem_error;
+    std::filesystem::remove_all(staging_directory_, filesystem_error);
+}
+
+mw::E<CardUpload> MultipartReader::read(
+    const httplib::ContentReader& content_reader)
+{
+    auto staging = createStagingDirectory(card_storage_root_);
+    if(!staging)
+    {
+        return std::unexpected(std::move(staging.error()));
+    }
+    staging_directory_ = *staging;
+
+    CardUpload upload;
+    upload.staging_directory = staging_directory_;
+    std::ofstream binary_stream;
+    std::string* text_value = nullptr;
+    bool ignore_part = false;
+    std::size_t part_size = 0;
+    std::size_t total_image_size = 0;
+    std::string error_message;
+    int error_status = 400;
+
+    const bool read = content_reader(
+        [&](const httplib::FormData& part)
+        {
+            binary_stream.close();
+            text_value = nullptr;
+            ignore_part = false;
+            part_size = 0;
+
+            if(isTextField(part.name))
+            {
+                if(part.name == "series_id")
+                {
+                    upload.series_ids.emplace_back();
+                    text_value = &upload.series_ids.back();
+                    return true;
+                }
+                auto [position, inserted] =
+                    upload.fields.try_emplace(part.name);
+                if(!inserted)
+                {
+                    error_message =
+                        "The form field '" + part.name +
+                        "' was supplied more than once";
+                    return false;
+                }
+                text_value = &position->second;
+                return true;
+            }
+
+            const auto filename = stagedFilename(part.name);
+            if(!filename)
+            {
+                error_message =
+                    "The form contains an unknown field: " + part.name;
+                return false;
+            }
+            if(part.filename.empty())
+            {
+                ignore_part = true;
+                return true;
+            }
+            std::optional<std::filesystem::path>* target = nullptr;
+            if(part.name == "front")
+            {
+                target = &upload.front;
+            }
+            else if(part.name == "foil")
+            {
+                target = &upload.foil;
+            }
+            else
+            {
+                target = &upload.thumbnail;
+            }
+            if(*target)
+            {
+                error_message =
+                    "The image field '" + part.name +
+                    "' was supplied more than once";
+                return false;
+            }
+            *target = staging_directory_ / *filename;
+            binary_stream.open(**target, std::ios::binary);
+            if(!binary_stream)
+            {
+                error_message = "Failed to stage an uploaded image";
+                error_status = 500;
+                return false;
+            }
+            return true;
+        },
+        [&](const char* data, std::size_t size)
+        {
+            part_size += size;
+            if(ignore_part)
+            {
+                total_image_size += size;
+                if(part_size > MAX_IMAGE_SIZE ||
+                   total_image_size > MAX_TOTAL_IMAGE_SIZE)
+                {
+                    error_message = "An uploaded image is too large";
+                    error_status = 413;
+                    return false;
+                }
+                return true;
+            }
+            if(text_value != nullptr)
+            {
+                if(part_size > MAX_TEXT_FIELD_SIZE)
+                {
+                    error_message = "A form field is too large";
+                    error_status = 413;
+                    return false;
+                }
+                text_value->append(data, size);
+                return true;
+            }
+            if(!binary_stream)
+            {
+                error_message = "The multipart request is malformed";
+                return false;
+            }
+            total_image_size += size;
+            if(part_size > MAX_IMAGE_SIZE ||
+               total_image_size > MAX_TOTAL_IMAGE_SIZE)
+            {
+                error_message = "An uploaded image is too large";
+                error_status = 413;
+                return false;
+            }
+            binary_stream.write(
+                data, static_cast<std::streamsize>(size));
+            if(!binary_stream)
+            {
+                error_message = "Failed to stage an uploaded image";
+                error_status = 500;
+                return false;
+            }
+            return true;
+        });
+    binary_stream.close();
+
+    if(!read)
+    {
+        if(error_message.empty())
+        {
+            error_message = "Failed to read the multipart request";
+        }
+        return std::unexpected(mw::httpError(
+            error_status, error_message));
+    }
+    return upload;
+}
diff --git a/src/multipart_reader.h b/src/multipart_reader.h
new file mode 100644
index 0000000..9e9c221
--- /dev/null
+++ b/src/multipart_reader.h
@@ -0,0 +1,56 @@
+#pragma once
+
+#include <filesystem>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include <httplib.h>
+#include <mw/error.hpp>
+
+/// Streamed fields and server-owned files from one card upload.
+struct CardUpload
+{
+    /// Private directory containing the staged binary fields.
+    std::filesystem::path staging_directory;
+
+    /// Singleton text fields indexed by their form names.
+    std::unordered_map<std::string, std::string> fields;
+
+    /// Repeated series membership values in request order.
+    std::vector<std::string> series_ids;
+
+    /// Staged front artwork, when supplied.
+    std::optional<std::filesystem::path> front;
+
+    /// Staged foil-control image, when supplied.
+    std::optional<std::filesystem::path> foil;
+
+    /// Staged browser-rendered thumbnail, when supplied.
+    std::optional<std::filesystem::path> thumbnail;
+};
+
+/// Stream a multipart card request into a private staging directory.
+class MultipartReader
+{
+public:
+    /// Construct a reader rooted below the private card storage directory.
+    explicit MultipartReader(std::filesystem::path card_storage_root);
+
+    /// Remove any staging directory which has not been published.
+    ~MultipartReader();
+
+    /// Prevent two readers from owning the same staging directory.
+    MultipartReader(const MultipartReader&) = delete;
+
+    /// Prevent reassignment of staging-directory cleanup ownership.
+    MultipartReader& operator=(const MultipartReader&) = delete;
+
+    /// Parse and stream one multipart request.
+    mw::E<CardUpload> read(const httplib::ContentReader& content_reader);
+
+private:
+    std::filesystem::path card_storage_root_;
+    std::filesystem::path staging_directory_;
+};
diff --git a/static/card_form.js b/static/card_form.js
index 080bcc1..bced84a 100644
--- a/static/card_form.js
+++ b/static/card_form.js
@@ -28,6 +28,123 @@ function updateFileMetadata(input, output)
     output.textContent = file == null ? "No file selected" : file.name;
 }
 
+/** Put one browser Blob into a file input for multipart submission. */
+function setInputFile(input, blob, filename)
+{
+    const transfer = new DataTransfer();
+    transfer.items.add(new File([blob], filename, {type: blob.type}));
+    input.files = transfer.files;
+}
+
+/** Refresh the WebGL preview from the currently selected local files. */
+function updateLocalPreview()
+{
+    const preview = window.cardPreview;
+    if(preview == null)
+    {
+        return Promise.resolve();
+    }
+    const front = document.getElementById("CardFrontFile").files[0];
+    const foil = document.getElementById("CardFoilFile").files[0];
+    return preview.setFiles(front, foil);
+}
+
+/** Fetch one CORS-enabled URL into its corresponding file input. */
+async function fetchImageInput(url_input, file_input, filename)
+{
+    const url = new URL(url_input.value);
+    if(url.protocol != "http:" && url.protocol != "https:")
+    {
+        throw(new Error("Image URLs must use HTTP or HTTPS."));
+    }
+    const response = await fetch(url, {
+        mode: "cors",
+        credentials: "omit",
+    });
+    if(!response.ok)
+    {
+        throw(new Error(
+            `Failed to fetch ${url}: HTTP ${response.status}`));
+    }
+    setInputFile(file_input, await response.blob(), filename);
+}
+
+/** Prepare URL images and the foil thumbnail before native submission. */
+async function prepareCardSubmission(event)
+{
+    const form = event.currentTarget;
+    if(form.dataset.prepared == "true")
+    {
+        return;
+    }
+    event.preventDefault();
+    if(form.dataset.state == "preparing")
+    {
+        return;
+    }
+    form.dataset.state = "preparing";
+    const submitter = event.submitter;
+    const submit_button = form.querySelector('button[type="submit"]');
+    submit_button.disabled = true;
+    const status = document.getElementById("CardFormStatus");
+    status.textContent = "Preparing card images…";
+
+    try
+    {
+        const mode = document.querySelector(
+            'input[name="source_mode"]:checked').value;
+        const front_input = document.getElementById("CardFrontFile");
+        const foil_input = document.getElementById("CardFoilFile");
+        if(mode == "urls")
+        {
+            foil_input.value = "";
+            await fetchImageInput(
+                document.getElementById("CardFrontUrl"),
+                front_input,
+                "front-upload");
+            const foil_url = document.getElementById("CardFoilUrl");
+            if(foil_url.value != "")
+            {
+                await fetchImageInput(foil_url, foil_input, "foil-upload");
+            }
+            front_input.disabled = false;
+            foil_input.disabled = false;
+        }
+
+        await updateLocalPreview();
+        if(foil_input.files.length != 0)
+        {
+            if(window.cardPreview == null)
+            {
+                throw(new Error("The foil preview is not ready."));
+            }
+            const thumbnail = await window.cardPreview.captureThumbnail();
+            const thumbnail_input = document.getElementById(
+                "CardThumbnailFile");
+            setInputFile(thumbnail_input, thumbnail, "thumbnail.png");
+            thumbnail_input.disabled = false;
+        }
+        form.dataset.prepared = "true";
+        form.dataset.state = "ready";
+        submit_button.disabled = false;
+        if(submitter == null)
+        {
+            form.requestSubmit();
+        }
+        else
+        {
+            form.requestSubmit(submitter);
+        }
+    }
+    catch(error)
+    {
+        console.error(error);
+        status.textContent = error.message;
+        form.dataset.state = "idle";
+        submit_button.disabled = false;
+    }
+}
+
 /** Connect the image-source controls after the form is available. */
 function initializeCardForm()
 {
@@ -44,13 +161,17 @@ function initializeCardForm()
         updateFileMetadata(
             front_input,
             document.getElementById("CardFrontFileMeta"));
+        updateLocalPreview().catch(console.error);
     });
     foil_input.addEventListener("change", function updateFoilMetadata()
     {
         updateFileMetadata(
             foil_input,
             document.getElementById("CardFoilFileMeta"));
+        updateLocalPreview().catch(console.error);
     });
+    document.getElementById("CardForm").addEventListener(
+        "submit", prepareCardSubmission);
     updateSourceMode();
 }
 
diff --git a/static/foil/card_preview.js b/static/foil/card_preview.js
index 0beb3bd..9982ab8 100644
--- a/static/foil/card_preview.js
+++ b/static/foil/card_preview.js
@@ -21,7 +21,9 @@ class ArtworkMaterial
     constructor(gl, artwork_url)
     {
         this.gl = gl;
-        this.artwork = new Texture(gl, artwork_url, {flip_y: true});
+        this.artwork = artwork_url instanceof Texture
+            ? artwork_url
+            : new Texture(gl, artwork_url, {flip_y: true});
         this.ready = this.artwork.ready;
     }
 
@@ -122,6 +124,83 @@ function asymptoticBound(value)
     return Math.atan(value) / Math.PI;
 }
 
+/** Find the inclusive nontransparent bounds in bottom-origin RGBA pixels. */
+function findAlphaBounds(pixels, width, height)
+{
+    let min_x = width;
+    let min_y = height;
+    let max_x = -1;
+    let max_y = -1;
+    for(let y = 0; y < height; ++y)
+    {
+        for(let x = 0; x < width; ++x)
+        {
+            if(pixels[(y * width + x) * 4 + 3] != 0)
+            {
+                min_x = Math.min(min_x, x);
+                min_y = Math.min(min_y, y);
+                max_x = Math.max(max_x, x);
+                max_y = Math.max(max_y, y);
+            }
+        }
+    }
+    return max_x < 0 ? null : {min_x, min_y, max_x, max_y};
+}
+
+/** Copy bounded bottom-origin WebGL pixels into top-origin ImageData. */
+function cropWebGLPixels(pixels, source_width, bounds)
+{
+    const width = bounds.max_x - bounds.min_x + 1;
+    const height = bounds.max_y - bounds.min_y + 1;
+    const image = new ImageData(width, height);
+    for(let output_y = 0; output_y < height; ++output_y)
+    {
+        const source_y = bounds.max_y - output_y;
+        for(let output_x = 0; output_x < width; ++output_x)
+        {
+            const source_x = bounds.min_x + output_x;
+            const source_offset =
+                (source_y * source_width + source_x) * 4;
+            const output_offset = (output_y * width + output_x) * 4;
+            image.data.set(
+                pixels.subarray(source_offset, source_offset + 4),
+                output_offset);
+        }
+    }
+    return image;
+}
+
+/** Scale dimensions proportionally so their long side is exact. */
+function scaleDimensions(width, height, long_side)
+{
+    if(!Number.isInteger(long_side) || long_side < 1)
+    {
+        throw(new Error("The thumbnail size is invalid."));
+    }
+    const scale = long_side / Math.max(width, height);
+    return {
+        width: Math.max(1, Math.round(width * scale)),
+        height: Math.max(1, Math.round(height * scale)),
+    };
+}
+
+/** Resolve one canvas PNG export or reject a failed encoding. */
+function canvasPng(canvas)
+{
+    return new Promise(function encode(resolve, reject)
+    {
+        canvas.toBlob(function finish(blob)
+        {
+            if(blob == null)
+            {
+                reject(new Error("Failed to encode the card thumbnail."));
+                return;
+            }
+            resolve(blob);
+        }, "image/png");
+    });
+}
+
 /** Internal compile-time shader variants for renderer quality. */
 const RENDER_QUALITY = Object.freeze({
     LOW: Object.freeze({
@@ -209,11 +288,122 @@ function main()
         });
 
         const light = new DiskLight(
-            [0, 0, 2.0],
-            [0, 0, -2.0],
+            [0, 1.0, 2.0],
+            [0, -1.0, -2.0],
             1,
             3.6);
         const camera_rotation = [0.0, 0.0];
+        let material_update = Promise.resolve();
+
+        /** Replace the preview material with selected local image files. */
+        function setFiles(front_file, foil_file)
+        {
+            if(front_file == null)
+            {
+                return material_update;
+            }
+            material_update = material_update.catch(function recover()
+            {
+                return undefined;
+            }).then(async function update()
+            {
+                const old_material = card.front_material;
+                let new_material = null;
+                if(foil_file == null)
+                {
+                    const artwork = await Texture.fromFile(
+                        gl, front_file, {flip_y: true});
+                    new_material = new ArtworkMaterial(gl, artwork);
+                }
+                else
+                {
+                    new_material = await PhysicalFoilMaterial.fromFiles(
+                        gl,
+                        front_file,
+                        foil_file,
+                        [0, 0, 0, 255],
+                        page_data.spectral_lut_url);
+                }
+                await new_material.ready;
+                for(const model of card.scene.models)
+                {
+                    if(model.material == old_material)
+                    {
+                        model.material = new_material;
+                    }
+                }
+                card.front_material = new_material;
+                old_material.dispose();
+                showPreviewStatus(
+                    foil_file == null ? "Standard finish" : "Foil finish",
+                    "success");
+            }).catch(function reportUpdateFailure(error)
+            {
+                console.error(error);
+                showPreviewStatus(error.message, "error");
+                throw(error);
+            });
+            return material_update;
+        }
+
+        /** Capture a cropped neutral PNG after pending textures load. */
+        async function captureThumbnail(
+            long_side = page_data.thumbnail_long_side)
+        {
+            await material_update;
+            const previous_rotation = camera_rotation.slice();
+            camera_rotation[0] = 0.0;
+            camera_rotation[1] = 0.0;
+            try
+            {
+                renderer.draw(
+                    card.scene,
+                    calculateMatrices(canvas, camera_rotation),
+                    light);
+
+                const pixels = new Uint8Array(
+                    canvas.width * canvas.height * 4);
+                gl.readPixels(
+                    0,
+                    0,
+                    canvas.width,
+                    canvas.height,
+                    gl.RGBA,
+                    gl.UNSIGNED_BYTE,
+                    pixels);
+                const bounds = findAlphaBounds(
+                    pixels, canvas.width, canvas.height);
+                if(bounds == null)
+                {
+                    throw(new Error(
+                        "The card preview is fully transparent."));
+                }
+                const cropped = cropWebGLPixels(
+                    pixels, canvas.width, bounds);
+                const source_canvas = document.createElement("canvas");
+                source_canvas.width = cropped.width;
+                source_canvas.height = cropped.height;
+                source_canvas.getContext("2d").putImageData(
+                    cropped, 0, 0);
+
+                const scaled = scaleDimensions(
+                    cropped.width, cropped.height, long_side);
+                const output_canvas = document.createElement("canvas");
+                output_canvas.width = scaled.width;
+                output_canvas.height = scaled.height;
+                output_canvas.getContext("2d").drawImage(
+                    source_canvas, 0, 0, scaled.width, scaled.height);
+                return await canvasPng(output_canvas);
+            }
+            finally
+            {
+                camera_rotation[0] = previous_rotation[0];
+                camera_rotation[1] = previous_rotation[1];
+            }
+        }
+
+        /** Form-facing preview controls for local files and thumbnails. */
+        window.cardPreview = {setFiles, captureThumbnail};
 
         /** Draw one animation frame using the latest pointer rotation. */
         function render()
diff --git a/static/foil/libwebgl.js b/static/foil/libwebgl.js
index 9163324..4739f81 100644
--- a/static/foil/libwebgl.js
+++ b/static/foil/libwebgl.js
@@ -515,6 +515,11 @@ class SpectralLut
         this.pending_reported = false;
         this.url = url;
         this.gl = gl;
+        this.ready = new Promise(function storeCompletion(resolve, reject)
+        {
+            this.resolve_load = resolve;
+            this.reject_load = reject;
+        }.bind(this));
 
         gl.activeTexture(gl.TEXTURE0);
         gl.bindTexture(gl.TEXTURE_2D, this.texture);
@@ -541,8 +546,9 @@ class SpectralLut
         if(request.status != 200 && request.status != 0)
         {
             this.failed = true;
-            throw(new Error(
+            this.reject_load(new Error(
                 `Failed to load ${this.url}: HTTP ${request.status}`));
+            return;
         }
         if(request.response == null || request.response.byteLength != 6416)
         {
@@ -550,9 +556,10 @@ class SpectralLut
             const actual_size = request.response == null
                 ? 0
                 : request.response.byteLength;
-            throw(new Error(
+            this.reject_load(new Error(
                 `Malformed ${this.url}: expected 6416 bytes, got `
                 + actual_size));
+            return;
         }
 
         const gl = this.gl;
@@ -561,6 +568,7 @@ class SpectralLut
                       gl.RGBA, gl.FLOAT,
                       new Float32Array(request.response));
         this.loaded = true;
+        this.resolve_load(this);
         for(const listener of this.load_listeners)
         {
             listener(this);
@@ -571,7 +579,8 @@ class SpectralLut
     handleError()
     {
         this.failed = true;
-        throw(new Error(`Failed to load spectral table: ${this.url}`));
+        this.reject_load(new Error(
+            `Failed to load spectral table: ${this.url}`));
     }
 
     /** Run a callback after successful validation and upload. */
@@ -632,6 +641,7 @@ class PhysicalFoilMaterial
         this.ready = Promise.all([
             this.artwork.ready,
             this.foil_control.ready,
+            this.spectral_lut.ready,
         ]).then(function finishLoadedMaterial()
         {
             return this;
diff --git a/templates/card_form.html b/templates/card_form.html
index 2f2476b..9065b24 100644
--- a/templates/card_form.html
+++ b/templates/card_form.html
@@ -56,6 +56,8 @@
                 </div>
 
                 <div id="FileSourceFields" class="source-fields">
+                    <input id="CardThumbnailFile" name="thumbnail"
+                           type="file" hidden disabled>
                     <label class="form-field image-field"
                            for="CardFrontFile">
                         <span>Front artwork</span>
@@ -68,7 +70,7 @@
                            for="CardFoilFile">
                         <span>Foil control <em>Optional</em></span>
                         <input id="CardFoilFile" name="foil" type="file"
-                               accept="image/png,image/webp,image/avif">
+                               accept="image/png,image/jpeg,image/webp,.avif">
                         <small id="CardFoilFileMeta">No file selected</small>
                     </label>
                 </div>
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
new file mode 100644
index 0000000..0b3eb51
--- /dev/null
+++ b/tests/card_service_test.cpp
@@ -0,0 +1,268 @@
+#include "card_service.h"
+#include "data_sqlite.h"
+#include "game_registry.h"
+#include "multipart_reader.h"
+#include "startup.h"
+
+#include <chrono>
+#include <filesystem>
+#include <fstream>
+#include <iterator>
+#include <memory>
+#include <optional>
+#include <string>
+#include <system_error>
+
+#include <Magick++.h>
+#include <gtest/gtest.h>
+
+namespace
+{
+
+class TemporaryCardRoot
+{
+public:
+    /// Allocate a unique temporary root for a card-service test.
+    TemporaryCardRoot()
+            : path_(
+                std::filesystem::path(testing::TempDir()) /
+                ("card_service_" + std::to_string(
+                    std::chrono::steady_clock::now()
+                        .time_since_epoch().count())))
+    {
+        std::filesystem::create_directories(path_ / ".staging/upload");
+    }
+
+    /// Remove all temporary database and asset files.
+    ~TemporaryCardRoot()
+    {
+        std::error_code error;
+        std::filesystem::remove_all(path_, error);
+    }
+
+    /// Return the temporary root.
+    const std::filesystem::path& path() const
+    {
+        return path_;
+    }
+
+private:
+    std::filesystem::path path_;
+};
+
+void initializeImageMagick()
+{
+    static const bool initialized = []
+    {
+        Magick::InitializeMagick(nullptr);
+        Magick::ResourceLimits::listLength(2);
+        Magick::ResourceLimits::width(2048);
+        Magick::ResourceLimits::height(2048);
+        return true;
+    }();
+    ASSERT_TRUE(initialized);
+}
+
+void writePng(const std::filesystem::path& path)
+{
+    Magick::Image image(Magick::Geometry(350, 490), Magick::Color("navy"));
+    image.write("PNG:" + path.string());
+}
+
+void writeJpeg(const std::filesystem::path& path)
+{
+    Magick::Image image(Magick::Geometry(350, 490), Magick::Color("navy"));
+    image.write("JPEG:" + path.string());
+}
+
+std::string readFile(const std::filesystem::path& path)
+{
+    std::ifstream input(path, std::ios::binary);
+    return std::string(
+        std::istreambuf_iterator<char>(input),
+        std::istreambuf_iterator<char>());
+}
+
+} // namespace
+
+/// Verify card creation publishes normalized assets with its committed row.
+TEST(CardServiceTest, CreatesLooseCard)
+{
+    initializeImageMagick();
+    TemporaryCardRoot temporary;
+    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+    GameRegistry games;
+    auto data_source = prepareDataSource(database, games);
+    ASSERT_TRUE(data_source);
+
+    const std::filesystem::path staging =
+        temporary.path() / ".staging/upload";
+    const std::filesystem::path front = staging / "upload_front";
+    writePng(front);
+
+    CardService service(
+        **data_source,
+        ImageProcessor(75, 256),
+        AssetStore(temporary.path()));
+    auto public_id = service.createLooseCard({
+        "Moon card",
+        std::optional<std::string>("Short"),
+        std::nullopt,
+        4,
+        staging,
+        front,
+        std::nullopt,
+        std::nullopt,
+    });
+
+    ASSERT_TRUE(public_id) << public_id.error().msg();
+    auto cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    ASSERT_EQ(cards->size(), 1);
+    EXPECT_EQ(cards->front().name, "Moon card");
+    EXPECT_EQ(cards->front().front_extension, "avif");
+    EXPECT_EQ(cards->front().thumbnail_extension, "avif");
+    auto stored_card = (*data_source)->getCard(cards->front().identity);
+    ASSERT_TRUE(stored_card);
+    ASSERT_TRUE(*stored_card);
+    EXPECT_EQ((**stored_card).id, cards->front().id);
+    auto memberships = (*data_source)->getCardSeries(cards->front().id);
+    ASSERT_TRUE(memberships);
+    EXPECT_TRUE(memberships->empty());
+    const std::filesystem::path published =
+        temporary.path() / "published" / *public_id;
+    EXPECT_TRUE(
+        std::filesystem::is_regular_file(published / "front-art.avif"));
+    EXPECT_TRUE(std::filesystem::is_regular_file(published / "thumb.avif"));
+    EXPECT_FALSE(std::filesystem::exists(staging));
+}
+
+/// Verify invalid image bytes leave both persistence domains unchanged.
+TEST(CardServiceTest, RejectsInvalidImage)
+{
+    initializeImageMagick();
+    TemporaryCardRoot temporary;
+    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+    GameRegistry games;
+    auto data_source = prepareDataSource(database, games);
+    ASSERT_TRUE(data_source);
+
+    const std::filesystem::path staging =
+        temporary.path() / ".staging/upload";
+    const std::filesystem::path front = staging / "upload_front";
+    {
+        std::ofstream output(front, std::ios::binary);
+        output << "not an image";
+    }
+    CardService service(
+        **data_source,
+        ImageProcessor(75, 256),
+        AssetStore(temporary.path()));
+    auto public_id = service.createLooseCard({
+        "Bad card",
+        std::nullopt,
+        std::nullopt,
+        0,
+        staging,
+        front,
+        std::nullopt,
+        std::nullopt,
+    });
+
+    EXPECT_FALSE(public_id);
+    auto cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    EXPECT_TRUE(cards->empty());
+    EXPECT_FALSE(std::filesystem::exists(temporary.path() / "published"));
+}
+
+/// Verify an opaque JPEG is accepted as a foil-control texture.
+TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
+{
+    initializeImageMagick();
+    TemporaryCardRoot temporary;
+    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+    GameRegistry games;
+    auto data_source = prepareDataSource(database, games);
+    ASSERT_TRUE(data_source);
+
+    const std::filesystem::path staging =
+        temporary.path() / ".staging/upload";
+    const std::filesystem::path front = staging / "upload_front";
+    const std::filesystem::path foil = staging / "upload_foil";
+    const std::filesystem::path thumbnail = staging / "upload_thumbnail";
+    writeJpeg(front);
+    writeJpeg(foil);
+    writePng(thumbnail);
+
+    CardService service(
+        **data_source,
+        ImageProcessor(75, 256),
+        AssetStore(temporary.path()));
+    auto public_id = service.createLooseCard({
+        "Opaque foil",
+        std::nullopt,
+        std::nullopt,
+        0,
+        staging,
+        front,
+        foil,
+        thumbnail,
+    });
+
+    ASSERT_TRUE(public_id) << public_id.error().msg();
+    auto cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    ASSERT_EQ(cards->size(), 1);
+    EXPECT_EQ(cards->front().foil_extension, "jpg");
+    const std::filesystem::path published =
+        temporary.path() / "published" / *public_id;
+    EXPECT_TRUE(std::filesystem::is_regular_file(published / "foil.jpg"));
+}
+
+/// Verify multipart binaries use server paths and empty file controls vanish.
+TEST(MultipartReaderTest, StreamsExpectedFields)
+{
+    initializeImageMagick();
+    TemporaryCardRoot temporary;
+    const std::filesystem::path source = temporary.path() / "source.png";
+    writePng(source);
+    const std::string image_bytes = readFile(source);
+
+    const httplib::ContentReader content_reader(
+        [](httplib::ContentReceiver)
+        {
+            return false;
+        },
+        [&](httplib::FormDataHeader header,
+            httplib::ContentReceiver receiver)
+        {
+            httplib::FormData name;
+            name.name = "name";
+            if(!header(name) || !receiver("Streamed card", 13))
+            {
+                return false;
+            }
+            httplib::FormData front;
+            front.name = "front";
+            front.filename = "../../browser-name.png";
+            if(!header(front) ||
+               !receiver(image_bytes.data(), image_bytes.size()))
+            {
+                return false;
+            }
+            httplib::FormData empty_foil;
+            empty_foil.name = "foil";
+            return header(empty_foil);
+        });
+
+    MultipartReader reader(temporary.path());
+    auto upload = reader.read(content_reader);
+
+    ASSERT_TRUE(upload);
+    EXPECT_EQ(upload->fields.at("name"), "Streamed card");
+    ASSERT_TRUE(upload->front);
+    EXPECT_EQ(upload->front->filename(), "upload_front");
+    EXPECT_EQ(readFile(*upload->front), image_bytes);
+    EXPECT_FALSE(upload->foil);
+}
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index 4990556..00a9c9b 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -3,9 +3,11 @@
 #include "startup.h"
 
 #include <chrono>
+#include <cstdint>
 #include <filesystem>
 #include <memory>
 #include <string>
+#include <utility>
 
 #include <gtest/gtest.h>
 
@@ -43,6 +45,22 @@ private:
     std::filesystem::path path_;
 };
 
+Card makeLooseCard(std::uint32_t number, std::string name)
+{
+    return {
+        0,
+        {std::nullopt, number},
+        std::move(name),
+        std::nullopt,
+        std::nullopt,
+        0,
+        "jpg",
+        std::nullopt,
+        "avif",
+        1,
+    };
+}
+
 } // namespace
 
 /// Verify the SQLite factory owns an opened connection.
@@ -62,11 +80,8 @@ TEST(DataSourceSQLiteTest, ReportsPlaceholderOperations)
     std::unique_ptr<DataSourceSQLite> data_source =
         std::move(*data_source_result);
 
-    EXPECT_FALSE(data_source->beginTransaction());
-    EXPECT_FALSE(data_source->getCard({std::nullopt, 1}));
     EXPECT_FALSE(data_source->getSeries());
     EXPECT_FALSE(data_source->getSeries(1));
-    EXPECT_FALSE(data_source->getCardSeries(1));
     EXPECT_FALSE(data_source->getPersistedGameNames());
 }
 
@@ -143,6 +158,12 @@ TEST(DataSourceSQLiteTest, ReturnsCards)
     EXPECT_EQ((*cards)[1].foil_extension, "webp");
     EXPECT_EQ((*cards)[1].thumbnail_extension, "avif");
     EXPECT_EQ((*cards)[1].revision, 3);
+
+    auto loose_card = (*data_source)->getCard({std::nullopt, 35});
+    ASSERT_TRUE(loose_card);
+    ASSERT_TRUE(*loose_card);
+    EXPECT_EQ((**loose_card).id, 1);
+    EXPECT_EQ((**loose_card).name, "Loose card");
 }
 
 /// Verify invalid persisted card identities fail the complete read.
@@ -166,3 +187,100 @@ TEST(DataSourceSQLiteTest, RejectsInvalidCards)
     ASSERT_TRUE(data_source);
     EXPECT_FALSE((*data_source)->getCards());
 }
+
+/// Verify a committed transaction persists a complete loose card.
+TEST(DataSourceSQLiteTest, InsertsLooseCard)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source);
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+
+    auto exists_before = (*transaction)->looseNumberExists(42);
+    ASSERT_TRUE(exists_before);
+    EXPECT_FALSE(*exists_before);
+    auto card_id = (*transaction)->insertCard(
+        makeLooseCard(42, "New card"), nullptr, nullptr, {});
+    ASSERT_TRUE(card_id);
+    EXPECT_GT(*card_id, 0);
+    auto exists_after = (*transaction)->looseNumberExists(42);
+    ASSERT_TRUE(exists_after);
+    EXPECT_TRUE(*exists_after);
+    ASSERT_TRUE((*transaction)->commit());
+
+    auto cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    ASSERT_EQ(cards->size(), 1);
+    EXPECT_EQ(cards->front().id, *card_id);
+    EXPECT_EQ(cards->front().identity.card_number, 42);
+    EXPECT_EQ(cards->front().name, "New card");
+}
+
+/// Verify destroying an uncommitted transaction rolls card insertion back.
+TEST(DataSourceSQLiteTest, RollsBackLooseCard)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source);
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->insertCard(
+        makeLooseCard(91, "Temporary card"), nullptr, nullptr, {}));
+
+    transaction->reset();
+
+    auto cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    EXPECT_TRUE(cards->empty());
+}
+
+/// Verify game sequence numbers increase and survive committed transactions.
+TEST(DataSourceSQLiteTest, AllocatesGameNumbers)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source);
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->ensureGameSequence("pkm"));
+
+    auto first = (*transaction)->allocateGameNumber("pkm");
+    auto second = (*transaction)->allocateGameNumber("pkm");
+    ASSERT_TRUE(first);
+    ASSERT_TRUE(second);
+    EXPECT_EQ(*first, 1);
+    EXPECT_EQ(*second, 2);
+    ASSERT_TRUE((*transaction)->commit());
+
+    auto next_transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(next_transaction);
+    auto third = (*next_transaction)->allocateGameNumber("pkm");
+    ASSERT_TRUE(third);
+    EXPECT_EQ(*third, 3);
+}
+
+/// Verify the loose-card unique index rejects a repeated number.
+TEST(DataSourceSQLiteTest, RejectsDuplicateLooseNumber)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source);
+    auto first_transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(first_transaction);
+    ASSERT_TRUE((*first_transaction)->insertCard(
+        makeLooseCard(8, "First"), nullptr, nullptr, {}));
+    ASSERT_TRUE((*first_transaction)->commit());
+
+    auto second_transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(second_transaction);
+    auto exists = (*second_transaction)->looseNumberExists(8);
+    ASSERT_TRUE(exists);
+    EXPECT_TRUE(*exists);
+    EXPECT_FALSE((*second_transaction)->insertCard(
+        makeLooseCard(8, "Duplicate"), nullptr, nullptr, {}));
+}