Load source images in the background with a progress window

Decoding a batch of photographs took seconds on the main thread, so the
window froze with no sign of what was happening. loadImages now takes an
optional LoadProgress (per-file callback plus a cancel flag) and hands files
out from a shared cursor, so one large file cannot stall the queue behind it.

The GUI decodes off the main thread and shows a progress window with the
count, the current file and a Cancel button; it only appears if the batch is
still running after 250 ms. Headless mode keeps a "reading n/total" line on
stderr when it is attached to a terminal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hans
2026-09-18 14:39:07 +02:00
co-authored by Claude Opus 5
parent 925a719da0
commit 7e98357070
7 changed files with 317 additions and 15 deletions
+11
View File
@@ -37,6 +37,13 @@ build/effector -o out.png -s 1920x1080 \
Any option can also be passed when launching the interface; it becomes the Any option can also be passed when launching the interface; it becomes the
starting state (images, canvas size, effect settings, seed). starting state (images, canvas size, effect settings, seed).
Reading a batch of large photographs takes seconds, so it never blocks the
interface: decoding runs in the background and a progress window shows how many
files are done, which one is being read, and a Cancel button that stops the
batch. Small batches finish before the window would be drawn, so it only appears
if the decode is still going after 250 ms. On a terminal the headless mode keeps
a single `reading n/total` line updated on stderr; redirected output stays clean.
## Effects ## Effects
### scanlines ### scanlines
@@ -113,3 +120,7 @@ are decoded in parallel too, which is what dominates a headless run.
A 7680×4320 canvas at `--strip-height 2` from three multi-megapixel JPEGs renders A 7680×4320 canvas at `--strip-height 2` from three multi-megapixel JPEGs renders
in ~35 ms, against ~73 ms for the Python prototype; the interface reports the in ~35 ms, against ~73 ms for the Python prototype; the interface reports the
render time of the current preview in its status line. render time of the current preview in its status line.
Decoding threads take the next file from a shared cursor rather than a fixed
slice of the list, so one very large file cannot stall the files queued behind
it and progress advances at a steady rate.
+36 -11
View File
@@ -2,14 +2,14 @@
#include <gdk-pixbuf/gdk-pixbuf.h> #include <gdk-pixbuf/gdk-pixbuf.h>
#include "core/parallel.hpp"
#include <algorithm> #include <algorithm>
#include <atomic>
#include <cctype> #include <cctype>
#include <cstring> #include <cstring>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <stdexcept> #include <stdexcept>
#include <thread>
namespace effector { namespace effector {
namespace { namespace {
@@ -108,30 +108,55 @@ void Image::save(const std::string& path) const {
} }
std::vector<Image> loadImages(const std::vector<std::string>& paths, std::vector<Image> loadImages(const std::vector<std::string>& paths,
std::vector<LoadFailure>& failures) { std::vector<LoadFailure>& failures,
const LoadProgress* progress) {
// Decoding is the dominant cost of a headless run, and the files are // Decoding is the dominant cost of a headless run, and the files are
// independent, so they are decoded in parallel. Slots keep the result in // independent, so they are decoded in parallel. Threads take the next path
// the order the paths were given. // from a shared cursor rather than a fixed slice, so one huge file cannot
// stall the files behind it and progress arrives at a steady rate. Slots
// keep the result in the order the paths were given.
std::vector<Image> slots(paths.size()); std::vector<Image> slots(paths.size());
std::vector<std::string> errors(paths.size()); std::vector<std::string> errors(paths.size());
std::atomic<std::size_t> cursor{0};
std::atomic<std::size_t> done{0};
parallelFor(paths.size(), [&](std::size_t begin, std::size_t end) { unsigned hardware = std::thread::hardware_concurrency();
for (std::size_t i = begin; i < end; ++i) { if (hardware == 0) hardware = 1;
const std::size_t workers = std::min<std::size_t>(hardware, std::max<std::size_t>(paths.size(), 1));
const auto decode = [&] {
for (;;) {
if (progress != nullptr && progress->cancel != nullptr &&
progress->cancel->load(std::memory_order_relaxed)) {
return;
}
const std::size_t i = cursor.fetch_add(1, std::memory_order_relaxed);
if (i >= paths.size()) return;
try { try {
slots[i] = Image::load(paths[i]); slots[i] = Image::load(paths[i]);
} catch (const std::exception& error) { } catch (const std::exception& error) {
errors[i] = error.what(); errors[i] = error.what();
} }
if (progress != nullptr && progress->onFile) {
progress->onFile(done.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(),
paths[i]);
} }
}, 1); }
};
std::vector<std::thread> threads;
threads.reserve(workers > 0 ? workers - 1 : 0);
for (std::size_t w = 1; w < workers; ++w) threads.emplace_back(decode);
decode();
for (auto& thread : threads) thread.join();
std::vector<Image> images; std::vector<Image> images;
images.reserve(paths.size()); images.reserve(paths.size());
for (std::size_t i = 0; i < paths.size(); ++i) { for (std::size_t i = 0; i < paths.size(); ++i) {
if (errors[i].empty()) { if (!errors[i].empty()) {
images.push_back(std::move(slots[i]));
} else {
failures.push_back({paths[i], errors[i]}); failures.push_back({paths[i], errors[i]});
} else if (!slots[i].empty()) {
images.push_back(std::move(slots[i]));
} }
} }
return images; return images;
+14 -1
View File
@@ -1,7 +1,9 @@
#pragma once #pragma once
#include <atomic>
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <functional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -49,8 +51,19 @@ struct LoadFailure {
std::string message; std::string message;
}; };
// Lets a caller follow a long batch decode and abort it. Both members are
// touched from the decoding threads, so `onFile` must be thread-safe and must
// not block: the GUI only bumps a counter from it and wakes the main thread.
struct LoadProgress {
std::function<void(std::size_t done, std::size_t total, const std::string& path)> onFile;
std::atomic<bool>* cancel = nullptr;
};
// Loads every path, collecting rather than throwing on individual failures. // Loads every path, collecting rather than throwing on individual failures.
// When `progress->cancel` is set the remaining paths are skipped, so the result
// is short and should be discarded rather than used.
std::vector<Image> loadImages(const std::vector<std::string>& paths, std::vector<Image> loadImages(const std::vector<std::string>& paths,
std::vector<LoadFailure>& failures); std::vector<LoadFailure>& failures,
const LoadProgress* progress = nullptr);
} // namespace effector } // namespace effector
+165 -1
View File
@@ -38,6 +38,8 @@ EffectorWindow::EffectorWindow(const Options& initial) {
seed_ = Rng::randomSeed(); seed_ = Rng::randomSeed();
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished)); renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress));
loadFinished_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadFinished));
buildUi(); buildUi();
applyInitial(initial); applyInitial(initial);
@@ -48,6 +50,9 @@ EffectorWindow::EffectorWindow(const Options& initial) {
EffectorWindow::~EffectorWindow() { EffectorWindow::~EffectorWindow() {
debounce_.disconnect(); debounce_.disconnect();
loadWindowDelay_.disconnect();
loadCancel_.store(true, std::memory_order_relaxed);
if (loader_.joinable()) loader_.join();
if (worker_.joinable()) worker_.join(); if (worker_.joinable()) worker_.join();
} }
@@ -62,6 +67,7 @@ void EffectorWindow::buildUi() {
addButton->set_tooltip_text("Add images"); addButton->set_tooltip_text("Add images");
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages)); addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
header->pack_start(*addButton); header->pack_start(*addButton);
addButton_ = addButton;
std::vector<Glib::ustring> titles; std::vector<Glib::ustring> titles;
for (const auto& effect : effects_) titles.push_back(effect->title()); for (const auto& effect : effects_) titles.push_back(effect->title());
@@ -330,8 +336,94 @@ void EffectorWindow::mutateSources(Mutate&& mutate) {
} }
void EffectorWindow::addPaths(const std::vector<std::string>& paths) { void EffectorWindow::addPaths(const std::vector<std::string>& paths) {
if (paths.empty()) return;
if (loading_) { // a second selection arrived mid-batch; take it afterwards
loadQueue_.insert(loadQueue_.end(), paths.begin(), paths.end());
return;
}
loading_ = true;
loadCancel_.store(false, std::memory_order_relaxed);
loadDone_.store(0, std::memory_order_relaxed);
loadTotal_ = paths.size();
{
std::lock_guard<std::mutex> lock(loadMutex_);
loadCurrent_.clear();
}
if (addButton_ != nullptr) addButton_->set_sensitive(false);
// A couple of small files decode faster than a window can be drawn, so the
// progress window only appears if the batch is still going after a moment.
loadWindowDelay_.disconnect();
loadWindowDelay_ = Glib::signal_timeout().connect(
[this] {
showLoadWindow();
return false;
},
250);
loader_ = std::thread([this, paths] {
LoadProgress progress;
progress.cancel = &loadCancel_;
progress.onFile = [this](std::size_t done, std::size_t, const std::string& path) {
{
std::lock_guard<std::mutex> lock(loadMutex_);
loadCurrent_ = path;
}
loadDone_.store(done, std::memory_order_relaxed);
loadProgress_.emit();
};
std::vector<LoadFailure> failures; std::vector<LoadFailure> failures;
auto images = loadImages(paths, failures); auto images = loadImages(paths, failures, &progress);
{
std::lock_guard<std::mutex> lock(loadMutex_);
loadedImages_ = std::move(images);
loadFailures_ = std::move(failures);
}
loadFinished_.emit();
});
}
void EffectorWindow::onLoadProgress() {
const std::size_t done = loadDone_.load(std::memory_order_relaxed);
std::string current;
{
std::lock_guard<std::mutex> lock(loadMutex_);
current = loadCurrent_;
}
if (loadBar_ != nullptr && loadTotal_ > 0) {
loadBar_->set_fraction(static_cast<double>(done) / static_cast<double>(loadTotal_));
loadBar_->set_text(std::to_string(done) + " of " + std::to_string(loadTotal_));
}
if (loadFileLabel_ != nullptr && !current.empty()) {
loadFileLabel_->set_text(std::filesystem::path(current).filename().string());
}
}
void EffectorWindow::onLoadFinished() {
if (loader_.joinable()) loader_.join();
loading_ = false;
loadWindowDelay_.disconnect();
closeLoadWindow();
if (addButton_ != nullptr) addButton_->set_sensitive(true);
std::vector<Image> images;
std::vector<LoadFailure> failures;
{
std::lock_guard<std::mutex> lock(loadMutex_);
images = std::move(loadedImages_);
failures = std::move(loadFailures_);
loadedImages_.clear();
loadFailures_.clear();
}
if (loadCancel_.load(std::memory_order_relaxed)) {
// A cancelled batch is incomplete, so none of it is taken.
loadQueue_.clear();
showError("Loading cancelled.");
return;
}
for (const auto& image : images) appendSourceRow(image.path()); for (const auto& image : images) appendSourceRow(image.path());
mutateSources([&images](SourceList& list) { mutateSources([&images](SourceList& list) {
@@ -349,6 +441,78 @@ void EffectorWindow::addPaths(const std::vector<std::string>& paths) {
clearError(); clearError();
} }
requestRender(); requestRender();
if (!loadQueue_.empty()) {
auto queued = std::move(loadQueue_);
loadQueue_.clear();
addPaths(queued);
}
}
void EffectorWindow::showLoadWindow() {
if (!loading_ || loadWindow_) return;
loadWindow_ = std::make_unique<Gtk::Window>();
loadWindow_->set_title("Reading images");
loadWindow_->set_transient_for(*this);
loadWindow_->set_modal(true);
loadWindow_->set_resizable(false);
loadWindow_->set_default_size(420, -1);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
box->set_margin(18);
auto* heading = Gtk::make_managed<Gtk::Label>("Reading source images…");
heading->set_xalign(0.0F);
heading->add_css_class("heading");
box->append(*heading);
auto* bar = Gtk::make_managed<Gtk::ProgressBar>();
bar->set_show_text(true);
bar->set_text("0 of " + std::to_string(loadTotal_));
box->append(*bar);
loadBar_ = bar;
auto* file = Gtk::make_managed<Gtk::Label>("");
file->set_xalign(0.0F);
file->set_ellipsize(Pango::EllipsizeMode::MIDDLE);
file->add_css_class("dim-label");
box->append(*file);
loadFileLabel_ = file;
auto* cancel = Gtk::make_managed<Gtk::Button>("Cancel");
cancel->set_halign(Gtk::Align::END);
cancel->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::cancelLoad));
box->append(*cancel);
// Closing the window means the same thing as pressing Cancel, but the
// window stays up until the decoding threads have actually stopped.
loadWindow_->signal_close_request().connect(
[this] {
cancelLoad();
return true;
},
false);
loadWindow_->set_child(*box);
onLoadProgress(); // show whatever finished while the window was pending
loadWindow_->present();
}
void EffectorWindow::closeLoadWindow() {
loadBar_ = nullptr;
loadFileLabel_ = nullptr;
if (!loadWindow_) return;
loadWindow_->set_hide_on_close(false);
loadWindow_->hide();
loadWindow_.reset();
}
void EffectorWindow::cancelLoad() {
if (!loading_) return;
loadCancel_.store(true, std::memory_order_relaxed);
if (loadBar_ != nullptr) loadBar_->set_text("Cancelling…");
if (loadFileLabel_ != nullptr) loadFileLabel_->set_text("");
} }
void EffectorWindow::appendSourceRow(const std::string& path) { void EffectorWindow::appendSourceRow(const std::string& path) {
+28 -1
View File
@@ -2,6 +2,7 @@
#include <gtkmm.h> #include <gtkmm.h>
#include <atomic>
#include <cstdint> #include <cstdint>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
@@ -34,7 +35,12 @@ private:
void onAddImages(); void onAddImages();
void onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result, void onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog); const Glib::RefPtr<Gtk::FileDialog>& dialog);
void addPaths(const std::vector<std::string>& paths); void addPaths(const std::vector<std::string>& paths); // decodes in the background
void onLoadProgress(); // dispatcher: one file finished decoding
void onLoadFinished(); // dispatcher: the batch is done or was cancelled
void showLoadWindow();
void closeLoadWindow();
void cancelLoad();
void appendSourceRow(const std::string& path); void appendSourceRow(const std::string& path);
void removeSource(Gtk::ListBoxRow* row); void removeSource(Gtk::ListBoxRow* row);
@@ -65,6 +71,22 @@ private:
std::uint64_t seed_ = 0; std::uint64_t seed_ = 0;
bool suspendRender_ = true; bool suspendRender_ = true;
// Source loading. Decoding a batch of large photographs takes seconds, so
// it runs off the main thread and reports into a small progress window.
std::thread loader_;
Glib::Dispatcher loadProgress_;
Glib::Dispatcher loadFinished_;
std::atomic<bool> loadCancel_{false};
std::atomic<std::size_t> loadDone_{0};
std::size_t loadTotal_ = 0;
std::mutex loadMutex_;
std::string loadCurrent_;
std::vector<Image> loadedImages_;
std::vector<LoadFailure> loadFailures_;
std::vector<std::string> loadQueue_; // chosen while another batch was loading
bool loading_ = false;
sigc::connection loadWindowDelay_;
// Render job // Render job
Glib::Dispatcher renderDone_; Glib::Dispatcher renderDone_;
std::thread worker_; std::thread worker_;
@@ -78,6 +100,11 @@ private:
// Widgets // Widgets
Gtk::Label banner_; Gtk::Label banner_;
Gtk::Button* addButton_ = nullptr;
// Built fresh with the progress window, and null whenever it is closed.
std::unique_ptr<Gtk::Window> loadWindow_;
Gtk::ProgressBar* loadBar_ = nullptr;
Gtk::Label* loadFileLabel_ = nullptr;
Gtk::ListBox sourceList_; Gtk::ListBox sourceList_;
Gtk::SpinButton widthSpin_; Gtk::SpinButton widthSpin_;
Gtk::SpinButton heightSpin_; Gtk::SpinButton heightSpin_;
+20 -1
View File
@@ -1,7 +1,11 @@
#include <unistd.h>
#include <chrono> #include <chrono>
#include <cstdio> #include <cstdio>
#include <filesystem>
#include <iomanip> #include <iomanip>
#include <iostream> #include <iostream>
#include <mutex>
#include "cli/options.hpp" #include "cli/options.hpp"
#include "core/effect.hpp" #include "core/effect.hpp"
@@ -16,7 +20,22 @@ int renderHeadless(const effector::Options& options) {
const auto effect = effector::effectByName(options.effect); const auto effect = effector::effectByName(options.effect);
std::vector<effector::LoadFailure> failures; std::vector<effector::LoadFailure> failures;
auto sources = effector::loadImages(options.images, failures);
// Decoding a batch of photographs takes seconds; on a terminal keep a
// single line updated so the wait is not silent. Redirected output stays
// clean, since the line would only clutter a log.
effector::LoadProgress progress;
std::mutex progressMutex;
const bool showProgress = isatty(fileno(stderr)) != 0 && options.images.size() > 1;
if (showProgress) {
progress.onFile = [&](std::size_t done, std::size_t total, const std::string& path) {
std::lock_guard<std::mutex> lock(progressMutex);
std::cerr << "\r\033[Kreading " << done << "/" << total << " "
<< std::filesystem::path(path).filename().string() << std::flush;
};
}
auto sources = effector::loadImages(options.images, failures, showProgress ? &progress : nullptr);
if (showProgress) std::cerr << "\r\033[K" << std::flush;
for (const auto& failure : failures) { for (const auto& failure : failures) {
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n"; std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
} }
+43
View File
@@ -1,9 +1,11 @@
// Small self-contained checks for the pieces that have no GUI dependency. // Small self-contained checks for the pieces that have no GUI dependency.
#include <atomic>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <filesystem> #include <filesystem>
#include <iostream> #include <iostream>
#include <mutex>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -144,6 +146,47 @@ int main() {
check(threw, "missing file throws"); check(threw, "missing file throws");
} }
std::cout << "batch loading\n";
{
const auto dir = std::filesystem::temp_directory_path() / "effector_test_batch";
std::filesystem::create_directories(dir);
std::vector<std::string> paths;
for (int i = 0; i < 6; ++i) {
const auto path = dir / ("src" + std::to_string(i) + ".png");
gradient(32, 24, i + 1).save(path.string());
paths.push_back(path.string());
}
paths.push_back((dir / "missing.png").string());
std::atomic<std::size_t> calls{0};
std::size_t lastTotal = 0;
std::mutex mutex;
LoadProgress progress;
progress.onFile = [&](std::size_t done, std::size_t total, const std::string&) {
std::lock_guard<std::mutex> lock(mutex);
++calls;
lastTotal = total;
check(done >= 1 && done <= total, "progress step is within the batch");
};
std::vector<LoadFailure> failuresOut;
const auto images = loadImages(paths, failuresOut, &progress);
check(images.size() == 6, "every readable image is returned");
check(failuresOut.size() == 1, "the unreadable one is reported");
check(calls.load() == paths.size(), "progress fires once per file");
check(lastTotal == paths.size(), "progress reports the batch size");
check(images.front().path() == paths.front(), "input order is preserved");
std::atomic<bool> cancel{true};
LoadProgress cancelled;
cancelled.cancel = &cancel;
std::vector<LoadFailure> ignored;
const auto none = loadImages(paths, ignored, &cancelled);
check(none.empty(), "a cancelled batch loads nothing");
std::filesystem::remove_all(dir);
}
std::cout << (failures == 0 ? "\nall checks passed\n" : "\n" + std::to_string(failures) + " failed\n"); std::cout << (failures == 0 ? "\nall checks passed\n" : "\n" + std::to_string(failures) + " failed\n");
return failures == 0 ? 0 : 1; return failures == 0 ? 0 : 1;
} }