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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user