diff --git a/CHANGELOG.md b/CHANGELOG.md index d4458c7..704d1d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ Every release of Effector, newest first. The version lives in one place, the `project()` line of `CMakeLists.txt`, and each release is a `vX.Y.Z` tag on the commit that made it. The interface shows this file under Changelog in its menu. +## [0.6.0] - 2026-09-26 + +### Added +- Video projects: switch Video on under Setup and give the clip a length and a + frame rate, or pick a preset that sets the canvas too: Instagram story, + Instagram reel, Instagram feed video, TikTok / YouTube Short, Square loop and + Full HD clip. The preview renders the clip and loops it, with play/pause and + a scale to look at any one frame. +- Save As… writes a video project as `.mp4`, `.mov`, `.mkv`, `.webm` or `.gif` + through `ffmpeg`, with a progress window that can cancel it. +- Channel split moves in a video: its channels sway, shake from frame to frame + and a tracking bar rolls down the picture, like an old tape on a tube. The + new Wobble, Jitter and Tracking bar settings show only in a video, and a + still renders exactly as before. +- `--duration`, `--fps` and `--video-preset` render a video from the command + line. +- A Vertical Full HD canvas format, 1080 × 1920. + ## [0.5.3] - 2026-09-26 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index af84268..34a77b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,9 @@ Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way. The core test suite is a single hand-rolled binary (no framework), so there is no per-test filter: run `./build/tests/effector_tests` directly to see the section headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`, -`image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment +`image io`, `batch loading`, `video`, `version`) and the `ok`/`FAIL` lines. The +`video` section writes real files through `ffmpeg` when it is on the PATH and +prints a `skip` line otherwise. To iterate on one area, comment out the other blocks in `tests/core_tests.cpp` or add a new `std::cout << "section\n"` block — checks are plain `check(condition, "description")` calls and `main()` returns the failure count. @@ -170,11 +172,56 @@ the `effector` application name on each entry; the GUI tests point `XDG_DATA_HOME` at a temporary folder so they never touch the user's list. Opening one replaces the sources, so a decode still in flight is discarded rather than mixed into the new project (`dropLoadedImages_`). +### Video projects + +A project is a still or a clip: `VideoSettings` (`src/core/video.hpp`: enabled, +duration, fps, preset) rides in `Project::video` and `Options::video`, and is +written to the project file only when enabled. `videoPresets()` is one table +like `canvasFormats()` — a preset names a canvas format plus a length and rate, +and like a format it is the presentation, never the truth: the render goes by +duration and fps. Adding a preset is a line there. + +Time reaches an effect as a `FrameTime` (`src/core/effect.hpp`; the default, +`fps == 0`, is a still) through `Effect::renderFrame`, which falls back to +`render` — an effect that does not move overrides nothing. One that moves +overrides `renderFrame` and `animated(values)`, and marks its motion params +`.forVideo()`: the interface hides those in a still project (declaratively, +like `.styled()`), the CLI still has their flags. A still must render byte for +byte what it did before the effect learned to move. + +`renderFrames` (`src/core/chain.cpp`) renders a clip. Every frame starts from +`Rng(seed)`, so a stage that does not move draws the same values every frame +and holds still. A moving effect must therefore leave the shared rng in the +same state on every frame — the channel split draws its per-frame shake from a +generator of its own, seeded by one draw — or everything after it would flicker. +The stages before the first `animated()` one are rendered once and their image +and rng state reused; the `video` tests assert that frame k is exactly +`renderChain(..., FrameTime{k, ...})`, so that shortcut can never change output. +The determinism contract covers the frames; the encoded file is whatever the +machine's ffmpeg makes of them. + +`VideoWriter` pipes raw `rgb24` frames into the `ffmpeg` program +(`posix_spawnp` with an argv — no shell; `SIGPIPE` ignored; stderr captured for +the error message; codec by extension). `cancel()` SIGKILLs it — SIGTERM makes +ffmpeg flush its encoder first — and removes the file; a writer destroyed +unfinished cancels itself. + +In the interface, a video render (`startRender`) renders every frame at full +size, shrinks each (`Image::scaledToFit`, sized by `clipSide` to a memory +budget) and hands them over through `pendingFrames_`/`clipProgress_`; the +clip on screen keeps looping until the new one is complete, and +`renderCancel_` stops a clip whose settings went stale. Save As… renders again +at full size on `exporter_` into a `VideoWriter`, with an `exportWindow_` +progress window (its `exportBar_` nulled in `closeExportWindow()`). Under Xvfb +the GTK file dialog goes through the desktop portal and may open on the real +session instead; set `GDK_DEBUG=no-portals` to keep it in the Xvfb display. + ### Two link targets, deliberately -`effector_core` (static) holds image IO, params, effects, rng and links only -`gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need -no display) + Threads — **no gtkmm, no display**. `effector_gui` (static) adds +`effector_core` (static) holds image IO, params, effects, rng, video writing +and links only `gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango +and Cairo need no display) + Threads — **no gtkmm, no display**; `ffmpeg` is a +program it runs, not a library it links. `effector_gui` (static) adds `src/cli/` and `src/gui/` and links gtkmm, and the `effector` executable is `src/main.cpp` on top of it; the GUI tests link `effector_gui` too. Keep GTK out of `src/core/` and `src/effects/`; the tests link the core alone and rely on this. diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e4af18..0ea4f7a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(effector VERSION 0.5.3 LANGUAGES CXX) +project(effector VERSION 0.6.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -40,6 +40,7 @@ add_library(effector_core STATIC src/core/registry.cpp src/core/rng.cpp src/core/style.cpp + src/core/video.cpp src/effects/bitcrush.cpp src/effects/blur.cpp src/effects/channelsplit.cpp diff --git a/README.md b/README.md index 7c95ca5..2aa9dc3 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ those declarations, so a new effect needs no UI or CLI code. Requirements: a C++20 compiler (GCC 13+), CMake 3.20+, `gtkmm-4.0` and `gdk-pixbuf-2.0` development packages. On Arch: `gcc cmake ninja gtkmm-4.0`. +Writing videos needs the `ffmpeg` program at run time (on Arch: `ffmpeg`); +without it everything else works and the video tests are skipped. ```bash cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release @@ -58,6 +60,32 @@ Custom hands them back, keeping whatever the format last worked out to. A format is saved with the project, so an A4 project reopens as A4 rather than as the pixels it happened to be. +### Video + +A project can be a video clip instead of a still: switch **Video** on under +Setup and give it a length and a frame rate, or pick a preset that sets the +canvas too — Instagram story (1080×1920, 15 s), Instagram reel (30 s), +Instagram feed video, TikTok / YouTube Short, Square loop (6 s) and Full HD +clip. The preview renders the whole clip first, shrunk to fit in memory, and +then loops it, with play/pause and a scale to look at any one frame. **Save +As…** then writes the video at full size through `ffmpeg`, which has to be +installed: `.mp4`, `.mov` and `.mkv` as H.264, `.webm` as VP9, `.gif` as a +palette GIF. + +```bash +build/effector -o story.mp4 --video-preset instagram-story -e channelsplit --seed 1 photo.jpg +build/effector -o loop.gif -s 480x480 --duration 3 --fps 15 -e channelsplit photo.jpg +``` + +`--duration SECONDS` and `--fps N` make any run a video; a preset fills in +whatever was not given alongside it. Effects that move over time are video +effects; for now that is **Channel split**, which in a video sways its +channels, shakes them from frame to frame and rolls a tracking bar down the +picture, like a worn tape on an old tube. Its motion params only show in a +video project, and a still renders exactly as before. Every frame starts from +the same seed, so stages that do not move hold still while one that does +moves, and a clip renders frame for frame the same again. + Any option can also be passed when launching the interface; it becomes the starting state (images, canvas size, effect settings, seed). diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 9390d56..bc75631 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -2,13 +2,14 @@ # so pkgver must follow the project() line of CMakeLists.txt (the version core # test checks it). From this folder: makepkg -si pkgname=effector -pkgver=0.5.3 +pkgver=0.6.0 pkgrel=1 pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects' arch=('x86_64' 'aarch64') url='https://git.plan9.uber.space/hans/effector' license=('GPL-3.0-or-later') depends=('gtkmm-4.0' 'gdk-pixbuf2' 'pango' 'cairo' 'glib2' 'glibc' 'gcc-libs') +optdepends=('ffmpeg: write video projects') makedepends=('cmake' 'ninja' 'git') checkdepends=('xorg-server-xvfb') source=("$pkgname::git+file://${startdir%/packaging/arch}#tag=v$pkgver") diff --git a/src/cli/options.cpp b/src/cli/options.cpp index e5b1e3a..37e4adf 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -55,6 +55,11 @@ std::string requireValue(int argc, char** argv, int& i, const std::string& flag) Options parseOptions(int argc, char** argv) { Options options; + // What was given explicitly, so that a --video-preset fills in only the + // rest, whichever order the flags come in. + bool sizeGiven = false; + bool durationGiven = false; + bool fpsGiven = false; for (int i = 1; i < argc; ++i) { const std::string arg = argv[i]; @@ -73,6 +78,7 @@ Options parseOptions(int argc, char** argv) { options.output = requireValue(argc, argv, i, arg); } else if (arg == "-s" || arg == "--size") { const std::string value = requireValue(argc, argv, i, arg); + sizeGiven = true; if (!parseSize(value, options.width, options.height)) { throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with " "each side between " + std::to_string(kMinCanvasSide) + " and " + @@ -92,6 +98,41 @@ Options parseOptions(int argc, char** argv) { std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi)); } options.dpi = static_cast(dpi); + } else if (arg == "--duration") { + const std::string value = requireValue(argc, argv, i, arg); + double seconds = 0.0; + try { + std::size_t used = 0; + seconds = std::stod(value, &used); + if (used != value.size()) seconds = 0.0; + } catch (const std::exception&) { + seconds = 0.0; + } + if (!(seconds >= kMinDuration) || !(seconds <= kMaxDuration)) { + throw EffectError("invalid duration '" + value + "', expected seconds between " + + formatSeconds(kMinDuration) + " and " + + formatSeconds(kMaxDuration)); + } + options.video.enabled = true; + options.video.duration = seconds; + durationGiven = true; + } else if (arg == "--fps") { + const std::string value = requireValue(argc, argv, i, arg); + long fps = 0; + if (!parseLong(value, fps) || fps < kMinFps || fps > kMaxFps) { + throw EffectError("invalid frame rate '" + value + "', expected " + + std::to_string(kMinFps) + "-" + std::to_string(kMaxFps)); + } + options.video.enabled = true; + options.video.fps = static_cast(fps); + fpsGiven = true; + } else if (arg == "--video-preset") { + const std::string value = requireValue(argc, argv, i, arg); + if (videoPresetByKey(value) == nullptr) { + throw EffectError("unknown video preset '" + value + "', see --help"); + } + options.video.enabled = true; + options.video.preset = value; } else if (arg == "--disable") { const std::string name = requireValue(argc, argv, i, arg); effectByName(name); // validate now, exactly as -e does @@ -162,6 +203,18 @@ Options parseOptions(int argc, char** argv) { options.images.push_back(arg); } } + // A preset fills in whatever was not given on its own: the length, the + // rate and -- unless -s or --format named one -- the canvas. It keeps its + // name only while the settings still are the preset's. + if (const auto* preset = videoPresetByKey(options.video.preset)) { + if (!durationGiven) options.video.duration = preset->seconds; + if (!fpsGiven) options.video.fps = preset->fps; + if (options.format.empty() && !sizeGiven) options.format = preset->canvasFormat; + if (options.video.duration != preset->seconds || options.video.fps != preset->fps || + options.format != preset->canvasFormat) { + options.video.preset.clear(); + } + } // Applied once everything is parsed, so --dpi may come before --format and // so a format always wins over an -s given alongside it. if (const auto* format = canvasFormatByKey(options.format)) { @@ -183,6 +236,11 @@ std::string usage(const char* programName) { << " --format NAME canvas format, overrides --size (see below)\n" << " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi << " (default: " << kDefaultDpi << ")\n" + << " --duration SECS render a video this long, " << formatSeconds(kMinDuration) + << " to " << formatSeconds(kMaxDuration) << "\n" + << " --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" << " --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" @@ -197,6 +255,15 @@ std::string usage(const char* programName) { << (format.print ? " at --dpi" : "") << "\n"; } + out << "\nA video is written through ffmpeg, as .mp4, .mov, .mkv, .webm or .gif by the " + "name given to\n--output.\n"; + out << "\nVideo presets (--video-preset):\n"; + for (const auto& preset : videoPresets()) { + out << " " << preset.key; + if (preset.key.size() < 20) out << std::string(20 - preset.key.size(), ' '); + out << " " << preset.label << ", " << videoPresetDetail(preset) << "\n"; + } + for (const auto& effect : effects()) { out << "\n" << effect->title() << " options:\n"; for (const auto& spec : effect->params()) { @@ -204,7 +271,8 @@ std::string usage(const char* programName) { if (spec.type != ParamSpec::Type::Bool) flag += " VALUE"; out << " " << flag; if (flag.size() < 20) out << std::string(20 - flag.size(), ' '); - out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n"; + out << " " << (spec.help.empty() ? spec.label : spec.help) + << (spec.videoOnly ? " (video only)" : "") << "\n"; } } return out.str(); diff --git a/src/cli/options.hpp b/src/cli/options.hpp index 6a39c43..a355fbf 100644 --- a/src/cli/options.hpp +++ b/src/cli/options.hpp @@ -8,6 +8,7 @@ #include "core/canvas.hpp" #include "core/chain.hpp" #include "core/params.hpp" +#include "core/video.hpp" namespace effector { @@ -23,6 +24,9 @@ struct Options { int height = 1080; std::string format; // a named canvas format, empty for plain pixels int dpi = kDefaultDpi; // only a print format is rendered at a resolution + // Enabled by --duration, --fps or --video-preset: the output is then a + // video, and --output has to name one. + VideoSettings video; std::optional seed; ParamValues params; // only the keys the user actually passed bool listEffects = false; diff --git a/src/core/canvas.cpp b/src/core/canvas.cpp index 4b55bc2..0a2eff8 100644 --- a/src/core/canvas.cpp +++ b/src/core/canvas.cpp @@ -33,6 +33,7 @@ const std::vector& canvasFormats() { {"instagram-post", "Instagram post", false, 1080, 1080, 0.0, 0.0}, {"instagram-portrait", "Instagram portrait", false, 1080, 1350, 0.0, 0.0}, {"instagram-story", "Instagram story", false, 1080, 1920, 0.0, 0.0}, + {"vertical-hd", "Vertical Full HD", false, 1080, 1920, 0.0, 0.0}, {"hd", "Full HD", false, 1920, 1080, 0.0, 0.0}, {"uhd", "4K UHD", false, 3840, 2160, 0.0, 0.0}, {"a5", "A5", true, 0, 0, 148.0, 210.0}, diff --git a/src/core/chain.cpp b/src/core/chain.cpp index 33e2d0c..a8ded83 100644 --- a/src/core/chain.cpp +++ b/src/core/chain.cpp @@ -1,5 +1,7 @@ #include "core/chain.hpp" +#include + namespace effector { std::vector enabledStages(const std::vector& stages) { @@ -29,8 +31,10 @@ ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) { return values; } -Image renderChain(const std::vector& stages, const std::vector& sources, - int width, int height, Rng& rng, const StyleSet& styles) { +namespace { + +// The stages that run, checked: throws for a chain with nothing to render. +std::vector runningStages(const std::vector& stages) { if (stages.empty()) throw EffectError("The effect chain is empty."); for (const auto& stage : stages) { if (!stage.effect) throw EffectError("The effect chain has an empty stage."); @@ -39,27 +43,83 @@ Image renderChain(const std::vector& stages, const std::vector& running, std::size_t first, + std::size_t last, const std::vector& sources, Image image, int width, + int height, Rng& rng, const StyleSet& styles, const FrameTime& frame) { + for (std::size_t i = first; i < last; ++i) { const auto& stage = *running[i]; const auto values = stageValues(stage, styles); if (i == 0) { - image = stage.effect->render(sources, width, height, rng, values); + image = stage.effect->renderFrame(sources, width, height, rng, values, frame); } else { // Feeding the previous result back in as the only source is what // cascading means here; effects need to know nothing about it. const std::vector previous = {std::move(image)}; - image = stage.effect->render(previous, width, height, rng, values); + image = stage.effect->renderFrame(previous, width, height, rng, values, frame); } } return image; } +} // namespace + +Image renderChain(const std::vector& stages, const std::vector& sources, + int width, int height, Rng& rng, const StyleSet& styles, + const FrameTime& frame) { + const auto running = runningStages(stages); + return runStages(running, 0, running.size(), sources, Image(), width, height, rng, styles, + frame); +} + +bool chainAnimated(const std::vector& stages, const StyleSet& styles) { + for (const auto* stage : enabledStages(stages)) { + if (stage->effect->animated(stageValues(*stage, styles))) return true; + } + return false; +} + +int renderFrames(const std::vector& stages, const std::vector& sources, + int width, int height, std::uint64_t seed, const StyleSet& styles, + const VideoSettings& video, + const std::function& onFrame) { + const auto running = runningStages(stages); + const int count = video.frameCount(); + const int fps = std::clamp(video.fps, kMinFps, kMaxFps); + + // Everything before the first stage that moves is the same on every + // frame -- the same seed, the same values, no time -- so it is rendered + // once, as frame 0, and picked up from there. The rng is copied along + // with the picture, which is what makes the shortcut exact. + std::size_t still = 0; + while (still < running.size() && + !running[still]->effect->animated(stageValues(*running[still], styles))) { + ++still; + } + Rng prefixRng(seed); + const Image prefix = runStages(running, 0, still, sources, Image(), width, height, prefixRng, + styles, FrameTime{0, count, fps}); + + for (int index = 0; index < count; ++index) { + const FrameTime frame{index, count, fps}; + Rng rng = prefixRng; + Image image = still == running.size() + ? prefix + : runStages(running, still, running.size(), sources, prefix, width, + height, rng, styles, frame); + if (!onFrame(index, std::move(image))) return index + 1; + } + return count; +} + bool chainNeedsSources(const std::vector& stages) { const auto running = enabledStages(stages); if (running.empty()) return false; diff --git a/src/core/chain.hpp b/src/core/chain.hpp index d779998..6fcb19a 100644 --- a/src/core/chain.hpp +++ b/src/core/chain.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -8,6 +9,7 @@ #include "core/params.hpp" #include "core/rng.hpp" #include "core/style.hpp" +#include "core/video.hpp" namespace effector { @@ -52,8 +54,28 @@ std::vector enabledStages(const std::vector& stag // reproduces the cascade byte for byte. Throws when no stage is left to run. // `styles` is the project's pool; a chain built without one -- the command // line's -- passes none and renders from the stages alone. +// +// `frame` says where in a video the render sits, a still by default; every +// stage is handed it, and only one that moves makes anything of it. Image renderChain(const std::vector& stages, const std::vector& sources, - int width, int height, Rng& rng, const StyleSet& styles = {}); + int width, int height, Rng& rng, const StyleSet& styles = {}, + const FrameTime& frame = {}); + +// Whether any stage that runs moves over time with the values it runs with. +bool chainAnimated(const std::vector& stages, const StyleSet& styles = {}); + +// Renders every frame of a video, in order, handing each to `onFrame`; it +// returning false stops the render there. Frame k is exactly what +// renderChain gives with a fresh Rng(seed) and FrameTime{k, count, fps} -- +// every frame starts from the same seed, so a stage that does not move +// renders the same picture throughout rather than reshuffling on every +// frame. The stages before the first one that moves are rendered once, and +// their picture and the rng state they leave are reused for every frame. +// Returns the number of frames handed out. +int renderFrames(const std::vector& stages, const std::vector& sources, + int width, int height, std::uint64_t seed, const StyleSet& styles, + const VideoSettings& video, + const std::function& onFrame); // Whether the chain has to be given source images. Only the first stage that // runs ever sees them — every later one works on the stage before it — so this diff --git a/src/core/effect.hpp b/src/core/effect.hpp index 8e305df..8bc9ab6 100644 --- a/src/core/effect.hpp +++ b/src/core/effect.hpp @@ -16,6 +16,19 @@ public: using std::runtime_error::runtime_error; }; +// Where a render sits in time. The default -- no frame rate -- is a still +// picture; a video hands every frame its index, the clip's length in frames +// and its rate, so an effect that moves works out where it is from these +// alone and a frame can be rendered on its own, in any order. +struct FrameTime { + int index = 0; + int count = 1; + int fps = 0; + + bool video() const noexcept { return fps > 0; } + double seconds() const noexcept { return video() ? static_cast(index) / fps : 0.0; } +}; + // Base class for every effect. Canvas size and the random source are common to // all of them; params() covers only the effect-specific knobs. class Effect { @@ -45,9 +58,27 @@ public: return title(); } + // Whether a stage with these values changes from one frame of a video to + // the next. A stage that does not is rendered once for the whole clip. + virtual bool animated(const ParamValues& values) const { + (void)values; + return false; + } + virtual Image render(const std::vector& sources, int width, int height, Rng& rng, const ParamValues& values) const = 0; + // One frame of a video, or a still when `frame` says so. An effect that + // does not move needs only render(), which this falls back to. One that + // does must leave `rng` in the same state on every frame -- a frame's own + // randomness comes from a generator of its own -- so the stages after it + // stay put while it moves. + virtual Image renderFrame(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values, const FrameTime& frame) const { + (void)frame; + return render(sources, width, height, rng, values); + } + ParamValues defaults() const; }; diff --git a/src/core/image.cpp b/src/core/image.cpp index 73321b8..aa218a1 100644 --- a/src/core/image.cpp +++ b/src/core/image.cpp @@ -91,6 +91,39 @@ Image Image::load(const std::string& path, std::vector* alphaOut) return image; } +Image Image::scaledToFit(int maxSide) const { + const int longest = std::max(width_, height_); + if (empty() || maxSide < 1 || longest <= maxSide) return *this; + + const double scale = static_cast(maxSide) / longest; + const int outWidth = std::max(1, static_cast(width_ * scale)); + const int outHeight = std::max(1, static_cast(height_ * scale)); + Image out(outWidth, outHeight); + for (int y = 0; y < outHeight; ++y) { + const int y0 = static_cast(static_cast(y) * height_ / outHeight); + const int y1 = std::max(y0 + 1, static_cast(static_cast(y + 1) * height_ / outHeight)); + std::uint8_t* target = out.row(y); + for (int x = 0; x < outWidth; ++x) { + const int x0 = static_cast(static_cast(x) * width_ / outWidth); + const int x1 = std::max(x0 + 1, static_cast(static_cast(x + 1) * width_ / outWidth)); + unsigned sum[3] = {0, 0, 0}; + for (int sy = y0; sy < y1; ++sy) { + const std::uint8_t* in = row(sy) + static_cast(x0) * 3; + for (int sx = x0; sx < x1; ++sx, in += 3) { + sum[0] += in[0]; + sum[1] += in[1]; + sum[2] += in[2]; + } + } + const unsigned area = static_cast((y1 - y0) * (x1 - x0)); + for (int c = 0; c < 3; ++c) { + target[static_cast(x) * 3 + c] = static_cast(sum[c] / area); + } + } + } + return out; +} + void Image::save(const std::string& path) const { if (empty()) throw std::runtime_error("nothing to save"); diff --git a/src/core/image.hpp b/src/core/image.hpp index 6885135..ffa584d 100644 --- a/src/core/image.hpp +++ b/src/core/image.hpp @@ -40,6 +40,11 @@ public: // which is what an effect compositing the image needs. static Image load(const std::string& path, std::vector* alphaOut = nullptr); + // A copy shrunk so that its longer side is at most `maxSide`, each pixel + // the average of the ones it covers; an image already that small comes + // back as it is. For previews: nothing rendered goes through it. + Image scaledToFit(int maxSide) const; + // Encodes by file extension; png is used when the extension is unknown. void save(const std::string& path) const; diff --git a/src/core/params.cpp b/src/core/params.cpp index fc95c52..b6f963c 100644 --- a/src/core/params.cpp +++ b/src/core/params.cpp @@ -16,6 +16,12 @@ ParamSpec ParamSpec::styled() const { return copy; } +ParamSpec ParamSpec::forVideo() const { + ParamSpec copy = *this; + copy.videoOnly = true; + return copy; +} + ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum, long maximum, std::string help, long step) { ParamSpec spec; diff --git a/src/core/params.hpp b/src/core/params.hpp index a834cc5..d189682 100644 --- a/src/core/params.hpp +++ b/src/core/params.hpp @@ -27,6 +27,10 @@ struct ParamSpec { // Whether a named style carries this param rather than the stage it sits // on: see core/style.hpp. bool inStyle = false; + // Whether the param only means something in a video project -- how a + // stage moves over time. The interface leaves it out of a still project, + // where it would change nothing. + bool videoOnly = false; std::string flag() const; // "strip_height" -> "--strip-height" @@ -34,6 +38,9 @@ struct ParamSpec { // reads makeDouble("font_size", ...).styled() and the factories keep the // signatures they have. ParamSpec styled() const; + // A copy of this spec marked as mattering only in a video project, read + // the same way: makeInt("split_jitter", ...).forVideo(). + ParamSpec forVideo() const; static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum, long maximum, std::string help = {}, long step = 1); diff --git a/src/core/project.cpp b/src/core/project.cpp index 71d7b46..6f61057 100644 --- a/src/core/project.cpp +++ b/src/core/project.cpp @@ -319,6 +319,25 @@ int canvasDpi(const Json& value) { return static_cast(value.number); } +// A clip's length in seconds and its frame rate, checked like the canvas. +double videoDuration(const Json& value) { + if (value.kind != Json::Kind::Number || !(value.number >= kMinDuration) || + !(value.number <= kMaxDuration)) { + throw EffectError("the video duration must be between " + formatSeconds(kMinDuration) + + " and " + formatSeconds(kMaxDuration)); + } + return value.number; +} + +int videoFps(const Json& value) { + if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number) || + !(value.number >= kMinFps) || !(value.number <= kMaxFps)) { + throw EffectError("the video frame rate must be a whole number between " + + std::to_string(kMinFps) + " and " + std::to_string(kMaxFps)); + } + return static_cast(value.number); +} + std::string resolve(const std::string& path, const std::string& directory) { std::filesystem::path file(path); if (file.is_absolute() || directory.empty()) return path; @@ -435,6 +454,16 @@ std::string projectToJson(const Project& project, const std::string& directory) if (format->print) out << ", \"dpi\": " << project.dpi; } out << "},\n"; + // Only a video says anything about time: a still project reads exactly as + // it did before there were videos. + if (project.video.enabled) { + out << " \"video\": {\"duration\": " << number(project.video.duration, 3) + << ", \"fps\": " << project.video.fps; + if (const auto* preset = videoPresetByKey(project.video.preset)) { + out << ", \"preset\": " << quote(preset->key); + } + out << "},\n"; + } if (project.seed) out << " \"seed\": " << *project.seed << ",\n"; out << " \"sources\": ["; @@ -506,6 +535,21 @@ Project projectFromJson(const std::string& text, const std::string& directory) { } } + if (const auto* video = root.member("video"); video != nullptr && + video->kind == Json::Kind::Object) { + project.video.enabled = true; + if (const auto* duration = video->member("duration")) { + project.video.duration = videoDuration(*duration); + } + if (const auto* fps = video->member("fps")) project.video.fps = videoFps(*fps); + // Like a canvas format, a preset this build does not know is just the + // user's own settings: the numbers were written too. + if (const auto* name = video->member("preset"); name != nullptr && + name->kind == Json::Kind::String) { + if (const auto* preset = videoPresetByKey(name->text)) project.video.preset = preset->key; + } + } + if (const auto* seed = root.member("seed")) { // Taken from the digits rather than from the double: a 64-bit seed has // more bits than a double has mantissa, and a seed that comes back off diff --git a/src/core/project.hpp b/src/core/project.hpp index ffb1749..a140ef2 100644 --- a/src/core/project.hpp +++ b/src/core/project.hpp @@ -9,6 +9,7 @@ #include "core/chain.hpp" #include "core/params.hpp" #include "core/style.hpp" +#include "core/video.hpp" namespace effector { @@ -30,6 +31,8 @@ struct Project { // resolution for. std::string format; int dpi = kDefaultDpi; + // Whether the project is a video, and how long and how fast. + VideoSettings video; std::optional seed; }; diff --git a/src/core/video.cpp b/src/core/video.cpp new file mode 100644 index 0000000..50540f4 --- /dev/null +++ b/src/core/video.cpp @@ -0,0 +1,272 @@ +#include "core/video.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/canvas.hpp" +#include "core/effect.hpp" + +extern char** environ; + +namespace effector { + +int VideoSettings::frameCount() const { + const double seconds = std::clamp(duration, kMinDuration, kMaxDuration); + const int rate = std::clamp(fps, kMinFps, kMaxFps); + return std::max(1, static_cast(std::lround(seconds * rate))); +} + +const std::vector& videoPresets() { + // A story segment runs fifteen seconds, a reel is best kept to half a + // minute, and a loop is short enough to watch go round a few times. + static const std::vector presets = { + {"instagram-story", "Instagram story", "instagram-story", 15.0, 30}, + {"instagram-reel", "Instagram reel", "instagram-story", 30.0, 30}, + {"instagram-feed", "Instagram feed video", "instagram-portrait", 10.0, 30}, + {"vertical-short", "TikTok / YouTube Short", "vertical-hd", 30.0, 30}, + {"square-loop", "Square loop", "instagram-post", 6.0, 30}, + {"hd-clip", "Full HD clip", "hd", 10.0, 30}, + }; + return presets; +} + +const VideoPreset* videoPresetByKey(const std::string& key) { + if (key.empty()) return nullptr; + for (const auto& preset : videoPresets()) { + if (preset.key == key) return &preset; + } + return nullptr; +} + +// 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) { + std::ostringstream out; + out.imbue(std::locale::classic()); + out.setf(std::ios::fixed); + out.precision(1); + out << seconds; + std::string text = out.str(); + if (text.size() > 2 && text.compare(text.size() - 2, 2, ".0") == 0) text.resize(text.size() - 2); + return text + " s"; +} + +std::string videoPresetDetail(const VideoPreset& preset) { + const auto* format = canvasFormatByKey(preset.canvasFormat); + std::string text = format != nullptr ? canvasFormatDetail(*format) + " · " : std::string(); + return text + formatSeconds(preset.seconds); +} + +namespace { + +std::string lowerExtension(const std::string& path) { + std::string extension = std::filesystem::path(path).extension().string(); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return extension; +} + +// The container ffmpeg is told to write, by extension; empty for one that +// is not a video. +std::string muxerFor(const std::string& extension) { + if (extension == ".mp4") return "mp4"; + if (extension == ".mov") return "mov"; + if (extension == ".mkv") return "matroska"; + if (extension == ".webm") return "webm"; + if (extension == ".gif") return "gif"; + return {}; +} + +} // namespace + +bool isVideoPath(const std::string& path) { return !muxerFor(lowerExtension(path)).empty(); } + +bool ffmpegAvailable() { + const char* path = std::getenv("PATH"); + if (path == nullptr) return false; + std::stringstream dirs(path); + std::string dir; + while (std::getline(dirs, dir, ':')) { + if (dir.empty()) dir = "."; + if (::access((dir + "/ffmpeg").c_str(), X_OK) == 0) return true; + } + return false; +} + +VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps) + : path_(path), width_(width), height_(height) { + const std::string muxer = muxerFor(lowerExtension(path)); + if (muxer.empty()) { + throw EffectError("A video is written as .mp4, .mov, .mkv, .webm or .gif, not '" + + std::filesystem::path(path).filename().string() + "'."); + } + if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); + + // A write to an ffmpeg that has already died must come back as an error + // to report, not as a signal that takes the whole program down with it. + static std::once_flag ignorePipe; + std::call_once(ignorePipe, [] { ::signal(SIGPIPE, SIG_IGN); }); + + std::vector args = { + "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "rawvideo", "-pix_fmt", "rgb24", + "-s", std::to_string(width) + "x" + std::to_string(height), + "-r", std::to_string(std::clamp(fps, kMinFps, kMaxFps)), + "-i", "pipe:0", "-an", + }; + 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", + "-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", + "-row-mt", "1"}); + } else { + args.insert(args.end(), {"-c:v", "libx264", "-preset", "medium", "-crf", "18"}); + } + args.insert(args.end(), {"-pix_fmt", "yuv420p"}); + if (muxer == "mp4" || muxer == "mov") args.insert(args.end(), {"-movflags", "+faststart"}); + } + // "file:" keeps a name that starts with a dash, or holds a colon, a file. + args.insert(args.end(), {"-f", muxer, "file:" + path}); + + int pipeEnds[2] = {-1, -1}; + if (::pipe2(pipeEnds, O_CLOEXEC) != 0) { + throw EffectError(std::string("Could not start ffmpeg: ") + std::strerror(errno)); + } + errors_ = std::tmpfile(); + if (errors_ != nullptr) ::fcntl(fileno(errors_), F_SETFD, FD_CLOEXEC); + + posix_spawn_file_actions_t actions; + posix_spawn_file_actions_init(&actions); + posix_spawn_file_actions_adddup2(&actions, pipeEnds[0], STDIN_FILENO); + if (errors_ != nullptr) posix_spawn_file_actions_adddup2(&actions, fileno(errors_), STDERR_FILENO); + + std::vector argv; + for (auto& arg : args) argv.push_back(arg.data()); + argv.push_back(nullptr); + + pid_t child = -1; + const int spawned = ::posix_spawnp(&child, "ffmpeg", &actions, nullptr, argv.data(), environ); + posix_spawn_file_actions_destroy(&actions); + ::close(pipeEnds[0]); + if (spawned != 0) { + ::close(pipeEnds[1]); + if (errors_ != nullptr) std::fclose(errors_); + errors_ = nullptr; + if (spawned == ENOENT) { + throw EffectError("Writing a video needs ffmpeg, which is not installed or not on " + "the PATH."); + } + throw EffectError(std::string("Could not start ffmpeg: ") + std::strerror(spawned)); + } + child_ = child; + input_ = pipeEnds[1]; +} + +VideoWriter::~VideoWriter() { + if (child_ > 0) cancel(); + if (errors_ != nullptr) std::fclose(errors_); +} + +void VideoWriter::write(const Image& frame) { + if (input_ < 0) throw EffectError("The video has already been closed."); + if (frame.width() != width_ || frame.height() != height_) { + throw EffectError("Every frame of a video must be the same size."); + } + const std::uint8_t* data = frame.data(); + std::size_t left = frame.byteSize(); + while (left > 0) { + const ssize_t written = ::write(input_, data, left); + if (written < 0) { + if (errno == EINTR) continue; + failed("ffmpeg stopped taking frames"); + } + data += written; + left -= static_cast(written); + } +} + +int VideoWriter::wait() { + int status = 0; + while (::waitpid(child_, &status, 0) < 0) { + if (errno != EINTR) { + status = -1; + break; + } + } + child_ = -1; + return status; +} + +void VideoWriter::finish() { + if (child_ <= 0) return; + ::close(input_); + input_ = -1; + const int status = wait(); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + std::error_code ignored; + std::filesystem::remove(path_, ignored); + std::string message = "ffmpeg could not write the video"; + const std::string said = errorOutput(); + if (!said.empty()) message += ": " + said; + throw EffectError(message); + } +} + +void VideoWriter::cancel() { + if (input_ >= 0) { + ::close(input_); + input_ = -1; + } + if (child_ > 0) { + // Not SIGTERM: ffmpeg takes that as "finish up", and flushing a slow + // encoder can take many seconds for a file that is thrown away. + ::kill(child_, SIGKILL); + wait(); + } + std::error_code ignored; + std::filesystem::remove(path_, ignored); +} + +void VideoWriter::failed(const std::string& what) { + cancel(); + std::string message = what; + const std::string said = errorOutput(); + if (!said.empty()) message += ": " + said; + throw EffectError(message); +} + +// The last thing ffmpeg said, which is where it names the problem. +std::string VideoWriter::errorOutput() const { + if (errors_ == nullptr) return {}; + std::string text; + std::rewind(errors_); + char buffer[4096]; + std::size_t read = 0; + while ((read = std::fread(buffer, 1, sizeof buffer, errors_)) > 0) text.append(buffer, read); + while (!text.empty() && (text.back() == '\n' || text.back() == '\r' || text.back() == ' ')) { + text.pop_back(); + } + const auto lastLine = text.rfind('\n'); + if (lastLine != std::string::npos) text.erase(0, lastLine + 1); + return text; +} + +} // namespace effector diff --git a/src/core/video.hpp b/src/core/video.hpp new file mode 100644 index 0000000..12e6e4e --- /dev/null +++ b/src/core/video.hpp @@ -0,0 +1,104 @@ +#pragma once + +#include + +#include +#include +#include + +#include "core/image.hpp" + +namespace effector { + +// The frame rates and lengths both front-ends accept. Five minutes at sixty +// frames is 18000 frames -- already a long wait -- and a tenth of a second is +// the shortest clip that still has a few frames in it. +inline constexpr int kMinFps = 1; +inline constexpr int kMaxFps = 60; +inline constexpr int kDefaultFps = 30; +inline constexpr double kMinDuration = 0.1; +inline constexpr double kMaxDuration = 300.0; +inline constexpr double kDefaultDuration = 10.0; + +// Whether a project is a video, and how long and how fast. A still project +// carries the other fields too, so switching video off and on again gives +// the same clip back. +struct VideoSettings { + bool enabled = false; + double duration = kDefaultDuration; // seconds + int fps = kDefaultFps; + // The preset the settings came from, empty for the user's own. Like a + // canvas format it is the presentation of the numbers, never the truth: + // the render goes by duration and fps. + std::string preset; + + // The frames the clip has: the length at the rate, never fewer than one. + int frameCount() const; +}; + +// A named kind of clip both front-ends offer: a canvas and a length that go +// together, so "Instagram story" is one choice rather than three. +struct VideoPreset { + std::string key; // written in a project and given to --video-preset + std::string label; // shown in the interface + std::string canvasFormat; // a key of canvasFormats() + double seconds = 0.0; + int fps = kDefaultFps; +}; + +// In the order they are offered. +const std::vector& videoPresets(); + +// Null when the key names no preset -- an empty or unknown key is "custom". +const VideoPreset* videoPresetByKey(const std::string& key); + +// "1080 × 1920 px · 15 s", for the dropdown and for --help. +std::string videoPresetDetail(const VideoPreset& preset); + +// "15 s", "7.5 s": a length as the interface and --help show it. +std::string formatSeconds(double seconds); + +// Whether a file name asks for a video: .mp4, .mov, .mkv, .webm or .gif. +bool isVideoPath(const std::string& path); + +// Whether an `ffmpeg` the writer below can run is on the PATH. +bool ffmpegAvailable(); + +// Encodes frames into a video file by piping them, raw, into the `ffmpeg` +// 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 +// ones the seed decides. +// +// Throws EffectError when ffmpeg is missing, fails, or quits early, with what +// it had to say. A writer that is neither finished nor cancelled cancels +// 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); + ~VideoWriter(); + VideoWriter(const VideoWriter&) = delete; + VideoWriter& operator=(const VideoWriter&) = delete; + + // Every frame must be the size the writer was opened with. + void write(const Image& frame); + // Closes the stream, waits for ffmpeg and throws if it failed. + void finish(); + // Stops ffmpeg and removes the unfinished file. + void cancel(); + +private: + void failed(const std::string& what); // cancels, then throws with ffmpeg's words + std::string errorOutput() const; + int wait(); + + std::string path_; + int width_ = 0; + int height_ = 0; + pid_t child_ = -1; + int input_ = -1; // the write end of ffmpeg's stdin + std::FILE* errors_ = nullptr; // ffmpeg's stderr, read back on failure +}; + +} // namespace effector diff --git a/src/effects/channelsplit.cpp b/src/effects/channelsplit.cpp index 802018e..5e3630b 100644 --- a/src/effects/channelsplit.cpp +++ b/src/effects/channelsplit.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include "core/parallel.hpp" @@ -22,6 +24,26 @@ const std::vector& ChannelSplitEffect::params() const { "rgb moves all three channels apart; red_cyan moves red one way and " "green and blue the other, as 3D glasses would; random moves one " "channel picked at random."), + ParamSpec::makeInt("split_wobble", "Wobble", 4, 0, 256, + "How far in pixels the channels sway back and forth over time.") + .forVideo(), + ParamSpec::makeDouble("split_wobble_speed", "Wobble speed", 0.5, 0.0, 10.0, + "Sways per second, rounded so that the clip loops without a jump.", + 0.1, 1) + .forVideo(), + ParamSpec::makeInt("split_jitter", "Jitter", 2, 0, 256, + "Furthest in pixels the channels shake, anew on every frame and, in " + "per_band mode, for every band.") + .forVideo(), + ParamSpec::makeInt("split_roll", "Tracking bar", 16, 0, 512, + "How far in pixels a bar rolling down the picture tears the rows " + "sideways and the channels apart; 0 for none.") + .forVideo(), + ParamSpec::makeDouble("split_roll_time", "Bar pass", 4.0, 0.5, 60.0, + "Seconds the tracking bar takes from top to bottom, rounded so " + "that the clip loops without a jump.", + 0.5, 1) + .forVideo(), }; return specs; } @@ -35,28 +57,68 @@ struct Offset { using ChannelOffsets = std::array; -ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels) { +ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels, int& moved) { ChannelOffsets offsets{}; auto draw = [&] { return Offset{static_cast(rng.inRange(-amount, amount)), static_cast(rng.inRange(-amount, amount))}; }; + moved = -1; if (channels == "red_cyan") { const Offset red = draw(); offsets[0] = red; offsets[1] = offsets[2] = Offset{-red.dx, -red.dy}; } else if (channels == "random") { - offsets[rng.below(2)] = draw(); + moved = static_cast(rng.below(2)); + offsets[static_cast(moved)] = draw(); } else { for (auto& offset : offsets) offset = draw(); } return offsets; } -} // namespace +// A motion of each channel, handed out the way the mode moves channels: all +// three on their own, green and blue opposite red, or only the one channel +// that was picked -- so the motion never moves a channel the still left alone. +using Motion = std::array; -Image ChannelSplitEffect::render(const std::vector& sources, int width, int height, Rng& rng, - const ParamValues& values) const { +Motion spread(const Motion& motion, const std::string& channels, int moved) { + if (channels == "red_cyan") return {motion[0], -motion[0], -motion[0]}; + if (moved >= 0) { + Motion only{}; + only[static_cast(moved)] = motion[0]; + return only; + } + return motion; +} + +double unit(Rng& rng) { return static_cast(rng.next() >> 11) * 0x1.0p-53; } + +struct MotionSettings { + int wobble = 0; + double wobbleSpeed = 0.0; + int jitter = 0; + int roll = 0; + double rollTime = 1.0; + + explicit MotionSettings(const ParamValues& values) + : wobble(static_cast(std::max(0L, paramInt(values, "split_wobble", 4)))), + wobbleSpeed(std::max(0.0, paramDouble(values, "split_wobble_speed", 0.5))), + jitter(static_cast(std::max(0L, paramInt(values, "split_jitter", 2)))), + roll(static_cast(std::max(0L, paramInt(values, "split_roll", 16)))), + rollTime(std::max(0.5, paramDouble(values, "split_roll_time", 4.0))) {} + + bool moves() const { return (wobble > 0 && wobbleSpeed > 0.0) || jitter > 0 || roll > 0; } +}; + +// Rounds a rate so a whole number of cycles fits the clip, never fewer than +// one: the last frame then leads straight back into the first. +double loopRate(double perSecond, double clipSeconds) { + return std::max(1.0, std::round(perSecond * clipSeconds)) / clipSeconds; +} + +Image renderSplit(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values, const FrameTime& frame) { if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); const int amount = static_cast(std::max(0L, paramInt(values, "split_amount", 12))); @@ -71,20 +133,105 @@ Image ChannelSplitEffect::render(const std::vector& sources, int width, i const int band = perBand ? bandHeight : height; const int bandCount = (height + band - 1) / band; std::vector plan; + std::vector moved; plan.reserve(static_cast(bandCount)); - for (int i = 0; i < bandCount; ++i) plan.push_back(drawOffsets(rng, amount, channels)); + moved.reserve(static_cast(bandCount)); + for (int i = 0; i < bandCount; ++i) { + int only = -1; + plan.push_back(drawOffsets(rng, amount, channels, only)); + moved.push_back(only); + } + + // Every row's offsets, and a sideways shift of the whole row. A still is + // just the band plan; a frame of a video adds the motion to it. + std::vector rows(static_cast(height)); + std::vector shift(static_cast(height), 0); + for (int y = 0; y < height; ++y) rows[static_cast(y)] = plan[static_cast(y / band)]; + + const MotionSettings motion(values); + if (frame.video() && motion.moves()) { + // The same draws on every frame, after the still's: where each + // channel starts its sway and which way it sways. The shake is the + // one thing that differs between frames, so it comes from a generator + // of the frame's own, seeded by one more draw -- the shared rng leaves + // every frame in the same state, and the stages after this one hold + // still while it moves. + std::array phase{}; + std::array angle{}; + for (int c = 0; c < 3; ++c) { + phase[static_cast(c)] = unit(rng) * 2.0 * std::numbers::pi; + angle[static_cast(c)] = unit(rng) * 2.0 * std::numbers::pi; + } + Rng shake(rng.next() ^ (0x9e3779b97f4a7c15ULL * static_cast(frame.index + 1))); + + const double clip = static_cast(std::max(1, frame.count)) / frame.fps; + const double t = frame.seconds(); + + Motion swayX{}; + Motion swayY{}; + if (motion.wobble > 0 && motion.wobbleSpeed > 0.0) { + const double rate = loopRate(motion.wobbleSpeed, clip); + for (std::size_t c = 0; c < 3; ++c) { + const double sway = motion.wobble * std::sin(2.0 * std::numbers::pi * rate * t + phase[c]); + swayX[c] = sway * std::cos(angle[c]); + swayY[c] = sway * std::sin(angle[c]); + } + } + + // The tracking bar: an eighth of the picture, soft at both edges, + // entering above the top and leaving below the bottom. + const double barHeight = std::max(8.0, height / 8.0); + const double passes = loopRate(1.0 / motion.rollTime, clip); + const double along = std::fmod(t * passes, 1.0); + const double barTop = -barHeight + along * (height + barHeight); + + for (int b = 0; b < bandCount; ++b) { + Motion jitterX{}; + Motion jitterY{}; + if (motion.jitter > 0) { + for (std::size_t c = 0; c < 3; ++c) { + jitterX[c] = static_cast(shake.inRange(-motion.jitter, motion.jitter)); + // Mostly sideways, as a picture losing its horizontal hold. + jitterY[c] = static_cast(shake.inRange(-motion.jitter, motion.jitter)) / 3.0; + } + } + const int only = moved[static_cast(b)]; + const Motion dx = spread({swayX[0] + jitterX[0], swayX[1] + jitterX[1], swayX[2] + jitterX[2]}, + channels, only); + const Motion dy = spread({swayY[0] + jitterY[0], swayY[1] + jitterY[1], swayY[2] + jitterY[2]}, + channels, only); + // The bar pulls red one way and blue the other (or the moved + // channel alone), whatever the sway is doing. + const Motion apart = spread({1.0, 0.0, -1.0}, channels, only); + + const int end = std::min(height, (b + 1) * band); + for (int y = b * band; y < end; ++y) { + double bar = 0.0; + if (motion.roll > 0 && y >= barTop && y < barTop + barHeight) { + bar = motion.roll * std::sin(std::numbers::pi * (y - barTop) / barHeight); + } + auto& offsets = rows[static_cast(y)]; + for (std::size_t c = 0; c < 3; ++c) { + offsets[c].dx += static_cast(std::lround(dx[c] + bar * apart[c])); + offsets[c].dy += static_cast(std::lround(dy[c])); + } + shift[static_cast(y)] = static_cast(std::lround(bar / 2.0)); + } + } + } Image canvas(width, height); parallelFor(static_cast(height), [&](std::size_t begin, std::size_t end) { for (std::size_t y = begin; y < end; ++y) { - const ChannelOffsets& offsets = plan[y / static_cast(band)]; + const ChannelOffsets& offsets = rows[y]; std::uint8_t* out = canvas.row(static_cast(y)); for (int c = 0; c < 3; ++c) { const Offset& offset = offsets[static_cast(c)]; + const int dx = offset.dx + shift[y]; const int sy = std::clamp(static_cast(y) - offset.dy, 0, height - 1); const std::uint8_t* in = input.row(sy); for (int x = 0; x < width; ++x) { - const int sx = std::clamp(x - offset.dx, 0, width - 1); + const int sx = std::clamp(x - dx, 0, width - 1); out[static_cast(x) * 3 + c] = in[static_cast(sx) * 3 + c]; } } @@ -94,4 +241,21 @@ Image ChannelSplitEffect::render(const std::vector& sources, int width, i return canvas; } +} // namespace + +bool ChannelSplitEffect::animated(const ParamValues& values) const { + return MotionSettings(values).moves(); +} + +Image ChannelSplitEffect::render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const { + return renderSplit(sources, width, height, rng, values, FrameTime{}); +} + +Image ChannelSplitEffect::renderFrame(const std::vector& sources, int width, int height, + Rng& rng, const ParamValues& values, + const FrameTime& frame) const { + return renderSplit(sources, width, height, rng, values, frame); +} + } // namespace effector diff --git a/src/effects/channelsplit.hpp b/src/effects/channelsplit.hpp index 12537e8..94ca873 100644 --- a/src/effects/channelsplit.hpp +++ b/src/effects/channelsplit.hpp @@ -6,6 +6,9 @@ namespace effector { // Knocks the red, green and blue planes out of register, either the whole image // at once or band by band, each band with offsets of its own. +// In a video the channels do not sit still: they sway, shake from frame to +// frame, and a tracking bar rolls down the picture as on a worn tape or an old +// tube. A still picture is rendered exactly as it always was. // A pure transform: it needs no source images, and leading a chain with // sources loaded it starts from a crop of a randomly chosen one. class ChannelSplitEffect : public Effect { @@ -18,9 +21,12 @@ public: } const std::vector& params() const override; bool needsSources() const override { return false; } + bool animated(const ParamValues& values) const override; Image render(const std::vector& sources, int width, int height, Rng& rng, const ParamValues& values) const override; + Image renderFrame(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values, const FrameTime& frame) const override; }; } // namespace effector diff --git a/src/gui/window.cpp b/src/gui/window.cpp index c259e83..05bd237 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -14,6 +14,7 @@ #include "core/registry.hpp" #include "core/rng.hpp" #include "core/version.hpp" +#include "core/video.hpp" namespace effector { namespace { @@ -42,6 +43,20 @@ Glib::RefPtr toTexture(const Image& image) { bytes, image.stride()); } +// The most memory a preview clip may take, all frames together. The frames +// are shrunk to fit, so a long clip is previewed smaller rather than not at +// all; the longer side stays between the two bounds below. +constexpr double kClipBudgetBytes = 256.0 * 1024 * 1024; +constexpr int kClipMinSide = 160; +constexpr int kClipMaxSide = 720; + +int clipSide(int width, int height, int frames) { + const double full = 3.0 * width * height * std::max(1, frames); + const double scale = std::min(1.0, std::sqrt(kClipBudgetBytes / full)); + const int side = static_cast(std::max(width, height) * scale); + return std::clamp(side, kClipMinSide, kClipMaxSide); +} + Gtk::Label* sectionLabel(const char* text) { auto* label = Gtk::make_managed(text); label->set_xalign(0.0F); @@ -97,6 +112,9 @@ EffectorWindow::EffectorWindow(const Options& initial) { renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished)); loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress)); loadFinished_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadFinished)); + clipProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onClipProgress)); + exportProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportProgress)); + exportDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportFinished)); buildUi(); applyInitial(initial); @@ -116,9 +134,13 @@ EffectorWindow::~EffectorWindow() { chainSplitFit_.disconnect(); recentChanged_.disconnect(); loadWindowDelay_.disconnect(); + playTimer_.disconnect(); loadCancel_.store(true, std::memory_order_relaxed); + renderCancel_.store(true, std::memory_order_relaxed); + exportCancel_.store(true, std::memory_order_relaxed); if (loader_.joinable()) loader_.join(); if (worker_.joinable()) worker_.join(); + if (exporter_.joinable()) exporter_.join(); // cancelled: removes its half-written file } // ------------------------------------------------------------------- UI --- @@ -334,6 +356,8 @@ Gtk::Widget& EffectorWindow::buildSetupTab() { updateSourcesLabel(); tab->append(sourcesSection_); + tab->append(buildVideoSection()); + tab->append(*sectionLabel("Canvas")); tab->append(buildCanvasSection()); @@ -392,6 +416,8 @@ Gtk::Widget& EffectorWindow::buildCanvasSection() { widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide); widthSpin_.set_increments(1, 100); widthSpin_.set_value(1920); + widthSpin_.set_name("canvas-width"); + heightSpin_.set_name("canvas-height"); heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide); heightSpin_.set_increments(1, 100); heightSpin_.set_value(1080); @@ -484,6 +510,220 @@ void EffectorWindow::syncCanvasWidgets() { if (!quiet) onParamChanged(); } +// ---------------------------------------------------------------- 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, +// then every preset with what it amounts to. +Gtk::Widget& EffectorWindow::buildVideoSection() { + auto* box = Gtk::make_managed(Gtk::Orientation::VERTICAL, 6); + + auto* heading = Gtk::make_managed(Gtk::Orientation::HORIZONTAL, 8); + auto* label = sectionLabel("Video"); + label->set_hexpand(true); + heading->append(*label); + videoSwitch_.set_name("video"); + videoSwitch_.set_valign(Gtk::Align::CENTER); + videoSwitch_.set_tooltip_text("Make a video clip rather than a still picture"); + videoSwitch_.property_active().signal_changed().connect( + sigc::mem_fun(*this, &EffectorWindow::onVideoToggled)); + heading->append(videoSwitch_); + box->append(*heading); + + videoDetails_.set_orientation(Gtk::Orientation::VERTICAL); + videoDetails_.set_spacing(6); + + // Index 0 is "Custom"; every later index is videoPresets()[index - 1]. + std::vector labels{"Custom"}; + for (const auto& preset : videoPresets()) { + labels.push_back(preset.label + " (" + videoPresetDetail(preset) + ")"); + } + videoPresetDrop_.set_name("video-preset"); + videoPresetDrop_.set_model(Gtk::StringList::create(labels)); + videoPresetDrop_.set_selected(0); + videoPresetDrop_.set_tooltip_text("A canvas and a length made for one place a clip is shown"); + videoPresetDrop_.property_selected().signal_changed().connect( + sigc::mem_fun(*this, &EffectorWindow::onVideoPresetSelected)); + videoDetails_.append(videoPresetDrop_); + + auto* grid = Gtk::make_managed(); + grid->set_column_spacing(8); + grid->set_row_spacing(6); + durationSpin_.set_name("video-duration"); + durationSpin_.set_range(kMinDuration, kMaxDuration); + durationSpin_.set_increments(0.5, 5.0); + durationSpin_.set_digits(1); + durationSpin_.set_value(kDefaultDuration); + fpsSpin_.set_name("video-fps"); + fpsSpin_.set_range(kMinFps, kMaxFps); + fpsSpin_.set_increments(1, 5); + fpsSpin_.set_value(kDefaultFps); + for (auto* spin : {&durationSpin_, &fpsSpin_}) { + spin->set_hexpand(true); + scrollPastUnlessFocused(*spin); + spin->signal_value_changed().connect( + sigc::mem_fun(*this, &EffectorWindow::onVideoTimingChanged)); + } + auto* durationLabel = Gtk::make_managed("Length (s)"); + durationLabel->set_xalign(0.0F); + auto* fpsLabel = Gtk::make_managed("Frames/s"); + fpsLabel->set_xalign(0.0F); + grid->attach(*durationLabel, 0, 0); + grid->attach(durationSpin_, 1, 0); + grid->attach(*fpsLabel, 0, 1); + grid->attach(fpsSpin_, 1, 1); + videoDetails_.append(*grid); + videoDetails_.set_visible(false); + box->append(videoDetails_); + return *box; +} + +VideoSettings EffectorWindow::videoSettings() const { + VideoSettings video; + video.enabled = isVideo(); + video.duration = durationSpin_.get_value(); + video.fps = static_cast(fpsSpin_.get_value()); + const auto selected = videoPresetDrop_.get_selected(); + const auto& presets = videoPresets(); + if (selected != GTK_INVALID_LIST_POSITION && selected > 0 && selected <= presets.size()) { + video.preset = presets[selected - 1].key; + } + return video; +} + +void EffectorWindow::setVideoSettings(const VideoSettings& video) { + const bool suspended = suspendVideoSync_; + suspendVideoSync_ = true; + durationSpin_.set_value(video.duration); + fpsSpin_.set_value(video.fps); + guint selected = 0; + const auto& presets = videoPresets(); + for (std::size_t i = 0; i < presets.size(); ++i) { + if (presets[i].key == video.preset) selected = static_cast(i + 1); + } + videoPresetDrop_.set_selected(selected); + videoSwitch_.set_active(video.enabled); + videoDetails_.set_visible(video.enabled); + suspendVideoSync_ = suspended; + playbackBar_.set_visible(false); + clearClip(); + buildParamWidgets(); // the motion params come and go with the switch +} + +void EffectorWindow::onVideoToggled() { + videoDetails_.set_visible(isVideo()); + if (suspendVideoSync_) return; + // What was on screen was the other kind of result, and cannot be saved as + // this one. + result_ = Image(); + clearClip(); + saveButton_.set_sensitive(false); + buildParamWidgets(); + requestRender(); +} + +// A preset decides the canvas too, so picking one fills in the format as well +// as the timing -- one render for all of it. +void EffectorWindow::onVideoPresetSelected() { + if (suspendVideoSync_) return; + const auto* preset = videoPresetByKey(videoSettings().preset); + if (preset == nullptr) return; // "Custom": leave the numbers as they are + suspendVideoSync_ = true; + durationSpin_.set_value(preset->seconds); + fpsSpin_.set_value(preset->fps); + setCanvasFormat(preset->canvasFormat, static_cast(dpiSpin_.get_value())); + suspendVideoSync_ = false; + requestRender(); +} + +void EffectorWindow::onVideoTimingChanged() { + if (suspendVideoSync_) return; + matchVideoPreset(); + requestRender(); +} + +void EffectorWindow::matchVideoPreset() { + if (suspendVideoSync_) return; + const auto* preset = videoPresetByKey(videoSettings().preset); + if (preset == nullptr) return; + const bool same = std::abs(durationSpin_.get_value() - preset->seconds) < 1e-9 && + static_cast(fpsSpin_.get_value()) == preset->fps && + canvasFormatKey() == preset->canvasFormat; + if (same) return; + suspendVideoSync_ = true; + videoPresetDrop_.set_selected(0); + suspendVideoSync_ = false; +} + +// Frames come over from the worker a few at a time and are turned into +// textures here, on the main thread. The clip on screen keeps playing until +// the new one is complete; the very first one shows its frames as they come. +void EffectorWindow::onClipProgress() { + std::vector frames; + { + std::lock_guard lock(resultMutex_); + frames.swap(pendingFrames_); + } + if (!renderingVideo_) return; // left over from a render that was dropped + for (const auto& frame : frames) incomingClip_.push_back(toTexture(frame)); + if (frames.empty()) return; + + if (clip_.empty()) { + picture_.set_paintable(incomingClip_.back()); + previewStack_.set_visible_child("picture"); + } + status_.set_text("Rendering preview · frame " + std::to_string(incomingClip_.size()) + " of " + + std::to_string(incomingTotal_)); +} + +void EffectorWindow::showClipFrame(int index) { + if (clip_.empty()) return; + playFrame_ = std::clamp(index, 0, static_cast(clip_.size()) - 1); + picture_.set_paintable(clip_[static_cast(playFrame_)]); + suspendScrub_ = true; + playbackScale_.set_value(playFrame_); + suspendScrub_ = false; + playbackTime_.set_text(formatSeconds(static_cast(playFrame_) / clipFps_) + " / " + + formatSeconds(static_cast(clip_.size()) / clipFps_)); +} + +void EffectorWindow::startPlayback() { + playTimer_.disconnect(); + if (clip_.empty()) return; + playbackScale_.set_range(0, std::max(1, static_cast(clip_.size()) - 1)); + playbackBar_.set_visible(true); + showClipFrame(playFrame_); + if (!playing_) return; + playTimer_ = Glib::signal_timeout().connect( + [this] { + showClipFrame((playFrame_ + 1) % static_cast(clip_.size())); + return true; + }, + static_cast(std::max(1, 1000 / std::max(1, clipFps_)))); +} + +void EffectorWindow::stopPlayback() { playTimer_.disconnect(); } + +void EffectorWindow::setPlaying(bool playing) { + playing_ = playing; + playButton_.set_icon_name(playing ? "media-playback-pause-symbolic" + : "media-playback-start-symbolic"); + playButton_.set_tooltip_text(playing ? "Pause" : "Play"); + if (playing) { + startPlayback(); + } else { + stopPlayback(); + } +} + +void EffectorWindow::clearClip() { + stopPlayback(); + clip_.clear(); + incomingClip_.clear(); + playFrame_ = 0; + playbackBar_.set_visible(false); +} + Gtk::Widget& EffectorWindow::buildPreview() { picture_.set_can_shrink(true); picture_.set_content_fit(Gtk::ContentFit::CONTAIN); @@ -516,6 +756,37 @@ Gtk::Widget& EffectorWindow::buildPreview() { status_.set_margin_top(6); status_.set_margin_bottom(8); status_.add_css_class("dim-label"); + + // Play and pause, and a scale to look at any one frame; only a video has it. + playbackBar_.set_name("playback"); + playbackBar_.set_orientation(Gtk::Orientation::HORIZONTAL); + playbackBar_.set_spacing(8); + playbackBar_.set_margin_start(8); + playbackBar_.set_margin_end(8); + playbackBar_.set_margin_top(6); + playButton_.set_icon_name("media-playback-pause-symbolic"); + playButton_.set_tooltip_text("Pause"); + playButton_.signal_clicked().connect([this] { setPlaying(!playing_); }); + playbackBar_.append(playButton_); + playbackScale_.set_orientation(Gtk::Orientation::HORIZONTAL); + playbackScale_.set_hexpand(true); + playbackScale_.set_draw_value(false); + playbackScale_.set_range(0, 1); + playbackScale_.set_increments(1, 10); + playbackScale_.set_tooltip_text("Drag to look at one frame"); + playbackScale_.signal_value_changed().connect([this] { + if (suspendScrub_) return; + // Looking at a frame means holding it: playback stops there. + if (playing_) setPlaying(false); + showClipFrame(static_cast(std::lround(playbackScale_.get_value()))); + }); + playbackBar_.append(playbackScale_); + playbackTime_.add_css_class("dim-label"); + playbackTime_.add_css_class("numeric"); + playbackBar_.append(playbackTime_); + playbackBar_.set_visible(false); + box->append(playbackBar_); + box->append(status_); return *box; } @@ -557,6 +828,9 @@ void EffectorWindow::buildParamWidgets() { } for (const auto& spec : stage.effect->params()) { + // How a stage moves means nothing in a still, so it is left out there; + // the values stay with the stage for when the project becomes a video. + if (spec.videoOnly && !isVideo()) continue; // A styled param is read from the style while one is followed, so the // widgets show what the stage will actually render with. const bool shared = style != nullptr && spec.inStyle; @@ -1011,6 +1285,7 @@ void EffectorWindow::applyInitial(const Options& initial) { widthSpin_.set_value(initial.width); heightSpin_.set_value(initial.height); setCanvasFormat(initial.format, initial.dpi); // a format fills the spins itself + setVideoSettings(initial.video); if (initial.seed) { seed_ = *initial.seed; seedEntry_.set_text(std::to_string(seed_)); @@ -1689,6 +1964,7 @@ void EffectorWindow::onParamChanged() { // styled one can rename every stage sharing the style. for (std::size_t i = 0; i < stageLabels_.size() && i < stages_.size(); ++i) stageLabels_[i]->set_text(stageLabel(i)); + matchVideoPreset(); // the canvas may have left the preset's format requestRender(); } @@ -1758,6 +2034,7 @@ std::string EffectorWindow::missingText() const { void EffectorWindow::showPlaceholder() { result_ = Image(); + clearClip(); saveButton_.set_sensitive(false); placeholder_.set_text(missingText()); previewStack_.set_visible_child("placeholder"); @@ -1786,9 +2063,13 @@ void EffectorWindow::startRender() { if (!canRender()) return; if (rendering_) { staleResult_ = true; // a fresh render follows this one + // A clip takes seconds; there is no point finishing one whose + // settings are already out of date. + renderCancel_.store(true, std::memory_order_relaxed); return; } rendering_ = true; + renderCancel_.store(false, std::memory_order_relaxed); const auto sources = sources_; const auto stages = stages_; // a snapshot, like the sources @@ -1796,17 +2077,46 @@ void EffectorWindow::startRender() { const int width = static_cast(widthSpin_.get_value()); const int height = static_cast(heightSpin_.get_value()); const std::uint64_t seed = seed_; + const VideoSettings video = videoSettings(); spinner_.start(); recreateButton_.set_sensitive(false); - worker_ = std::thread([this, sources, stages, styles, width, height, seed] { + renderingVideo_ = video.enabled; + if (video.enabled) { + { + std::lock_guard lock(resultMutex_); + pendingFrames_.clear(); + } + incomingClip_.clear(); + incomingTotal_ = video.frameCount(); + status_.set_text("Rendering preview…"); + } + + worker_ = std::thread([this, sources, stages, styles, width, height, seed, video] { Image image; std::string error; const auto start = std::chrono::steady_clock::now(); try { - Rng rng(seed); - image = renderChain(stages, *sources, width, height, rng, styles); + if (video.enabled) { + // Rendered at the full size, so the preview shows what the + // file will hold, and shrunk only to be kept. + const int side = clipSide(width, height, video.frameCount()); + renderFrames(stages, *sources, width, height, seed, styles, video, + [this, side](int, Image&& frame) { + if (renderCancel_.load(std::memory_order_relaxed)) return false; + Image small = frame.scaledToFit(side); + { + std::lock_guard lock(resultMutex_); + pendingFrames_.push_back(std::move(small)); + } + clipProgress_.emit(); + return true; + }); + } else { + Rng rng(seed); + image = renderChain(stages, *sources, width, height, rng, styles); + } } catch (const std::exception& exception) { error = exception.what(); } @@ -1826,6 +2136,9 @@ void EffectorWindow::startRender() { void EffectorWindow::onRenderFinished() { if (worker_.joinable()) worker_.join(); rendering_ = false; + onClipProgress(); // whatever frames came in after the last wake-up + const bool wasVideo = renderingVideo_; + renderingVideo_ = false; Image image; std::string error; @@ -1839,6 +2152,7 @@ void EffectorWindow::onRenderFinished() { if (staleResult_) { // settings changed while this one was running staleResult_ = false; + incomingClip_.clear(); startRender(); return; } @@ -1848,25 +2162,45 @@ void EffectorWindow::onRenderFinished() { if (!error.empty()) { result_ = Image(); + clearClip(); saveButton_.set_sensitive(false); showError(error); return; } clearError(); - result_ = std::move(image); - picture_.set_paintable(toTexture(result_)); - previewStack_.set_visible_child("picture"); - saveButton_.set_sensitive(true); - - // The line itself stays short; the full chain and setup go in the tooltip. - const std::size_t effects = enabledStages(stages_).size(); std::ostringstream status; status.setf(std::ios::fixed); status.precision(1); - status << effects << (effects == 1 ? " effect" : " effects") << " · " << millis << " ms"; + const std::size_t effects = enabledStages(stages_).size(); + if (wasVideo) { + result_ = Image(); + clip_ = std::move(incomingClip_); + incomingClip_.clear(); + clipFps_ = videoSettings().fps; + if (playFrame_ >= static_cast(clip_.size())) playFrame_ = 0; + previewStack_.set_visible_child("picture"); + saveButton_.set_sensitive(!clip_.empty()); + startPlayback(); + status << clip_.size() << " frames · " << effects << (effects == 1 ? " effect" : " effects") + << " · " << millis / 1000.0 << " s"; + } else { + clearClip(); + result_ = std::move(image); + picture_.set_paintable(toTexture(result_)); + previewStack_.set_visible_child("picture"); + saveButton_.set_sensitive(true); + status << effects << (effects == 1 ? " effect" : " effects") << " · " << millis << " ms"; + } + + // The line itself stays short; the full chain and setup go in the tooltip. std::ostringstream details; - details << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · "; + details << chainTitle(stages_) << " · " << static_cast(widthSpin_.get_value()) << "×" + << static_cast(heightSpin_.get_value()) << " · "; + if (wasVideo) { + const auto video = videoSettings(); + details << formatSeconds(video.duration) << " at " << video.fps << " fps · "; + } if (!sources_->empty()) { details << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · "; } @@ -1945,6 +2279,7 @@ Project EffectorWindow::currentProject() const { project.height = static_cast(heightSpin_.get_value()); project.format = canvasFormatKey(); project.dpi = static_cast(dpiSpin_.get_value()); + project.video = videoSettings(); project.seed = seed_; return project; } @@ -1965,6 +2300,7 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat widthSpin_.set_value(project.width); heightSpin_.set_value(project.height); setCanvasFormat(project.format, project.dpi); + setVideoSettings(project.video); seed_ = project.seed.value_or(Rng::randomSeed()); seedEntry_.set_text(std::to_string(seed_)); @@ -1988,6 +2324,7 @@ void EffectorWindow::clearSources() { if (loading_) dropLoadedImages_ = true; result_ = Image(); + clearClip(); saveButton_.set_sensitive(false); previewStack_.set_visible_child("placeholder"); } @@ -2287,11 +2624,13 @@ void EffectorWindow::updateTitle() { // --------------------------------------------------------------- saving --- void EffectorWindow::onSave() { - if (result_.empty()) return; + const bool video = isVideo(); + if (video ? clip_.empty() || exporting_ : result_.empty()) return; auto dialog = Gtk::FileDialog::create(); - dialog->set_title("Save image"); - dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) + ".png"); + dialog->set_title(video ? "Save video" : "Save image"); + dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) + + (video ? ".mp4" : ".png")); dialog->save(*this, [this, dialog](const Glib::RefPtr& result) { onSaveChosen(result, dialog); }); @@ -2307,8 +2646,19 @@ void EffectorWindow::onSaveChosen(const Glib::RefPtr& result, } catch (const Glib::Error&) { return; // cancelled } - if (path.extension().empty()) path.replace_extension(".png"); + if (isVideo()) { + if (path.extension().empty()) path.replace_extension(".mp4"); + if (!isVideoPath(path.string())) { + showError("A video is saved as .mp4, .mov, .mkv, .webm or .gif, not " + + path.filename().string() + "."); + return; + } + startExport(path.string()); + return; + } + + if (path.extension().empty()) path.replace_extension(".png"); try { result_.save(path.string()); } catch (const std::exception& error) { @@ -2320,6 +2670,138 @@ void EffectorWindow::onSaveChosen(const Glib::RefPtr& result, status_.set_tooltip_text("Saved " + path.string()); } +// The preview keeps only small frames, so the file is rendered again at the +// full size, frame by frame, straight into ffmpeg. That takes a while, which +// is why it has a window of its own and can be cancelled. +void EffectorWindow::startExport(const std::string& path) { + if (exporting_) return; + if (exporter_.joinable()) exporter_.join(); + exporting_ = true; + exportCancel_.store(false, std::memory_order_relaxed); + exportFrames_.store(0, std::memory_order_relaxed); + exportPath_ = path; + exportError_.clear(); + saveButton_.set_sensitive(false); + + const auto sources = sources_; + const auto stages = stages_; + const auto styles = styles_; + const int width = static_cast(widthSpin_.get_value()); + const int height = static_cast(heightSpin_.get_value()); + const std::uint64_t seed = seed_; + const VideoSettings video = videoSettings(); + exportTotal_ = video.frameCount(); + + exportWindow_ = std::make_unique(); + exportWindow_->set_name("export"); + exportWindow_->set_title("Saving video"); + exportWindow_->set_transient_for(*this); + exportWindow_->set_modal(true); + exportWindow_->set_resizable(false); + exportWindow_->set_default_size(420, -1); + + auto* box = Gtk::make_managed(Gtk::Orientation::VERTICAL, 12); + box->set_margin(18); + auto* heading = Gtk::make_managed("Saving " + + std::filesystem::path(path).filename().string()); + heading->set_xalign(0.0F); + heading->set_ellipsize(Pango::EllipsizeMode::MIDDLE); + heading->add_css_class("heading"); + box->append(*heading); + auto* bar = Gtk::make_managed(); + bar->set_show_text(true); + bar->set_text("frame 0 of " + std::to_string(exportTotal_)); + box->append(*bar); + exportBar_ = bar; + auto* cancel = Gtk::make_managed("Cancel"); + cancel->set_halign(Gtk::Align::END); + cancel->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::cancelExport)); + box->append(*cancel); + // As with reading images: closing means Cancel, and the window stays up + // until the exporter has actually stopped and cleaned up. + exportWindow_->signal_close_request().connect( + [this] { + cancelExport(); + return true; + }, + false); + exportWindow_->set_child(*box); + exportWindow_->present(); + + exporter_ = std::thread([this, path, sources, stages, styles, width, height, seed, video] { + std::string error; + try { + VideoWriter writer(path, width, height, video.fps); + renderFrames(stages, *sources, width, height, seed, styles, video, + [this, &writer](int, Image&& frame) { + if (exportCancel_.load(std::memory_order_relaxed)) return false; + writer.write(frame); + exportFrames_.fetch_add(1, std::memory_order_relaxed); + exportProgress_.emit(); + return true; + }); + if (exportCancel_.load(std::memory_order_relaxed)) { + writer.cancel(); + } else { + writer.finish(); + } + } catch (const std::exception& exception) { + error = exception.what(); + } + { + std::lock_guard lock(resultMutex_); + exportError_ = std::move(error); + } + exportDone_.emit(); + }); +} + +void EffectorWindow::onExportProgress() { + if (exportBar_ == nullptr || exportCancel_.load(std::memory_order_relaxed)) return; + const int done = exportFrames_.load(std::memory_order_relaxed); + exportBar_->set_fraction(exportTotal_ > 0 ? static_cast(done) / exportTotal_ : 0.0); + exportBar_->set_text("frame " + std::to_string(done) + " of " + std::to_string(exportTotal_)); +} + +void EffectorWindow::onExportFinished() { + if (exporter_.joinable()) exporter_.join(); + exporting_ = false; + closeExportWindow(); + saveButton_.set_sensitive(isVideo() ? !clip_.empty() : !result_.empty()); + + std::string error; + { + std::lock_guard lock(resultMutex_); + error = exportError_; + } + if (!error.empty()) { + showError("Could not save the video: " + error); + return; + } + if (exportCancel_.load(std::memory_order_relaxed)) { + status_.set_text("Saving cancelled"); + status_.set_tooltip_text(""); + return; + } + clearError(); + status_.set_text("Saved " + exportPath_); + status_.set_tooltip_text("Saved " + exportPath_); +} + +void EffectorWindow::cancelExport() { + if (!exporting_) return; + exportCancel_.store(true, std::memory_order_relaxed); + if (exportBar_ != nullptr) exportBar_->set_text("Cancelling…"); +} + +void EffectorWindow::closeExportWindow() { + exportBar_ = nullptr; + if (!exportWindow_) return; + exportWindow_->set_hide_on_close(false); + exportWindow_->hide(); + exportWindow_.reset(); +} + // --------------------------------------------------------------- errors --- void EffectorWindow::showError(const std::string& message) { diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 4129737..5172eff 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -18,6 +18,7 @@ #include "core/image.hpp" #include "core/project.hpp" #include "core/style.hpp" +#include "core/video.hpp" namespace effector { @@ -87,6 +88,34 @@ private: void syncCanvasWidgets(); void removeSource(Gtk::ListBoxRow* row); + // Video: a switch that makes the project a clip, and its length, rate and + // preset. A preset owns the canvas format as well as the timing; changing + // any of them by hand drops it back to "Custom". + Gtk::Widget& buildVideoSection(); + VideoSettings videoSettings() const; + void setVideoSettings(const VideoSettings& video); // quietly, for a project + void onVideoToggled(); + void onVideoPresetSelected(); + void onVideoTimingChanged(); + void matchVideoPreset(); // "Custom" once the settings are no longer the preset's + bool isVideo() const { return videoSwitch_.get_active(); } + + // The preview of a video is the whole clip, rendered first and then looped. + void onClipProgress(); // dispatcher: frames arrived from the worker + void showClipFrame(int index); + void startPlayback(); + void stopPlayback(); + void setPlaying(bool playing); + void clearClip(); + + // Writing a video file: rendered again at full size, on a thread of its + // own, into ffmpeg, with a progress window that can cancel it. + void startExport(const std::string& path); + void onExportProgress(); + void onExportFinished(); + void cancelExport(); + void closeExportWindow(); + Gtk::Widget& buildChainSection(); Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended void appendStage(const EffectPtr& effect); @@ -205,6 +234,35 @@ private: Image pendingImage_; std::string pendingError_; double pendingMillis_ = 0.0; + // Set to stop a clip render early once its settings are out of date. + std::atomic renderCancel_{false}; + bool renderingVideo_ = false; // what the render in flight is making + + // The clip being previewed: small frames, ready to show, and the frames + // of the clip being rendered, which take its place once it is complete. + Glib::Dispatcher clipProgress_; + std::vector pendingFrames_; // handed over under resultMutex_ + std::vector> clip_; + std::vector> incomingClip_; + int incomingTotal_ = 0; + int clipFps_ = kDefaultFps; + int playFrame_ = 0; + bool playing_ = true; + sigc::connection playTimer_; + bool suspendScrub_ = false; // set while playback moves the scale itself + + // Video export + std::thread exporter_; + Glib::Dispatcher exportProgress_; + Glib::Dispatcher exportDone_; + std::atomic exportCancel_{false}; + std::atomic exportFrames_{0}; + int exportTotal_ = 0; + bool exporting_ = false; + std::string exportPath_; + std::string exportError_; // written by the exporter before exportDone_ + std::unique_ptr exportWindow_; + Gtk::ProgressBar* exportBar_ = nullptr; // nulled with the window // Widgets Gtk::Label banner_; @@ -224,6 +282,12 @@ private: Gtk::SpinButton heightSpin_; Gtk::SpinButton dpiSpin_; Gtk::Label* dpiLabel_ = nullptr; // hidden along with the dpi spin + Gtk::Switch videoSwitch_; + Gtk::Box videoDetails_; // hidden while the project is a still + Gtk::DropDown videoPresetDrop_; + Gtk::SpinButton durationSpin_; + Gtk::SpinButton fpsSpin_; + 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. bool suspendCanvasSync_ = false; @@ -245,6 +309,10 @@ private: Gtk::Label placeholder_; Gtk::Spinner spinner_; Gtk::Label status_; + Gtk::Box playbackBar_; // under the preview of a video only + Gtk::Button playButton_; + Gtk::Scale playbackScale_; + Gtk::Label playbackTime_; struct ParamWidget { ParamSpec spec; diff --git a/src/main.cpp b/src/main.cpp index f063dd9..0eb9e26 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,12 +13,55 @@ #include "core/image.hpp" #include "core/registry.hpp" #include "core/rng.hpp" +#include "core/video.hpp" #include "core/version.hpp" #include "gui/app.hpp" namespace { +int renderVideo(const effector::Options& options, const std::vector& stages, + const std::vector& sources, std::uint64_t seed) { + const int count = options.video.frameCount(); + 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::renderFrames(stages, sources, options.width, options.height, seed, {}, options.video, + [&](int index, effector::Image&& frame) { + writer.write(frame); + if (showProgress) { + std::cerr << "\r\033[Kframe " << index + 1 << "/" << count + << std::flush; + } + return true; + }); + if (showProgress) std::cerr << "\r\033[K" << std::flush; + writer.finish(); + const auto seconds = + std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + + std::cout << options.output << " " << options.width << "x" << options.height << " " + << count << " frames at " << options.video.fps << " fps effect=" + << effector::chainSlug(stages) << " seed=" << seed << " render=" << std::fixed + << std::setprecision(1) << seconds << " s\n"; + return 0; +} + int renderHeadless(const effector::Options& options) { + // Checked before anything is decoded: a mismatch between what was asked + // for and the file named is a typo, not something to find out later. + const bool videoFile = effector::isVideoPath(options.output); + if (options.video.enabled && !videoFile) { + std::cerr << "effector: a video is written as .mp4, .mov, .mkv, .webm or .gif, not " + << options.output << "\n"; + return 2; + } + if (!options.video.enabled && videoFile) { + std::cerr << "effector: " << options.output + << " is a video; give its length with --duration or --video-preset\n"; + return 2; + } + auto stages = effector::buildChain(options); // A headless run always renders something, so with no -e the default // effect stands in. The interface has no such need: it opens on an empty @@ -54,6 +97,7 @@ int renderHeadless(const effector::Options& options) { } const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed()); + if (options.video.enabled) return renderVideo(options, stages, sources, seed); effector::Rng rng(seed); const auto start = std::chrono::steady_clock::now(); diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 2267cf0..9b8359b 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -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(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 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 chain = {stage("tiles"), stage("channelsplit", wild), stage("grain")}; + VideoSettings video; + video.enabled = true; + video.duration = 1.0; + video.fps = 8; + + std::vector 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 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(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 steady = {stage("tiles"), stage("channelsplit", frozen), stage("grain")}; + std::vector 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 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 diff --git a/tests/gui_tests.cpp b/tests/gui_tests.cpp index f7a7fce..020268a 100644 --- a/tests/gui_tests.cpp +++ b/tests/gui_tests.cpp @@ -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(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::create(Glib::filename_to_uri(path.string()))); + pump(300); + + auto* stack = all(window).front(); + auto* toggle = named(window, "video"); + auto* preset = named(window, "video-preset"); + auto* duration = named(window, "video-duration"); + auto* width = named(window, "canvas-width"); + auto* height = named(window, "canvas-height"); + auto* playback = named(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(*playback).empty() ? nullptr : all(*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);