Make video projects, with a moving channel split as the first video effect

A project can now be a clip: Setup has a Video switch with a length, a
frame rate and presets that set the canvas too (Instagram story, reel and
feed video, TikTok / YouTube Short, Square loop, Full HD clip). The preview
renders the whole clip, shrunk to a memory budget, and loops it with
play/pause and a scale; Save As... renders it again at full size and pipes
the frames into ffmpeg as .mp4, .mov, .mkv, .webm or .gif, with a progress
window that can cancel. --duration, --fps and --video-preset do the same
from the command line.

Effects learn about time through FrameTime and Effect::renderFrame, which
falls back to render, so nothing that holds still changes. Channel split
now sways, shakes and rolls a tracking bar down the picture in a video; its
motion params are marked forVideo() and hidden in a still project, and a
still renders byte for byte as before. renderFrames starts every frame
from the same seed and renders the still stages ahead of the first moving
one only once. The version moves to 0.6.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-26 11:22:01 +02:00
co-authored by Claude Opus 5.5
parent c069de3566
commit 385617781e
26 changed files with 1868 additions and 39 deletions
+235
View File
@@ -25,7 +25,9 @@
#include "core/rng.hpp"
#include "core/style.hpp"
#include "core/version.hpp"
#include "core/video.hpp"
#include "effects/blur.hpp"
#include "effects/channelsplit.hpp"
#include "effects/grain.hpp"
#include "effects/monochrome.hpp"
#include "effects/overlay.hpp"
@@ -1716,6 +1718,239 @@ int main() {
std::filesystem::remove_all(dir);
}
std::cout << "video\n";
{
using effector::ChainStage;
using effector::FrameTime;
using effector::VideoSettings;
// Every preset names a canvas format this build offers, and its
// numbers are ones the project reader and the CLI accept.
int sound = 0;
for (const auto& preset : effector::videoPresets()) {
if (effector::canvasFormatByKey(preset.canvasFormat) != nullptr &&
preset.seconds >= effector::kMinDuration && preset.seconds <= effector::kMaxDuration &&
preset.fps >= effector::kMinFps && preset.fps <= effector::kMaxFps &&
effector::videoPresetByKey(preset.key) == &preset) {
++sound;
}
}
check(sound == static_cast<int>(effector::videoPresets().size()),
"every video preset names a known canvas and a valid length and rate");
const auto* story = effector::videoPresetByKey("instagram-story");
check(story != nullptr && story->canvasFormat == "instagram-story" && story->seconds == 15.0,
"the Instagram story preset is a 15 s vertical clip");
VideoSettings clip;
clip.duration = 15.0;
clip.fps = 30;
check(clip.frameCount() == 450, "15 s at 30 fps is 450 frames");
clip.duration = 0.01;
check(clip.frameCount() == 3, "a clip below the shortest length is clamped to it");
check(effector::formatSeconds(7.5) == "7.5 s" && effector::formatSeconds(15) == "15 s",
"lengths read as 7.5 s and 15 s");
check(effector::isVideoPath("a/b.MP4") && effector::isVideoPath("x.webm") &&
effector::isVideoPath("x.gif") && !effector::isVideoPath("x.png") &&
!effector::isVideoPath("mp4"),
"a video is recognised by its extension");
const auto split = effector::effectByName("channelsplit");
const std::vector<effector::Image> sources = {gradient(96, 64, 40)};
// A still ignores every motion param: the picture is the one it
// always was, whatever they are set to.
auto calm = split->defaults();
calm["split_mode"] = std::string("per_band");
auto wild = calm;
wild["split_wobble"] = 40L;
wild["split_jitter"] = 30L;
wild["split_roll"] = 60L;
effector::Rng stillA(5);
effector::Rng stillB(5);
check(sameBytes(split->render(sources, 96, 64, stillA, calm),
split->renderFrame(sources, 96, 64, stillB, wild, FrameTime{})),
"motion params leave a still untouched");
check(split->animated(split->defaults()), "channel split moves by default in a video");
auto frozen = split->defaults();
frozen["split_wobble"] = 0L;
frozen["split_jitter"] = 0L;
frozen["split_roll"] = 0L;
check(!split->animated(frozen), "and holds still with every motion at 0");
// A moving stage leaves the shared rng in the same state on every
// frame, so what comes after it holds still.
effector::Rng frameA(9);
effector::Rng frameB(9);
split->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
split->renderFrame(sources, 96, 64, frameB, wild, FrameTime{17, 30, 30});
check(frameA.next() == frameB.next(), "every frame leaves the shared rng in one state");
auto stage = [](const std::string& name, effector::ParamValues values = {}) {
ChainStage built;
built.effect = effector::effectByName(name);
built.values = values.empty() ? built.effect->defaults() : std::move(values);
return built;
};
std::vector<ChainStage> chain = {stage("tiles"), stage("channelsplit", wild), stage("grain")};
VideoSettings video;
video.enabled = true;
video.duration = 1.0;
video.fps = 8;
std::vector<effector::Image> first;
const int handed = effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
first.push_back(std::move(frame));
return true;
});
check(handed == 8 && first.size() == 8, "a clip hands out every one of its frames");
std::vector<effector::Image> second;
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
second.push_back(std::move(frame));
return true;
});
bool repeatable = second.size() == first.size();
for (std::size_t i = 0; repeatable && i < first.size(); ++i) {
repeatable = sameBytes(first[i], second[i]);
}
check(repeatable, "a clip renders byte for byte again from its seed");
// The still stages before the moving one are rendered once and
// reused; that shortcut must give exactly what a frame on its own is.
bool exact = true;
for (const int index : {0, 3, 7}) {
effector::Rng rng(21);
const auto alone = effector::renderChain(chain, sources, 80, 48, rng, {},
FrameTime{index, 8, 8});
exact = exact && sameBytes(alone, first[static_cast<std::size_t>(index)]);
}
check(exact, "a clip's frame is exactly that frame rendered on its own");
check(!sameBytes(first[0], first[3]) && !sameBytes(first[3], first[7]),
"frames of a moving chain differ");
std::vector<ChainStage> steady = {stage("tiles"), stage("channelsplit", frozen), stage("grain")};
std::vector<effector::Image> held;
effector::renderFrames(steady, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
held.push_back(std::move(frame));
return true;
});
bool same = held.size() == 8;
for (const auto& frame : held) same = same && sameBytes(frame, held[0]);
effector::Rng stillRng(21);
check(same && sameBytes(held[0], effector::renderChain(steady, sources, 80, 48, stillRng)),
"a chain that does not move renders its still on every frame");
int seen = 0;
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&&) { return ++seen < 3; });
check(seen == 3, "returning false stops a clip early");
chain[1].enabled = false;
std::vector<effector::Image> skipped;
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
skipped.push_back(std::move(frame));
return true;
});
check(skipped.size() == 8 && sameBytes(skipped[0], skipped[7]),
"with the moving stage switched off the clip holds still");
const auto big = gradient(300, 120, 10);
const auto small = big.scaledToFit(100);
check(small.width() == 100 && small.height() == 40, "a preview frame keeps its shape");
check(big.scaledToFit(400).width() == 300, "a frame already small enough is left alone");
// The project file: a video says so, a still says nothing about time.
effector::Project project;
project.stages = {stage("channelsplit", wild)};
const auto stillJson = effector::projectToJson(project);
check(stillJson.find("\"video\"") == std::string::npos, "a still project writes no video");
check(!effector::projectFromJson(stillJson).video.enabled, "and reads back as a still");
project.video.enabled = true;
project.video.duration = 7.5;
project.video.fps = 24;
project.video.preset = "instagram-story";
const auto back = effector::projectFromJson(effector::projectToJson(project));
check(back.video.enabled && back.video.duration == 7.5 && back.video.fps == 24 &&
back.video.preset == "instagram-story",
"a video project keeps its length, rate and preset");
const auto motion = back.stages.at(0).values;
check(effector::paramInt(motion, "split_jitter", 0) == 30 &&
effector::paramInt(motion, "split_roll", 0) == 60,
"and its stages keep their motion");
const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": ";
check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(),
"an unknown preset reads as custom");
bool rejected = false;
try {
effector::projectFromJson(base + "2, \"fps\": 500}}");
} catch (const effector::EffectError&) {
rejected = true;
}
check(rejected, "a frame rate out of range is refused");
// The command line.
const auto preset = parsedOptions({"--video-preset", "instagram-story", "-o", "x.mp4"});
check(preset && preset->video.enabled && preset->width == 1080 && preset->height == 1920 &&
preset->video.duration == 15.0 && preset->video.fps == 30 &&
preset->video.preset == "instagram-story",
"--video-preset sets the canvas, length and rate");
const auto shorter = parsedOptions({"--duration", "5", "--video-preset", "instagram-story"});
check(shorter && shorter->video.duration == 5.0 && shorter->height == 1920 &&
shorter->video.preset.empty(),
"a length given alongside wins and leaves the preset custom");
const auto sized = parsedOptions({"--video-preset", "hd-clip", "-s", "320x240"});
check(sized && sized->width == 320 && sized->format.empty(),
"a size given alongside wins over the preset's canvas");
check(!parsedOptions({"--fps", "0"}) && !parsedOptions({"--duration", "abc"}) &&
!parsedOptions({"--duration", "400"}) && !parsedOptions({"--video-preset", "vhs"}),
"a bad rate, length or preset is refused");
check(!parsedOptions({"-o", "x.png"})->video.enabled, "without them the output is a still");
bool wrongName = false;
try {
effector::VideoWriter writer("clip.png", 16, 16, 30);
} catch (const effector::EffectError&) {
wrongName = true;
}
check(wrongName, "a video is not written under an image's name");
if (effector::ffmpegAvailable()) {
const auto dir = std::filesystem::temp_directory_path() / "effector-video-test";
std::filesystem::create_directories(dir);
const auto file = (dir / "clip.mp4").string();
{
// Odd sides: the writer pads them for the encoder.
effector::VideoWriter writer(file, 17, 9, 12);
for (int i = 0; i < 3; ++i) writer.write(gradient(17, 9, 40 * i));
writer.finish();
}
check(std::filesystem::exists(file) && std::filesystem::file_size(file) > 0,
"ffmpeg writes an .mp4 of odd size");
const auto dropped = (dir / "dropped.webm").string();
{
effector::VideoWriter writer(dropped, 16, 16, 12);
writer.write(gradient(16, 16, 10));
writer.cancel();
}
check(!std::filesystem::exists(dropped), "a cancelled video leaves no file behind");
bool wrongSize = false;
try {
effector::VideoWriter writer((dir / "sized.mp4").string(), 16, 16, 12);
writer.write(gradient(8, 8, 10));
} catch (const effector::EffectError&) {
wrongSize = true;
}
check(wrongSize && !std::filesystem::exists(dir / "sized.mp4"),
"a frame of the wrong size is refused and the file dropped");
std::filesystem::remove_all(dir);
} else {
std::cout << " skip writing a video (ffmpeg is not installed)\n";
}
}
std::cout << "version\n";
{
// A release is the version in CMakeLists.txt and its entry at the top
+75
View File
@@ -440,6 +440,80 @@ void testRecentProjects(Gtk::Window& window, const std::filesystem::path& dir) {
"opened projects are listed newest first, a shared name with its folder");
}
// Visible labels in the params grid saying `text`.
bool showsLabel(Gtk::Widget& root, const std::string& text) {
for (auto* label : all<Gtk::Label>(root)) {
if (label->get_text() == text && label->is_drawable()) return true;
}
return false;
}
void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
std::cout << "video\n";
std::filesystem::create_directories(dir);
const auto path = dir / "moving.json";
effector::Project project;
project.stages.push_back({effector::effectByName("channelsplit"), {}}); // needs no sources
effector::saveProject(path.string(), project);
window.activate_action("win.open-recent",
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
pump(300);
auto* stack = all<Gtk::Stack>(window).front();
auto* toggle = named<Gtk::Switch>(window, "video");
auto* preset = named<Gtk::DropDown>(window, "video-preset");
auto* duration = named<Gtk::SpinButton>(window, "video-duration");
auto* width = named<Gtk::SpinButton>(window, "canvas-width");
auto* height = named<Gtk::SpinButton>(window, "canvas-height");
auto* playback = named<Gtk::Box>(window, "playback");
check(toggle != nullptr && preset != nullptr && duration != nullptr && width != nullptr &&
height != nullptr && playback != nullptr,
"the video switch, its settings and the playback bar are there");
if (toggle == nullptr || preset == nullptr || duration == nullptr || width == nullptr ||
height == nullptr || playback == nullptr) {
return;
}
check(isAncestor(*stack->get_child_by_name("setup"), *toggle), "the video switch is on Setup");
check(!toggle->get_active() && !preset->is_drawable() && !playback->get_visible(),
"a still project shows no video settings and no playback");
check(!showsLabel(window, "Wobble"), "and no motion params for the channel split");
toggle->set_active(true);
pump(200);
check(showsLabel(window, "Wobble") && showsLabel(window, "Tracking bar"),
"a video shows the channel split's motion params");
stack->set_visible_child("setup");
pump(100);
check(preset->is_drawable() && duration->is_drawable(), "and its preset and length");
preset->set_selected(1); // Instagram story, the first preset
pump(200);
check(width->get_value() == 1080 && height->get_value() == 1920 && duration->get_value() == 15,
"Instagram story picks a 1080 × 1920 canvas and 15 s");
duration->set_value(0.5);
pump(100);
check(preset->get_selected() == 0, "changing the length drops the preset to Custom");
for (int waited = 0; waited < 200 && !playback->get_visible(); ++waited) pump(50);
check(playback->get_visible(), "the clip is rendered and plays in the preview");
auto* scale = all<Gtk::Scale>(*playback).empty() ? nullptr : all<Gtk::Scale>(*playback).front();
const double before = scale != nullptr ? scale->get_value() : -1.0;
pump(250);
check(scale != nullptr && scale->get_value() != before, "and it moves on by itself");
window.activate_action("win.save-project");
pump(100);
const auto saved = effector::loadProject(path.string());
check(saved.video.enabled && saved.video.duration == 0.5 && saved.width == 1080,
"the project is saved as a video");
toggle->set_active(false);
pump(300);
check(!playback->get_visible() && !showsLabel(window, "Wobble"),
"switched back to a still, the playback and motion params go");
stack->set_visible_child("chain");
}
} // namespace
int main(int argc, char** argv) {
@@ -467,6 +541,7 @@ int main(int argc, char** argv) {
runTests(*window);
testHelpWindows(*window);
testRecentProjects(*window, dataDir / "projects");
testVideo(*window, dataDir / "video");
app->remove_window(*window);
});
const int status = app->run(argc > 0 ? 1 : 0, argv);