From 6df4bcb0791abfe1f0117d8aa051866044912819 Mon Sep 17 00:00:00 2001 From: Si Date: Sat, 26 Sep 2026 19:41:00 +0200 Subject: [PATCH] Add a video export quality choice: Small file, Standard or High A Quality dropdown under Video on Setup, --video-quality on the command line, and "quality" in a project's video settings (written only when it is not Standard). One table, videoQualities(), holds the ffmpeg settings per codec; Standard is what every video was written with before. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ src/cli/options.cpp | 14 ++++++++++++++ src/core/project.cpp | 12 ++++++++++++ src/core/video.cpp | 39 +++++++++++++++++++++++++++++++++++---- src/core/video.hpp | 34 ++++++++++++++++++++++++++++++++-- src/gui/window.cpp | 33 ++++++++++++++++++++++++++++++--- src/gui/window.hpp | 1 + src/main.cpp | 3 ++- tests/core_tests.cpp | 25 +++++++++++++++++++++++++ tests/gui_tests.cpp | 5 +++++ 10 files changed, 160 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e738730..bf62e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ commit that made it. The interface shows this file under Changelog in its menu. moment of its own, so the mosaic keeps changing piece by piece. On the command line it is `-e tileswap` with `--tileswap-rate`, `--tileswap-layout` and the rest. +- A Quality choice under Video on Setup: Small file, Standard or High, for + how the saved video is encoded. It is kept in the project, and on the + command line it is `--video-quality small|standard|high`. Standard writes + videos exactly as before. ## [0.7.0] - 2026-09-26 diff --git a/src/cli/options.cpp b/src/cli/options.cpp index 12bc269..d9fb6a8 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -133,6 +133,12 @@ Options parseOptions(int argc, char** argv) { } options.video.enabled = true; options.video.preset = value; + } else if (arg == "--video-quality") { + const std::string value = requireValue(argc, argv, i, arg); + if (videoQualityByKey(value) == nullptr) { + throw EffectError("unknown video quality '" + value + "', see --help"); + } + options.video.quality = value; } else if (arg == "--disable") { const std::string name = requireValue(argc, argv, i, arg); effectByName(name); // validate now, exactly as -e does @@ -241,6 +247,8 @@ std::string usage(const char* programName) { << " --fps N frames per second of a video, " << kMinFps << "-" << kMaxFps << " (default: " << kDefaultFps << ")\n" << " --video-preset NAME a video's canvas and length in one (see below)\n" + << " --video-quality NAME how a video file is encoded (default: " + << kDefaultVideoQuality << ", see below)\n" << " --disable NAME keep this effect in the chain but skip it\n" << " --seed N random seed (default: random)\n" << " --list-effects list available effects and exit\n" @@ -263,6 +271,12 @@ std::string usage(const char* programName) { if (preset.key.size() < 20) out << std::string(20 - preset.key.size(), ' '); out << " " << preset.label << ", " << videoPresetDetail(preset) << "\n"; } + out << "\nVideo qualities (--video-quality):\n"; + for (const auto& quality : videoQualities()) { + out << " " << quality.key; + if (quality.key.size() < 20) out << std::string(20 - quality.key.size(), ' '); + out << " " << quality.label << ", " << quality.description << "\n"; + } // Image effects first, then the video effects under a heading of their // own: those run only in a clip. diff --git a/src/core/project.cpp b/src/core/project.cpp index 6f61057..2a20b97 100644 --- a/src/core/project.cpp +++ b/src/core/project.cpp @@ -462,6 +462,11 @@ std::string projectToJson(const Project& project, const std::string& directory) if (const auto* preset = videoPresetByKey(project.video.preset)) { out << ", \"preset\": " << quote(preset->key); } + // Written only when it is not the standard one. + if (const auto* quality = videoQualityByKey(project.video.quality); + quality != nullptr && quality->key != kDefaultVideoQuality) { + out << ", \"quality\": " << quote(quality->key); + } out << "},\n"; } if (project.seed) out << " \"seed\": " << *project.seed << ",\n"; @@ -548,6 +553,13 @@ Project projectFromJson(const std::string& text, const std::string& directory) { name->kind == Json::Kind::String) { if (const auto* preset = videoPresetByKey(name->text)) project.video.preset = preset->key; } + // An unknown quality is the standard one, as it is everywhere else. + if (const auto* name = video->member("quality"); name != nullptr && + name->kind == Json::Kind::String) { + if (const auto* quality = videoQualityByKey(name->text)) { + project.video.quality = quality->key; + } + } } if (const auto* seed = root.member("seed")) { diff --git a/src/core/video.cpp b/src/core/video.cpp index 50540f4..143d675 100644 --- a/src/core/video.cpp +++ b/src/core/video.cpp @@ -51,6 +51,31 @@ const VideoPreset* videoPresetByKey(const std::string& key) { return nullptr; } +const std::vector& videoQualities() { + // Standard is what every video was written with before there was a + // choice: CRF 18 is close to what the eye can tell from the frames. + static const std::vector qualities = { + {"small", "Small file", "smaller and quicker to share, with visible softening", 26, "fast", + 40, 128}, + {kDefaultVideoQuality, "Standard", "close to the rendered frames at a moderate size", 18, + "medium", 32, 256}, + {"high", "High", "as faithful as it gets, larger and slower to write", 12, "slow", 24, 256}, + }; + return qualities; +} + +const VideoQuality* videoQualityByKey(const std::string& key) { + for (const auto& quality : videoQualities()) { + if (quality.key == key) return &quality; + } + return nullptr; +} + +const VideoQuality& videoQualityOrDefault(const std::string& key) { + if (const auto* quality = videoQualityByKey(key)) return *quality; + return *videoQualityByKey(kDefaultVideoQuality); +} + // In the C locale whatever the user's is, for the same reason as the // millimetres of a paper size: "7.5 s", never "7,5 s". std::string formatSeconds(double seconds) { @@ -106,7 +131,8 @@ bool ffmpegAvailable() { return false; } -VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps) +VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps, + const std::string& quality) : path_(path), width_(width), height_(height) { const std::string muxer = muxerFor(lowerExtension(path)); if (muxer.empty()) { @@ -120,6 +146,7 @@ VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps static std::once_flag ignorePipe; std::call_once(ignorePipe, [] { ::signal(SIGPIPE, SIG_IGN); }); + const VideoQuality& squeeze = videoQualityOrDefault(quality); std::vector args = { "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", @@ -129,16 +156,20 @@ VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps }; if (muxer == "gif") { // One palette made for the whole clip, rather than the fixed web one. - args.insert(args.end(), {"-vf", "split[a][b];[b]palettegen[p];[a][p]paletteuse", + args.insert(args.end(), {"-vf", "split[a][b];[b]palettegen=max_colors=" + + std::to_string(squeeze.gifColors) + + "[p];[a][p]paletteuse", "-loop", "0"}); } else { // 4:2:0 wants even sides; a pixel of padding beats refusing the canvas. args.insert(args.end(), {"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"}); if (muxer == "webm") { - args.insert(args.end(), {"-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0", + args.insert(args.end(), {"-c:v", "libvpx-vp9", "-crf", std::to_string(squeeze.vp9Crf), + "-b:v", "0", "-row-mt", "1"}); } else { - args.insert(args.end(), {"-c:v", "libx264", "-preset", "medium", "-crf", "18"}); + args.insert(args.end(), {"-c:v", "libx264", "-preset", squeeze.h264Preset, "-crf", + std::to_string(squeeze.h264Crf)}); } args.insert(args.end(), {"-pix_fmt", "yuv420p"}); if (muxer == "mp4" || muxer == "mov") args.insert(args.end(), {"-movflags", "+faststart"}); diff --git a/src/core/video.hpp b/src/core/video.hpp index 12e6e4e..d698299 100644 --- a/src/core/video.hpp +++ b/src/core/video.hpp @@ -31,6 +31,10 @@ struct VideoSettings { // canvas format it is the presentation of the numbers, never the truth: // the render goes by duration and fps. std::string preset; + // How hard the file is squeezed: a key of videoQualities(), empty or + // unknown for the standard one. Only the encoded file hears of it; the + // frames are the same at every quality. + std::string quality; // The frames the clip has: the length at the rate, never fewer than one. int frameCount() const; @@ -55,6 +59,30 @@ const VideoPreset* videoPresetByKey(const std::string& key); // "1080 × 1920 px · 15 s", for the dropdown and for --help. std::string videoPresetDetail(const VideoPreset& preset); +// How a video file is encoded, from small and quick to large and faithful. +// The numbers go to ffmpeg by the codec the file's extension picks. +struct VideoQuality { + std::string key; // written in a project and given to --video-quality + std::string label; // shown in the interface + std::string description; // for the tooltip and --help + int h264Crf = 18; // .mp4, .mov, .mkv: lower is better and larger + std::string h264Preset; // x264's speed preset: slower packs tighter + int vp9Crf = 32; // .webm + int gifColors = 256; // .gif palette size +}; + +// In the order they are offered, smallest first. +const std::vector& videoQualities(); + +// The standard quality, which an empty or unknown key stands for. +inline constexpr const char* kDefaultVideoQuality = "standard"; + +// Null when the key names no quality. +const VideoQuality* videoQualityByKey(const std::string& key); + +// The quality a key stands for: the standard one for an empty or unknown key. +const VideoQuality& videoQualityOrDefault(const std::string& key); + // "15 s", "7.5 s": a length as the interface and --help show it. std::string formatSeconds(double seconds); @@ -68,7 +96,7 @@ bool ffmpegAvailable(); // program. Run as a plain argv rather than through a shell, so a file name is // never read as anything but a file name. The container and codec follow the // extension: H.264 for .mp4, .mov and .mkv, VP9 for .webm, a palette GIF for -// .gif. The encoded bytes are ffmpeg's business; the frames going in are the +// .gif, squeezed as the quality says. The encoded bytes are ffmpeg's business; the frames going in are the // ones the seed decides. // // Throws EffectError when ffmpeg is missing, fails, or quits early, with what @@ -76,7 +104,9 @@ bool ffmpegAvailable(); // itself on destruction, so a half-written file is never left behind. class VideoWriter { public: - VideoWriter(const std::string& path, int width, int height, int fps); + // The quality is a key of videoQualities(); empty means the standard one. + VideoWriter(const std::string& path, int width, int height, int fps, + const std::string& quality = {}); ~VideoWriter(); VideoWriter(const VideoWriter&) = delete; VideoWriter& operator=(const VideoWriter&) = delete; diff --git a/src/gui/window.cpp b/src/gui/window.cpp index c701075..1c34d3a 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -513,8 +513,8 @@ void EffectorWindow::syncCanvasWidgets() { // ---------------------------------------------------------------- video --- -// A still project shows only the switch; a video shows its preset, length and -// rate below it. The preset list reads like the canvas one: "Custom" first, +// A still project shows only the switch; a video shows its preset, length, rate +// and file quality below it. The preset list reads like the canvas one: "Custom" first, // then every preset with what it amounts to. Gtk::Widget& EffectorWindow::buildVideoSection() { auto* box = Gtk::make_managed(Gtk::Orientation::VERTICAL, 6); @@ -573,6 +573,27 @@ Gtk::Widget& EffectorWindow::buildVideoSection() { grid->attach(durationSpin_, 1, 0); grid->attach(*fpsLabel, 0, 1); grid->attach(fpsSpin_, 1, 1); + + // How the saved file is encoded. It changes nothing on screen, so it asks + // for no render. + std::vector qualityLabels; + Glib::ustring qualityTooltip = "How the saved video file is encoded"; + guint standard = 0; + for (std::size_t i = 0; i < videoQualities().size(); ++i) { + const auto& quality = videoQualities()[i]; + qualityLabels.push_back(quality.label); + qualityTooltip += "\n" + quality.label + ": " + quality.description; + if (quality.key == kDefaultVideoQuality) standard = static_cast(i); + } + videoQualityDrop_.set_name("video-quality"); + videoQualityDrop_.set_model(Gtk::StringList::create(qualityLabels)); + videoQualityDrop_.set_selected(standard); + videoQualityDrop_.set_hexpand(true); + videoQualityDrop_.set_tooltip_text(qualityTooltip); + auto* qualityLabel = Gtk::make_managed("Quality"); + qualityLabel->set_xalign(0.0F); + grid->attach(*qualityLabel, 0, 2); + grid->attach(videoQualityDrop_, 1, 2); videoDetails_.append(*grid); videoDetails_.set_visible(false); box->append(videoDetails_); @@ -589,6 +610,8 @@ VideoSettings EffectorWindow::videoSettings() const { if (selected != GTK_INVALID_LIST_POSITION && selected > 0 && selected <= presets.size()) { video.preset = presets[selected - 1].key; } + const auto quality = videoQualityDrop_.get_selected(); + if (quality < videoQualities().size()) video.quality = videoQualities()[quality].key; return video; } @@ -603,6 +626,10 @@ void EffectorWindow::setVideoSettings(const VideoSettings& video) { if (presets[i].key == video.preset) selected = static_cast(i + 1); } videoPresetDrop_.set_selected(selected); + const auto& quality = videoQualityOrDefault(video.quality); + for (std::size_t i = 0; i < videoQualities().size(); ++i) { + if (videoQualities()[i].key == quality.key) videoQualityDrop_.set_selected(static_cast(i)); + } videoSwitch_.set_active(video.enabled); videoDetails_.set_visible(video.enabled); suspendVideoSync_ = suspended; @@ -2836,7 +2863,7 @@ void EffectorWindow::startExport(const std::string& path) { exporter_ = std::thread([this, path, sources, stages, styles, width, height, seed, video] { std::string error; try { - VideoWriter writer(path, width, height, video.fps); + VideoWriter writer(path, width, height, video.fps, video.quality); renderFrames(stages, *sources, width, height, seed, styles, video, [this, &writer](int, Image&& frame) { if (exportCancel_.load(std::memory_order_relaxed)) return false; diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 50e1e9b..5285b22 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -294,6 +294,7 @@ private: Gtk::DropDown videoPresetDrop_; Gtk::SpinButton durationSpin_; Gtk::SpinButton fpsSpin_; + Gtk::DropDown videoQualityDrop_; // videoQualities()[index]; only the export hears of it bool suspendVideoSync_ = false; // set while code, not the user, fills them // Set while a format fills the spins, so their own handler does not queue // a second render on top of the one the format change asks for. diff --git a/src/main.cpp b/src/main.cpp index 4939fec..77b31b2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,7 +25,8 @@ int renderVideo(const effector::Options& options, const std::vectorvideo.quality == "high", "--video-quality picks the encoding"); + check(!parsedOptions({"--video-quality", "best"}), "and refuses one it does not know"); check(!parsedOptions({"-o", "x.png"})->video.enabled, "without them the output is a still"); bool wrongName = false; @@ -2128,6 +2141,18 @@ int main() { } check(std::filesystem::exists(file) && std::filesystem::file_size(file) > 0, "ffmpeg writes an .mp4 of odd size"); + // Every quality, through every codec. + bool everyQuality = true; + for (const auto& quality : effector::videoQualities()) { + for (const char* extension : {".mp4", ".webm", ".gif"}) { + const auto encoded = (dir / (quality.key + extension)).string(); + effector::VideoWriter writer(encoded, 16, 16, 12, quality.key); + for (int i = 0; i < 2; ++i) writer.write(gradient(16, 16, 60 * i)); + writer.finish(); + everyQuality = everyQuality && std::filesystem::file_size(encoded) > 0; + } + } + check(everyQuality, "every quality writes .mp4, .webm and .gif"); const auto dropped = (dir / "dropped.webm").string(); { effector::VideoWriter writer(dropped, 16, 16, 12); diff --git a/tests/gui_tests.cpp b/tests/gui_tests.cpp index 88ad4e2..fbcb951 100644 --- a/tests/gui_tests.cpp +++ b/tests/gui_tests.cpp @@ -553,6 +553,11 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) { stack->set_visible_child("setup"); pump(100); check(preset->is_drawable() && duration->is_drawable(), "and its preset and length"); + auto* quality = named(window, "video-quality"); + check(quality != nullptr && quality->is_drawable() && + effector::videoQualities().at(quality->get_selected()).key == + effector::kDefaultVideoQuality, + "and its file quality, standard to begin with"); preset->set_selected(1); // Instagram story, the first preset pump(200);