NegativeDetector now accepts an optional forced FilmType. When film_type != "auto" in config.ini, auto-detection is skipped and the configured type is applied directly. build_pipeline() in CliRunner maps c41→ColorNegative and bw→BWNegative accordingly. Default config changed from film_type=auto to film_type=c41 to match the project's primary use case (C-41 color negatives). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
303 lines
12 KiB
C++
303 lines
12 KiB
C++
#include "CliRunner.h"
|
|
|
|
#include "../converter/pipeline/ImageData.h"
|
|
#include "../converter/rawloader/RawLoader.h"
|
|
#include "../converter/preprocess/Preprocessor.h"
|
|
#include "../converter/negative/NegativeDetector.h"
|
|
#include "../converter/invert/Inverter.h"
|
|
#include "../converter/color/ColorCorrector.h"
|
|
#include "../converter/crop/CropProcessor.h"
|
|
#include "../converter/output/OutputWriter.h"
|
|
|
|
#include <algorithm>
|
|
#include <format>
|
|
#include <iostream>
|
|
|
|
namespace photoconv {
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// parse_args
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
std::expected<CliRunner::Config, Error> CliRunner::parse_args(int argc, char* argv[]) {
|
|
Config config;
|
|
bool reading_inputs = false;
|
|
|
|
for (int i = 1; i < argc; ++i) {
|
|
const std::string arg{argv[i]};
|
|
|
|
if (arg == "--cli") {
|
|
reading_inputs = false;
|
|
continue;
|
|
}
|
|
|
|
if (arg == "--batch") {
|
|
config.batch_mode = true;
|
|
reading_inputs = false;
|
|
continue;
|
|
}
|
|
|
|
if (arg == "--config") {
|
|
reading_inputs = false;
|
|
if (i + 1 < argc) {
|
|
config.config_file = argv[++i];
|
|
config.batch_mode = true; // --config implies batch mode
|
|
} else {
|
|
return std::unexpected(make_error(
|
|
ErrorCode::InvalidArgument, "--config requires a file path"));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (arg == "-i" || arg == "--input") {
|
|
reading_inputs = true;
|
|
continue;
|
|
}
|
|
|
|
if (arg == "-o" || arg == "--output") {
|
|
reading_inputs = false;
|
|
if (i + 1 < argc) {
|
|
config.output_dir = argv[++i];
|
|
} else {
|
|
return std::unexpected(make_error(
|
|
ErrorCode::InvalidArgument, "--output requires a directory path"));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (arg == "--format") {
|
|
reading_inputs = false;
|
|
if (i + 1 < argc) {
|
|
config.output_format = argv[++i];
|
|
} else {
|
|
return std::unexpected(make_error(
|
|
ErrorCode::InvalidArgument, "--format requires a value (png16|png8|tiff16|jpeg)"));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (arg == "--quality") {
|
|
reading_inputs = false;
|
|
if (i + 1 < argc) {
|
|
config.jpeg_quality = std::stoi(argv[++i]);
|
|
} else {
|
|
return std::unexpected(make_error(
|
|
ErrorCode::InvalidArgument, "--quality requires a numeric value"));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (arg == "-v" || arg == "--verbose") {
|
|
config.verbose = true;
|
|
reading_inputs = false;
|
|
continue;
|
|
}
|
|
|
|
if (arg == "-h" || arg == "--help") {
|
|
std::cout <<
|
|
"Usage: photo-converter --cli [options] -i <files...>\n"
|
|
" photo-converter --batch --config config.ini\n"
|
|
"\n"
|
|
"Options:\n"
|
|
" --cli CLI mode (no GUI)\n"
|
|
" --batch Batch mode (read config for input directory)\n"
|
|
" --config <file> INI configuration file (implies --batch)\n"
|
|
" -i, --input <files...> Input image files (RAW or standard)\n"
|
|
" -o, --output <dir> Output directory (default: output/)\n"
|
|
" --format <fmt> Output format: png16 png8 tiff16 jpeg\n"
|
|
" --quality <0-100> JPEG quality (default: 95)\n"
|
|
" -v, --verbose Verbose output\n"
|
|
" -h, --help Show this help\n"
|
|
<< std::endl;
|
|
return std::unexpected(make_error(ErrorCode::InvalidArgument, "Help requested"));
|
|
}
|
|
|
|
// If reading_inputs is active or the argument does not start with '-',
|
|
// treat it as an input file path.
|
|
if (reading_inputs || (!arg.empty() && arg.front() != '-')) {
|
|
config.input_files.emplace_back(arg);
|
|
reading_inputs = true;
|
|
}
|
|
}
|
|
|
|
// Validation: we need either input files or a config file for batch mode.
|
|
if (!config.batch_mode && config.input_files.empty()) {
|
|
return std::unexpected(make_error(
|
|
ErrorCode::InvalidArgument,
|
|
"No input files specified. Use -i <files...> or --batch --config config.ini"));
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// run
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
std::expected<int, Error> CliRunner::run(const Config& config) const {
|
|
// ── Resolve AppConfig ────────────────────────────────────────────────────
|
|
AppConfig app_cfg{};
|
|
|
|
if (!config.config_file.empty()) {
|
|
auto cfg_result = AppConfig::load(config.config_file);
|
|
if (!cfg_result.has_value()) {
|
|
return std::unexpected(cfg_result.error());
|
|
}
|
|
app_cfg = std::move(cfg_result.value());
|
|
std::cout << std::format("[CLI] Loaded config: {}", config.config_file.string())
|
|
<< std::endl;
|
|
}
|
|
|
|
// CLI flags override config file values where set explicitly.
|
|
if (!config.output_dir.empty()) {
|
|
app_cfg.batch.output_dir = config.output_dir;
|
|
}
|
|
if (!config.output_format.empty() && config.output_format != "png16") {
|
|
// "png16" is the default; only override if the user explicitly changed it.
|
|
app_cfg.conversion.output_format = config.output_format;
|
|
}
|
|
if (config.jpeg_quality != 95) {
|
|
app_cfg.quality.jpeg_quality = config.jpeg_quality;
|
|
}
|
|
|
|
// ── Collect input files ──────────────────────────────────────────────────
|
|
std::vector<std::filesystem::path> files = config.input_files;
|
|
|
|
if (files.empty() && config.batch_mode) {
|
|
const auto extensions = app_cfg.parsed_extensions();
|
|
files = collect_files(app_cfg.batch.input_dir, extensions,
|
|
app_cfg.batch.recursive);
|
|
|
|
if (files.empty()) {
|
|
std::cerr << std::format(
|
|
"[CLI] No matching files found in: {}",
|
|
app_cfg.batch.input_dir.string()) << std::endl;
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// ── Build pipeline ───────────────────────────────────────────────────────
|
|
RawLoader loader;
|
|
Pipeline pipeline = build_pipeline(app_cfg);
|
|
|
|
// ── Process files ────────────────────────────────────────────────────────
|
|
int success_count = 0;
|
|
const auto total = static_cast<int>(files.size());
|
|
|
|
for (int idx = 0; idx < total; ++idx) {
|
|
const auto& file = files[static_cast<std::size_t>(idx)];
|
|
|
|
std::cerr << std::format("[{}/{}] Processing {}...",
|
|
idx + 1, total, file.filename().string())
|
|
<< std::endl;
|
|
|
|
// Load image
|
|
auto load_result = loader.load(file);
|
|
if (!load_result.has_value()) {
|
|
std::cerr << std::format("[CLI] Load failed: {}",
|
|
load_result.error().format()) << std::endl;
|
|
continue;
|
|
}
|
|
|
|
// Run pipeline
|
|
auto result = pipeline.execute(std::move(load_result.value()));
|
|
if (!result.has_value()) {
|
|
std::cerr << std::format("[CLI] Pipeline failed: {}",
|
|
result.error().format()) << std::endl;
|
|
continue;
|
|
}
|
|
|
|
++success_count;
|
|
}
|
|
|
|
std::cout << std::format("\n[CLI] Done: {}/{} file(s) converted successfully",
|
|
success_count, total) << std::endl;
|
|
|
|
return success_count;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// collect_files
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
std::vector<std::filesystem::path> CliRunner::collect_files(
|
|
const std::filesystem::path& dir,
|
|
const std::vector<std::string>& extensions,
|
|
const bool recursive)
|
|
{
|
|
std::vector<std::filesystem::path> result;
|
|
|
|
if (!std::filesystem::is_directory(dir)) {
|
|
std::cerr << std::format("[CLI] Input directory not found: {}", dir.string())
|
|
<< std::endl;
|
|
return result;
|
|
}
|
|
|
|
auto collect_entry = [&](const std::filesystem::directory_entry& entry) {
|
|
if (!entry.is_regular_file()) return;
|
|
|
|
// Compare extension case-insensitively.
|
|
std::string ext = entry.path().extension().string();
|
|
std::ranges::transform(ext, ext.begin(),
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
const bool match = std::ranges::any_of(extensions,
|
|
[&ext](const std::string& e) { return e == ext; });
|
|
|
|
if (match) {
|
|
result.push_back(entry.path());
|
|
}
|
|
};
|
|
|
|
if (recursive) {
|
|
for (const auto& entry : std::filesystem::recursive_directory_iterator{dir}) {
|
|
collect_entry(entry);
|
|
}
|
|
} else {
|
|
for (const auto& entry : std::filesystem::directory_iterator{dir}) {
|
|
collect_entry(entry);
|
|
}
|
|
}
|
|
|
|
std::ranges::sort(result);
|
|
return result;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// build_pipeline
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
Pipeline CliRunner::build_pipeline(const AppConfig& app_cfg) {
|
|
Pipeline pipeline;
|
|
|
|
// Resolve forced film type from config.
|
|
// "auto" → Unknown (auto-detection), "c41" → ColorNegative, "bw" → BWNegative.
|
|
FilmType forced_film = FilmType::Unknown;
|
|
if (app_cfg.conversion.film_type == "c41") {
|
|
forced_film = FilmType::ColorNegative;
|
|
} else if (app_cfg.conversion.film_type == "bw") {
|
|
forced_film = FilmType::BWNegative;
|
|
}
|
|
|
|
pipeline.add_stage(std::make_unique<Preprocessor>());
|
|
pipeline.add_stage(std::make_unique<NegativeDetector>(forced_film));
|
|
|
|
if (app_cfg.conversion.invert) {
|
|
pipeline.add_stage(std::make_unique<Inverter>());
|
|
}
|
|
|
|
pipeline.add_stage(std::make_unique<ColorCorrector>());
|
|
|
|
pipeline.add_stage(std::make_unique<CropProcessor>());
|
|
|
|
pipeline.add_stage(std::make_unique<OutputWriter>(OutputConfig{
|
|
app_cfg.batch.output_dir,
|
|
app_cfg.output_format(),
|
|
app_cfg.quality.jpeg_quality
|
|
}));
|
|
|
|
return pipeline;
|
|
}
|
|
|
|
} // namespace photoconv
|