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 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-26 19:41:00 +02:00
co-authored by Claude Opus 5.5
parent 1b30a079e6
commit 6df4bcb079
10 changed files with 160 additions and 10 deletions
+4
View File
@@ -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 moment of its own, so the mosaic keeps changing piece by piece. On the
command line it is `-e tileswap` with `--tileswap-rate`, command line it is `-e tileswap` with `--tileswap-rate`,
`--tileswap-layout` and the rest. `--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 ## [0.7.0] - 2026-09-26
+14
View File
@@ -133,6 +133,12 @@ Options parseOptions(int argc, char** argv) {
} }
options.video.enabled = true; options.video.enabled = true;
options.video.preset = value; 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") { } else if (arg == "--disable") {
const std::string name = requireValue(argc, argv, i, arg); const std::string name = requireValue(argc, argv, i, arg);
effectByName(name); // validate now, exactly as -e does 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 << " --fps N frames per second of a video, " << kMinFps << "-" << kMaxFps
<< " (default: " << kDefaultFps << ")\n" << " (default: " << kDefaultFps << ")\n"
<< " --video-preset NAME a video's canvas and length in one (see below)\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" << " --disable NAME keep this effect in the chain but skip it\n"
<< " --seed N random seed (default: random)\n" << " --seed N random seed (default: random)\n"
<< " --list-effects list available effects and exit\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(), ' '); if (preset.key.size() < 20) out << std::string(20 - preset.key.size(), ' ');
out << " " << preset.label << ", " << videoPresetDetail(preset) << "\n"; 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 // Image effects first, then the video effects under a heading of their
// own: those run only in a clip. // own: those run only in a clip.
+12
View File
@@ -462,6 +462,11 @@ std::string projectToJson(const Project& project, const std::string& directory)
if (const auto* preset = videoPresetByKey(project.video.preset)) { if (const auto* preset = videoPresetByKey(project.video.preset)) {
out << ", \"preset\": " << quote(preset->key); 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"; out << "},\n";
} }
if (project.seed) out << " \"seed\": " << *project.seed << ",\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) { name->kind == Json::Kind::String) {
if (const auto* preset = videoPresetByKey(name->text)) project.video.preset = preset->key; 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")) { if (const auto* seed = root.member("seed")) {
+35 -4
View File
@@ -51,6 +51,31 @@ const VideoPreset* videoPresetByKey(const std::string& key) {
return nullptr; return nullptr;
} }
const std::vector<VideoQuality>& 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<VideoQuality> 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 // 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". // millimetres of a paper size: "7.5 s", never "7,5 s".
std::string formatSeconds(double seconds) { std::string formatSeconds(double seconds) {
@@ -106,7 +131,8 @@ bool ffmpegAvailable() {
return false; 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) { : path_(path), width_(width), height_(height) {
const std::string muxer = muxerFor(lowerExtension(path)); const std::string muxer = muxerFor(lowerExtension(path));
if (muxer.empty()) { if (muxer.empty()) {
@@ -120,6 +146,7 @@ VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps
static std::once_flag ignorePipe; static std::once_flag ignorePipe;
std::call_once(ignorePipe, [] { ::signal(SIGPIPE, SIG_IGN); }); std::call_once(ignorePipe, [] { ::signal(SIGPIPE, SIG_IGN); });
const VideoQuality& squeeze = videoQualityOrDefault(quality);
std::vector<std::string> args = { std::vector<std::string> args = {
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-f", "rawvideo", "-pix_fmt", "rgb24",
@@ -129,16 +156,20 @@ VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps
}; };
if (muxer == "gif") { if (muxer == "gif") {
// One palette made for the whole clip, rather than the fixed web one. // 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"}); "-loop", "0"});
} else { } else {
// 4:2:0 wants even sides; a pixel of padding beats refusing the canvas. // 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"}); args.insert(args.end(), {"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"});
if (muxer == "webm") { 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"}); "-row-mt", "1"});
} else { } 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"}); args.insert(args.end(), {"-pix_fmt", "yuv420p"});
if (muxer == "mp4" || muxer == "mov") args.insert(args.end(), {"-movflags", "+faststart"}); if (muxer == "mp4" || muxer == "mov") args.insert(args.end(), {"-movflags", "+faststart"});
+32 -2
View File
@@ -31,6 +31,10 @@ struct VideoSettings {
// canvas format it is the presentation of the numbers, never the truth: // canvas format it is the presentation of the numbers, never the truth:
// the render goes by duration and fps. // the render goes by duration and fps.
std::string preset; 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. // The frames the clip has: the length at the rate, never fewer than one.
int frameCount() const; 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. // "1080 × 1920 px · 15 s", for the dropdown and for --help.
std::string videoPresetDetail(const VideoPreset& preset); 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<VideoQuality>& 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. // "15 s", "7.5 s": a length as the interface and --help show it.
std::string formatSeconds(double seconds); 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 // 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 // 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 // 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. // ones the seed decides.
// //
// Throws EffectError when ffmpeg is missing, fails, or quits early, with what // 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. // itself on destruction, so a half-written file is never left behind.
class VideoWriter { class VideoWriter {
public: 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();
VideoWriter(const VideoWriter&) = delete; VideoWriter(const VideoWriter&) = delete;
VideoWriter& operator=(const VideoWriter&) = delete; VideoWriter& operator=(const VideoWriter&) = delete;
+30 -3
View File
@@ -513,8 +513,8 @@ void EffectorWindow::syncCanvasWidgets() {
// ---------------------------------------------------------------- video --- // ---------------------------------------------------------------- video ---
// A still project shows only the switch; a video shows its preset, length and // A still project shows only the switch; a video shows its preset, length, rate
// rate below it. The preset list reads like the canvas one: "Custom" first, // and file quality below it. The preset list reads like the canvas one: "Custom" first,
// then every preset with what it amounts to. // then every preset with what it amounts to.
Gtk::Widget& EffectorWindow::buildVideoSection() { Gtk::Widget& EffectorWindow::buildVideoSection() {
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6); auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
@@ -573,6 +573,27 @@ Gtk::Widget& EffectorWindow::buildVideoSection() {
grid->attach(durationSpin_, 1, 0); grid->attach(durationSpin_, 1, 0);
grid->attach(*fpsLabel, 0, 1); grid->attach(*fpsLabel, 0, 1);
grid->attach(fpsSpin_, 1, 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<Glib::ustring> 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<guint>(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<Gtk::Label>("Quality");
qualityLabel->set_xalign(0.0F);
grid->attach(*qualityLabel, 0, 2);
grid->attach(videoQualityDrop_, 1, 2);
videoDetails_.append(*grid); videoDetails_.append(*grid);
videoDetails_.set_visible(false); videoDetails_.set_visible(false);
box->append(videoDetails_); box->append(videoDetails_);
@@ -589,6 +610,8 @@ VideoSettings EffectorWindow::videoSettings() const {
if (selected != GTK_INVALID_LIST_POSITION && selected > 0 && selected <= presets.size()) { if (selected != GTK_INVALID_LIST_POSITION && selected > 0 && selected <= presets.size()) {
video.preset = presets[selected - 1].key; video.preset = presets[selected - 1].key;
} }
const auto quality = videoQualityDrop_.get_selected();
if (quality < videoQualities().size()) video.quality = videoQualities()[quality].key;
return video; return video;
} }
@@ -603,6 +626,10 @@ void EffectorWindow::setVideoSettings(const VideoSettings& video) {
if (presets[i].key == video.preset) selected = static_cast<guint>(i + 1); if (presets[i].key == video.preset) selected = static_cast<guint>(i + 1);
} }
videoPresetDrop_.set_selected(selected); 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<guint>(i));
}
videoSwitch_.set_active(video.enabled); videoSwitch_.set_active(video.enabled);
videoDetails_.set_visible(video.enabled); videoDetails_.set_visible(video.enabled);
suspendVideoSync_ = suspended; 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] { exporter_ = std::thread([this, path, sources, stages, styles, width, height, seed, video] {
std::string error; std::string error;
try { 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, renderFrames(stages, *sources, width, height, seed, styles, video,
[this, &writer](int, Image&& frame) { [this, &writer](int, Image&& frame) {
if (exportCancel_.load(std::memory_order_relaxed)) return false; if (exportCancel_.load(std::memory_order_relaxed)) return false;
+1
View File
@@ -294,6 +294,7 @@ private:
Gtk::DropDown videoPresetDrop_; Gtk::DropDown videoPresetDrop_;
Gtk::SpinButton durationSpin_; Gtk::SpinButton durationSpin_;
Gtk::SpinButton fpsSpin_; 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 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 // 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. // a second render on top of the one the format change asks for.
+2 -1
View File
@@ -25,7 +25,8 @@ int renderVideo(const effector::Options& options, const std::vector<effector::Ch
const bool showProgress = isatty(fileno(stderr)) != 0; const bool showProgress = isatty(fileno(stderr)) != 0;
const auto start = std::chrono::steady_clock::now(); const auto start = std::chrono::steady_clock::now();
effector::VideoWriter writer(options.output, options.width, options.height, options.video.fps); effector::VideoWriter writer(options.output, options.width, options.height, options.video.fps,
options.video.quality);
effector::renderFrames(stages, sources, options.width, options.height, seed, {}, options.video, effector::renderFrames(stages, sources, options.width, options.height, seed, {}, options.video,
[&](int index, effector::Image&& frame) { [&](int index, effector::Image&& frame) {
writer.write(frame); writer.write(frame);
+25
View File
@@ -2082,6 +2082,16 @@ int main() {
const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": "; const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": ";
check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(), check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(),
"an unknown preset reads as custom"); "an unknown preset reads as custom");
check(effector::projectToJson(project).find("\"quality\"") == std::string::npos,
"the standard quality is not written");
project.video.quality = "small";
check(effector::projectFromJson(effector::projectToJson(project)).video.quality == "small",
"another quality is kept");
check(effector::projectFromJson(base + "2, \"quality\": \"lossless\"}}").video.quality.empty(),
"an unknown quality reads as the standard one");
check(effector::videoQualityOrDefault("").key == effector::kDefaultVideoQuality &&
effector::videoQualityByKey(effector::kDefaultVideoQuality) != nullptr,
"an empty quality is the standard one, which the table holds");
bool rejected = false; bool rejected = false;
try { try {
effector::projectFromJson(base + "2, \"fps\": 500}}"); effector::projectFromJson(base + "2, \"fps\": 500}}");
@@ -2106,6 +2116,9 @@ int main() {
check(!parsedOptions({"--fps", "0"}) && !parsedOptions({"--duration", "abc"}) && check(!parsedOptions({"--fps", "0"}) && !parsedOptions({"--duration", "abc"}) &&
!parsedOptions({"--duration", "400"}) && !parsedOptions({"--video-preset", "vhs"}), !parsedOptions({"--duration", "400"}) && !parsedOptions({"--video-preset", "vhs"}),
"a bad rate, length or preset is refused"); "a bad rate, length or preset is refused");
const auto quality = parsedOptions({"--duration", "2", "--video-quality", "high"});
check(quality && quality->video.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"); check(!parsedOptions({"-o", "x.png"})->video.enabled, "without them the output is a still");
bool wrongName = false; bool wrongName = false;
@@ -2128,6 +2141,18 @@ int main() {
} }
check(std::filesystem::exists(file) && std::filesystem::file_size(file) > 0, check(std::filesystem::exists(file) && std::filesystem::file_size(file) > 0,
"ffmpeg writes an .mp4 of odd size"); "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(); const auto dropped = (dir / "dropped.webm").string();
{ {
effector::VideoWriter writer(dropped, 16, 16, 12); effector::VideoWriter writer(dropped, 16, 16, 12);
+5
View File
@@ -553,6 +553,11 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
stack->set_visible_child("setup"); stack->set_visible_child("setup");
pump(100); pump(100);
check(preset->is_drawable() && duration->is_drawable(), "and its preset and length"); check(preset->is_drawable() && duration->is_drawable(), "and its preset and length");
auto* quality = named<Gtk::DropDown>(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 preset->set_selected(1); // Instagram story, the first preset
pump(200); pump(200);