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
+14
View File
@@ -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.
+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)) {
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")) {
+35 -4
View File
@@ -51,6 +51,31 @@ const VideoPreset* videoPresetByKey(const std::string& key) {
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
// 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<std::string> 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"});
+32 -2
View File
@@ -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<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.
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;
+30 -3
View File
@@ -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::Box>(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<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_.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<guint>(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<guint>(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;
+1
View File
@@ -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.
+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 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,
[&](int index, effector::Image&& frame) {
writer.write(frame);