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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-26 11:22:01 +02:00
co-authored by Claude Opus 5.5
parent c069de3566
commit 385617781e
26 changed files with 1868 additions and 39 deletions
+51 -4
View File
@@ -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.