Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
385617781e | ||
|
|
c069de3566 |
@@ -4,6 +4,31 @@ 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
|
`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.
|
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
|
||||||
|
- Effector has its own icon, an E split into its colour channels, in the app
|
||||||
|
menu, the dock and About Effector; a one-colour version goes with it for
|
||||||
|
panels.
|
||||||
|
|
||||||
## [0.5.2] - 2026-09-26
|
## [0.5.2] - 2026-09-26
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -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
|
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
|
per-test filter: run `./build/tests/effector_tests` directly to see the section
|
||||||
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
|
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
|
out the other blocks in `tests/core_tests.cpp` or add a new
|
||||||
`std::cout << "section\n"` block — checks are plain
|
`std::cout << "section\n"` block — checks are plain
|
||||||
`check(condition, "description")` calls and `main()` returns the failure count.
|
`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
|
`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_`).
|
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
|
### Two link targets, deliberately
|
||||||
|
|
||||||
`effector_core` (static) holds image IO, params, effects, rng and links only
|
`effector_core` (static) holds image IO, params, effects, rng, video writing
|
||||||
`gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need
|
and links only `gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango
|
||||||
no display) + Threads — **no gtkmm, no display**. `effector_gui` (static) adds
|
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/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/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.
|
`src/core/` and `src/effects/`; the tests link the core alone and rely on this.
|
||||||
|
|||||||
+7
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
cmake_minimum_required(VERSION 3.20)
|
||||||
project(effector VERSION 0.5.2 LANGUAGES CXX)
|
project(effector VERSION 0.6.0 LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
@@ -40,6 +40,7 @@ add_library(effector_core STATIC
|
|||||||
src/core/registry.cpp
|
src/core/registry.cpp
|
||||||
src/core/rng.cpp
|
src/core/rng.cpp
|
||||||
src/core/style.cpp
|
src/core/style.cpp
|
||||||
|
src/core/video.cpp
|
||||||
src/effects/bitcrush.cpp
|
src/effects/bitcrush.cpp
|
||||||
src/effects/blur.cpp
|
src/effects/blur.cpp
|
||||||
src/effects/channelsplit.cpp
|
src/effects/channelsplit.cpp
|
||||||
@@ -70,6 +71,9 @@ add_library(effector_gui STATIC
|
|||||||
)
|
)
|
||||||
target_link_libraries(effector_gui PUBLIC effector_core PkgConfig::GTKMM)
|
target_link_libraries(effector_gui PUBLIC effector_core PkgConfig::GTKMM)
|
||||||
target_compile_options(effector_gui PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(effector_gui PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
# Where a build that was never installed finds its own icon (see app.cpp).
|
||||||
|
target_compile_definitions(effector_gui PRIVATE
|
||||||
|
EFFECTOR_SOURCE_ICON_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data/icons")
|
||||||
|
|
||||||
# --- application --------------------------------------------------------------
|
# --- application --------------------------------------------------------------
|
||||||
add_executable(effector src/main.cpp)
|
add_executable(effector src/main.cpp)
|
||||||
@@ -79,6 +83,8 @@ target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic)
|
|||||||
install(TARGETS effector RUNTIME DESTINATION bin)
|
install(TARGETS effector RUNTIME DESTINATION bin)
|
||||||
# The launcher entry, so the interface shows up in the desktop's app menu.
|
# The launcher entry, so the interface shows up in the desktop's app menu.
|
||||||
install(FILES data/org.effector.Effector.desktop DESTINATION share/applications)
|
install(FILES data/org.effector.Effector.desktop DESTINATION share/applications)
|
||||||
|
# The icon it names, in full colour and as a one-colour symbolic for panels.
|
||||||
|
install(DIRECTORY data/icons/hicolor DESTINATION share/icons)
|
||||||
|
|
||||||
# --- tests --------------------------------------------------------------------
|
# --- tests --------------------------------------------------------------------
|
||||||
include(CTest)
|
include(CTest)
|
||||||
|
|||||||
@@ -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
|
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`.
|
`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
|
```bash
|
||||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
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
|
format is saved with the project, so an A4 project reopens as A4 rather than as
|
||||||
the pixels it happened to be.
|
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
|
Any option can also be passed when launching the interface; it becomes the
|
||||||
starting state (images, canvas size, effect settings, seed).
|
starting state (images, canvas size, effect settings, seed).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||||
|
<!-- Effector: an E split into its colour channels, two slices slipped sideways -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2b2837"/>
|
||||||
|
<stop offset="1" stop-color="#15131c"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="face"><rect x="12" y="10" width="104" height="104" rx="24"/></clipPath>
|
||||||
|
<clipPath id="band0"><rect x="0" y="20" width="128" height="30"/></clipPath>
|
||||||
|
<clipPath id="band1"><rect x="0" y="50" width="128" height="9"/></clipPath>
|
||||||
|
<clipPath id="band2"><rect x="0" y="59" width="128" height="15"/></clipPath>
|
||||||
|
<clipPath id="band3"><rect x="0" y="74" width="128" height="6"/></clipPath>
|
||||||
|
<clipPath id="band4"><rect x="0" y="80" width="128" height="20"/></clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect x="12" y="16" width="104" height="104" rx="24" fill="#0b0a10"/>
|
||||||
|
<g clip-path="url(#face)">
|
||||||
|
<rect x="12" y="10" width="104" height="104" fill="url(#bg)"/>
|
||||||
|
<g fill="#ffffff" opacity=".05">
|
||||||
|
<rect x="12" y="12" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="16" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="20" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="24" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="28" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="32" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="36" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="40" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="44" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="48" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="52" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="56" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="60" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="64" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="68" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="72" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="76" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="80" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="84" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="88" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="92" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="96" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="100" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="104" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="108" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="112" width="104" height="1.4"/>
|
||||||
|
</g>
|
||||||
|
<g clip-path="url(#band0)"><g transform="translate(0 0)"><g transform="translate(-5 0)"><rect x="40" y="30" width="16" height="62" fill="#00e5ff"/><rect x="40" y="30" width="48" height="14" fill="#00e5ff"/><rect x="40" y="54" width="40" height="13" fill="#00e5ff"/><rect x="40" y="78" width="48" height="14" fill="#00e5ff"/></g><g transform="translate(5 0)"><rect x="40" y="30" width="16" height="62" fill="#ff2e88"/><rect x="40" y="30" width="48" height="14" fill="#ff2e88"/><rect x="40" y="54" width="40" height="13" fill="#ff2e88"/><rect x="40" y="78" width="48" height="14" fill="#ff2e88"/></g><rect x="40" y="30" width="16" height="62" fill="#ffffff"/><rect x="40" y="30" width="48" height="14" fill="#ffffff"/><rect x="40" y="54" width="40" height="13" fill="#ffffff"/><rect x="40" y="78" width="48" height="14" fill="#ffffff"/></g></g>
|
||||||
|
<g clip-path="url(#band1)"><g transform="translate(9 0)"><g transform="translate(-5 0)"><rect x="40" y="30" width="16" height="62" fill="#00e5ff"/><rect x="40" y="30" width="48" height="14" fill="#00e5ff"/><rect x="40" y="54" width="40" height="13" fill="#00e5ff"/><rect x="40" y="78" width="48" height="14" fill="#00e5ff"/></g><g transform="translate(5 0)"><rect x="40" y="30" width="16" height="62" fill="#ff2e88"/><rect x="40" y="30" width="48" height="14" fill="#ff2e88"/><rect x="40" y="54" width="40" height="13" fill="#ff2e88"/><rect x="40" y="78" width="48" height="14" fill="#ff2e88"/></g><rect x="40" y="30" width="16" height="62" fill="#ffffff"/><rect x="40" y="30" width="48" height="14" fill="#ffffff"/><rect x="40" y="54" width="40" height="13" fill="#ffffff"/><rect x="40" y="78" width="48" height="14" fill="#ffffff"/></g></g>
|
||||||
|
<g clip-path="url(#band2)"><g transform="translate(0 0)"><g transform="translate(-5 0)"><rect x="40" y="30" width="16" height="62" fill="#00e5ff"/><rect x="40" y="30" width="48" height="14" fill="#00e5ff"/><rect x="40" y="54" width="40" height="13" fill="#00e5ff"/><rect x="40" y="78" width="48" height="14" fill="#00e5ff"/></g><g transform="translate(5 0)"><rect x="40" y="30" width="16" height="62" fill="#ff2e88"/><rect x="40" y="30" width="48" height="14" fill="#ff2e88"/><rect x="40" y="54" width="40" height="13" fill="#ff2e88"/><rect x="40" y="78" width="48" height="14" fill="#ff2e88"/></g><rect x="40" y="30" width="16" height="62" fill="#ffffff"/><rect x="40" y="30" width="48" height="14" fill="#ffffff"/><rect x="40" y="54" width="40" height="13" fill="#ffffff"/><rect x="40" y="78" width="48" height="14" fill="#ffffff"/></g></g>
|
||||||
|
<g clip-path="url(#band3)"><g transform="translate(-7 0)"><g transform="translate(-5 0)"><rect x="40" y="30" width="16" height="62" fill="#00e5ff"/><rect x="40" y="30" width="48" height="14" fill="#00e5ff"/><rect x="40" y="54" width="40" height="13" fill="#00e5ff"/><rect x="40" y="78" width="48" height="14" fill="#00e5ff"/></g><g transform="translate(5 0)"><rect x="40" y="30" width="16" height="62" fill="#ff2e88"/><rect x="40" y="30" width="48" height="14" fill="#ff2e88"/><rect x="40" y="54" width="40" height="13" fill="#ff2e88"/><rect x="40" y="78" width="48" height="14" fill="#ff2e88"/></g><rect x="40" y="30" width="16" height="62" fill="#ffffff"/><rect x="40" y="30" width="48" height="14" fill="#ffffff"/><rect x="40" y="54" width="40" height="13" fill="#ffffff"/><rect x="40" y="78" width="48" height="14" fill="#ffffff"/></g></g>
|
||||||
|
<g clip-path="url(#band4)"><g transform="translate(0 0)"><g transform="translate(-5 0)"><rect x="40" y="30" width="16" height="62" fill="#00e5ff"/><rect x="40" y="30" width="48" height="14" fill="#00e5ff"/><rect x="40" y="54" width="40" height="13" fill="#00e5ff"/><rect x="40" y="78" width="48" height="14" fill="#00e5ff"/></g><g transform="translate(5 0)"><rect x="40" y="30" width="16" height="62" fill="#ff2e88"/><rect x="40" y="30" width="48" height="14" fill="#ff2e88"/><rect x="40" y="54" width="40" height="13" fill="#ff2e88"/><rect x="40" y="78" width="48" height="14" fill="#ff2e88"/></g><rect x="40" y="30" width="16" height="62" fill="#ffffff"/><rect x="40" y="30" width="48" height="14" fill="#ffffff"/><rect x="40" y="54" width="40" height="13" fill="#ffffff"/><rect x="40" y="78" width="48" height="14" fill="#ffffff"/></g></g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Effector, one colour: the E with its middle slice slipped sideways -->
|
||||||
|
<g fill="#2e3436">
|
||||||
|
<rect x="3" y="2" width="10" height="3"/>
|
||||||
|
<rect x="3" y="5" width="3" height="1.5"/>
|
||||||
|
<rect x="5" y="6.5" width="8" height="3"/>
|
||||||
|
<rect x="3" y="9.5" width="3" height="1.5"/>
|
||||||
|
<rect x="3" y="11" width="10" height="3"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 433 B |
@@ -4,7 +4,7 @@ Name=Effector
|
|||||||
GenericName=Image Effects
|
GenericName=Image Effects
|
||||||
Comment=Cascade glitch and collage effects over your photographs
|
Comment=Cascade glitch and collage effects over your photographs
|
||||||
Exec=effector %F
|
Exec=effector %F
|
||||||
Icon=applications-graphics
|
Icon=org.effector.Effector
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Graphics;2DGraphics;RasterGraphics;GTK;
|
Categories=Graphics;2DGraphics;RasterGraphics;GTK;
|
||||||
MimeType=image/jpeg;image/png;image/webp;image/tiff;image/bmp;
|
MimeType=image/jpeg;image/png;image/webp;image/tiff;image/bmp;
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
# so pkgver must follow the project() line of CMakeLists.txt (the version core
|
# so pkgver must follow the project() line of CMakeLists.txt (the version core
|
||||||
# test checks it). From this folder: makepkg -si
|
# test checks it). From this folder: makepkg -si
|
||||||
pkgname=effector
|
pkgname=effector
|
||||||
pkgver=0.5.2
|
pkgver=0.6.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
url='https://git.plan9.uber.space/hans/effector'
|
url='https://git.plan9.uber.space/hans/effector'
|
||||||
license=('GPL-3.0-or-later')
|
license=('GPL-3.0-or-later')
|
||||||
depends=('gtkmm-4.0' 'gdk-pixbuf2' 'pango' 'cairo' 'glib2' 'glibc' 'gcc-libs')
|
depends=('gtkmm-4.0' 'gdk-pixbuf2' 'pango' 'cairo' 'glib2' 'glibc' 'gcc-libs')
|
||||||
|
optdepends=('ffmpeg: write video projects')
|
||||||
makedepends=('cmake' 'ninja' 'git')
|
makedepends=('cmake' 'ninja' 'git')
|
||||||
checkdepends=('xorg-server-xvfb')
|
checkdepends=('xorg-server-xvfb')
|
||||||
source=("$pkgname::git+file://${startdir%/packaging/arch}#tag=v$pkgver")
|
source=("$pkgname::git+file://${startdir%/packaging/arch}#tag=v$pkgver")
|
||||||
|
|||||||
+69
-1
@@ -55,6 +55,11 @@ std::string requireValue(int argc, char** argv, int& i, const std::string& flag)
|
|||||||
|
|
||||||
Options parseOptions(int argc, char** argv) {
|
Options parseOptions(int argc, char** argv) {
|
||||||
Options options;
|
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) {
|
for (int i = 1; i < argc; ++i) {
|
||||||
const std::string arg = argv[i];
|
const std::string arg = argv[i];
|
||||||
@@ -73,6 +78,7 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
options.output = requireValue(argc, argv, i, arg);
|
options.output = requireValue(argc, argv, i, arg);
|
||||||
} else if (arg == "-s" || arg == "--size") {
|
} else if (arg == "-s" || arg == "--size") {
|
||||||
const std::string value = requireValue(argc, argv, i, arg);
|
const std::string value = requireValue(argc, argv, i, arg);
|
||||||
|
sizeGiven = true;
|
||||||
if (!parseSize(value, options.width, options.height)) {
|
if (!parseSize(value, options.width, options.height)) {
|
||||||
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with "
|
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with "
|
||||||
"each side between " + std::to_string(kMinCanvasSide) + " and " +
|
"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));
|
std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi));
|
||||||
}
|
}
|
||||||
options.dpi = static_cast<int>(dpi);
|
options.dpi = static_cast<int>(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<int>(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") {
|
} else if (arg == "--disable") {
|
||||||
const std::string name = requireValue(argc, argv, i, arg);
|
const std::string name = requireValue(argc, argv, i, arg);
|
||||||
effectByName(name); // validate now, exactly as -e does
|
effectByName(name); // validate now, exactly as -e does
|
||||||
@@ -162,6 +203,18 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
options.images.push_back(arg);
|
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
|
// Applied once everything is parsed, so --dpi may come before --format and
|
||||||
// so a format always wins over an -s given alongside it.
|
// so a format always wins over an -s given alongside it.
|
||||||
if (const auto* format = canvasFormatByKey(options.format)) {
|
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"
|
<< " --format NAME canvas format, overrides --size (see below)\n"
|
||||||
<< " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi
|
<< " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi
|
||||||
<< " (default: " << kDefaultDpi << ")\n"
|
<< " (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"
|
<< " --disable NAME keep this effect in the chain but skip it\n"
|
||||||
<< " --seed N random seed (default: random)\n"
|
<< " --seed N random seed (default: random)\n"
|
||||||
<< " --list-effects list available effects and exit\n"
|
<< " --list-effects list available effects and exit\n"
|
||||||
@@ -197,6 +255,15 @@ std::string usage(const char* programName) {
|
|||||||
<< (format.print ? " at --dpi" : "") << "\n";
|
<< (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()) {
|
for (const auto& effect : effects()) {
|
||||||
out << "\n" << effect->title() << " options:\n";
|
out << "\n" << effect->title() << " options:\n";
|
||||||
for (const auto& spec : effect->params()) {
|
for (const auto& spec : effect->params()) {
|
||||||
@@ -204,7 +271,8 @@ std::string usage(const char* programName) {
|
|||||||
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
|
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
|
||||||
out << " " << flag;
|
out << " " << flag;
|
||||||
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
|
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();
|
return out.str();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include "core/canvas.hpp"
|
#include "core/canvas.hpp"
|
||||||
#include "core/chain.hpp"
|
#include "core/chain.hpp"
|
||||||
#include "core/params.hpp"
|
#include "core/params.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -23,6 +24,9 @@ struct Options {
|
|||||||
int height = 1080;
|
int height = 1080;
|
||||||
std::string format; // a named canvas format, empty for plain pixels
|
std::string format; // a named canvas format, empty for plain pixels
|
||||||
int dpi = kDefaultDpi; // only a print format is rendered at a resolution
|
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<std::uint64_t> seed;
|
std::optional<std::uint64_t> seed;
|
||||||
ParamValues params; // only the keys the user actually passed
|
ParamValues params; // only the keys the user actually passed
|
||||||
bool listEffects = false;
|
bool listEffects = false;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const std::vector<CanvasFormat>& canvasFormats() {
|
|||||||
{"instagram-post", "Instagram post", false, 1080, 1080, 0.0, 0.0},
|
{"instagram-post", "Instagram post", false, 1080, 1080, 0.0, 0.0},
|
||||||
{"instagram-portrait", "Instagram portrait", false, 1080, 1350, 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},
|
{"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},
|
{"hd", "Full HD", false, 1920, 1080, 0.0, 0.0},
|
||||||
{"uhd", "4K UHD", false, 3840, 2160, 0.0, 0.0},
|
{"uhd", "4K UHD", false, 3840, 2160, 0.0, 0.0},
|
||||||
{"a5", "A5", true, 0, 0, 148.0, 210.0},
|
{"a5", "A5", true, 0, 0, 148.0, 210.0},
|
||||||
|
|||||||
+67
-7
@@ -1,5 +1,7 @@
|
|||||||
#include "core/chain.hpp"
|
#include "core/chain.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages) {
|
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages) {
|
||||||
@@ -29,8 +31,10 @@ ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
namespace {
|
||||||
int width, int height, Rng& rng, const StyleSet& styles) {
|
|
||||||
|
// The stages that run, checked: throws for a chain with nothing to render.
|
||||||
|
std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stages) {
|
||||||
if (stages.empty()) throw EffectError("The effect chain is empty.");
|
if (stages.empty()) throw EffectError("The effect chain is empty.");
|
||||||
for (const auto& stage : stages) {
|
for (const auto& stage : stages) {
|
||||||
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
||||||
@@ -39,27 +43,83 @@ Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image
|
|||||||
// A disabled stage never reaches the render at all: it is not handed an
|
// A disabled stage never reaches the render at all: it is not handed an
|
||||||
// image and draws nothing from the shared rng, so switching one off gives
|
// image and draws nothing from the shared rng, so switching one off gives
|
||||||
// back exactly the picture the chain made before it was added.
|
// back exactly the picture the chain made before it was added.
|
||||||
const auto running = enabledStages(stages);
|
auto running = enabledStages(stages);
|
||||||
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
|
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
Image image;
|
// Runs running[first, last) on `image`, which holds the sources' place for
|
||||||
for (std::size_t i = 0; i < running.size(); ++i) {
|
// the first stage of the chain and the previous stage's output after it.
|
||||||
|
Image runStages(const std::vector<const ChainStage*>& running, std::size_t first,
|
||||||
|
std::size_t last, const std::vector<Image>& 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& stage = *running[i];
|
||||||
|
|
||||||
const auto values = stageValues(stage, styles);
|
const auto values = stageValues(stage, styles);
|
||||||
|
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
image = stage.effect->render(sources, width, height, rng, values);
|
image = stage.effect->renderFrame(sources, width, height, rng, values, frame);
|
||||||
} else {
|
} else {
|
||||||
// Feeding the previous result back in as the only source is what
|
// Feeding the previous result back in as the only source is what
|
||||||
// cascading means here; effects need to know nothing about it.
|
// cascading means here; effects need to know nothing about it.
|
||||||
const std::vector<Image> previous = {std::move(image)};
|
const std::vector<Image> 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;
|
return image;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& 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<ChainStage>& 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<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
|
int width, int height, std::uint64_t seed, const StyleSet& styles,
|
||||||
|
const VideoSettings& video,
|
||||||
|
const std::function<bool(int index, Image&& frame)>& 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<ChainStage>& stages) {
|
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
|
||||||
const auto running = enabledStages(stages);
|
const auto running = enabledStages(stages);
|
||||||
if (running.empty()) return false;
|
if (running.empty()) return false;
|
||||||
|
|||||||
+23
-1
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@
|
|||||||
#include "core/params.hpp"
|
#include "core/params.hpp"
|
||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
#include "core/style.hpp"
|
#include "core/style.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -52,8 +54,28 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
|
|||||||
// reproduces the cascade byte for byte. Throws when no stage is left to run.
|
// 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
|
// `styles` is the project's pool; a chain built without one -- the command
|
||||||
// line's -- passes none and renders from the stages alone.
|
// 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<ChainStage>& stages, const std::vector<Image>& sources,
|
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& 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<ChainStage>& 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<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
|
int width, int height, std::uint64_t seed, const StyleSet& styles,
|
||||||
|
const VideoSettings& video,
|
||||||
|
const std::function<bool(int index, Image&& frame)>& onFrame);
|
||||||
|
|
||||||
// Whether the chain has to be given source images. Only the first stage that
|
// 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
|
// runs ever sees them — every later one works on the stage before it — so this
|
||||||
|
|||||||
@@ -16,6 +16,19 @@ public:
|
|||||||
using std::runtime_error::runtime_error;
|
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<double>(index) / fps : 0.0; }
|
||||||
|
};
|
||||||
|
|
||||||
// Base class for every effect. Canvas size and the random source are common to
|
// Base class for every effect. Canvas size and the random source are common to
|
||||||
// all of them; params() covers only the effect-specific knobs.
|
// all of them; params() covers only the effect-specific knobs.
|
||||||
class Effect {
|
class Effect {
|
||||||
@@ -45,9 +58,27 @@ public:
|
|||||||
return title();
|
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<Image>& sources, int width, int height, Rng& rng,
|
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values) const = 0;
|
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<Image>& 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;
|
ParamValues defaults() const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,39 @@ Image Image::load(const std::string& path, std::vector<std::uint8_t>* alphaOut)
|
|||||||
return image;
|
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<double>(maxSide) / longest;
|
||||||
|
const int outWidth = std::max(1, static_cast<int>(width_ * scale));
|
||||||
|
const int outHeight = std::max(1, static_cast<int>(height_ * scale));
|
||||||
|
Image out(outWidth, outHeight);
|
||||||
|
for (int y = 0; y < outHeight; ++y) {
|
||||||
|
const int y0 = static_cast<int>(static_cast<long>(y) * height_ / outHeight);
|
||||||
|
const int y1 = std::max(y0 + 1, static_cast<int>(static_cast<long>(y + 1) * height_ / outHeight));
|
||||||
|
std::uint8_t* target = out.row(y);
|
||||||
|
for (int x = 0; x < outWidth; ++x) {
|
||||||
|
const int x0 = static_cast<int>(static_cast<long>(x) * width_ / outWidth);
|
||||||
|
const int x1 = std::max(x0 + 1, static_cast<int>(static_cast<long>(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<std::size_t>(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<unsigned>((y1 - y0) * (x1 - x0));
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
target[static_cast<std::size_t>(x) * 3 + c] = static_cast<std::uint8_t>(sum[c] / area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
void Image::save(const std::string& path) const {
|
void Image::save(const std::string& path) const {
|
||||||
if (empty()) throw std::runtime_error("nothing to save");
|
if (empty()) throw std::runtime_error("nothing to save");
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ public:
|
|||||||
// which is what an effect compositing the image needs.
|
// which is what an effect compositing the image needs.
|
||||||
static Image load(const std::string& path, std::vector<std::uint8_t>* alphaOut = nullptr);
|
static Image load(const std::string& path, std::vector<std::uint8_t>* 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.
|
// Encodes by file extension; png is used when the extension is unknown.
|
||||||
void save(const std::string& path) const;
|
void save(const std::string& path) const;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ ParamSpec ParamSpec::styled() const {
|
|||||||
return copy;
|
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,
|
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||||
long maximum, std::string help, long step) {
|
long maximum, std::string help, long step) {
|
||||||
ParamSpec spec;
|
ParamSpec spec;
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ struct ParamSpec {
|
|||||||
// Whether a named style carries this param rather than the stage it sits
|
// Whether a named style carries this param rather than the stage it sits
|
||||||
// on: see core/style.hpp.
|
// on: see core/style.hpp.
|
||||||
bool inStyle = false;
|
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"
|
std::string flag() const; // "strip_height" -> "--strip-height"
|
||||||
|
|
||||||
@@ -34,6 +38,9 @@ struct ParamSpec {
|
|||||||
// reads makeDouble("font_size", ...).styled() and the factories keep the
|
// reads makeDouble("font_size", ...).styled() and the factories keep the
|
||||||
// signatures they have.
|
// signatures they have.
|
||||||
ParamSpec styled() const;
|
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,
|
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||||
long maximum, std::string help = {}, long step = 1);
|
long maximum, std::string help = {}, long step = 1);
|
||||||
|
|||||||
@@ -319,6 +319,25 @@ int canvasDpi(const Json& value) {
|
|||||||
return static_cast<int>(value.number);
|
return static_cast<int>(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<int>(value.number);
|
||||||
|
}
|
||||||
|
|
||||||
std::string resolve(const std::string& path, const std::string& directory) {
|
std::string resolve(const std::string& path, const std::string& directory) {
|
||||||
std::filesystem::path file(path);
|
std::filesystem::path file(path);
|
||||||
if (file.is_absolute() || directory.empty()) return 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;
|
if (format->print) out << ", \"dpi\": " << project.dpi;
|
||||||
}
|
}
|
||||||
out << "},\n";
|
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";
|
if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
|
||||||
|
|
||||||
out << " \"sources\": [";
|
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")) {
|
if (const auto* seed = root.member("seed")) {
|
||||||
// Taken from the digits rather than from the double: a 64-bit seed has
|
// 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
|
// more bits than a double has mantissa, and a seed that comes back off
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "core/chain.hpp"
|
#include "core/chain.hpp"
|
||||||
#include "core/params.hpp"
|
#include "core/params.hpp"
|
||||||
#include "core/style.hpp"
|
#include "core/style.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ struct Project {
|
|||||||
// resolution for.
|
// resolution for.
|
||||||
std::string format;
|
std::string format;
|
||||||
int dpi = kDefaultDpi;
|
int dpi = kDefaultDpi;
|
||||||
|
// Whether the project is a video, and how long and how fast.
|
||||||
|
VideoSettings video;
|
||||||
std::optional<std::uint64_t> seed;
|
std::optional<std::uint64_t> seed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
#include "core/video.hpp"
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <spawn.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <locale>
|
||||||
|
#include <mutex>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#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<int>(std::lround(seconds * rate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<VideoPreset>& 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<VideoPreset> 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<char>(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<std::string> 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<char*> 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<std::size_t>(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
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <sys/types.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<VideoPreset>& 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
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <numbers>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/parallel.hpp"
|
#include "core/parallel.hpp"
|
||||||
@@ -22,6 +24,26 @@ const std::vector<ParamSpec>& ChannelSplitEffect::params() const {
|
|||||||
"rgb moves all three channels apart; red_cyan moves red one way and "
|
"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 "
|
"green and blue the other, as 3D glasses would; random moves one "
|
||||||
"channel picked at random."),
|
"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;
|
return specs;
|
||||||
}
|
}
|
||||||
@@ -35,28 +57,68 @@ struct Offset {
|
|||||||
|
|
||||||
using ChannelOffsets = std::array<Offset, 3>;
|
using ChannelOffsets = std::array<Offset, 3>;
|
||||||
|
|
||||||
ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels) {
|
ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels, int& moved) {
|
||||||
ChannelOffsets offsets{};
|
ChannelOffsets offsets{};
|
||||||
auto draw = [&] {
|
auto draw = [&] {
|
||||||
return Offset{static_cast<int>(rng.inRange(-amount, amount)),
|
return Offset{static_cast<int>(rng.inRange(-amount, amount)),
|
||||||
static_cast<int>(rng.inRange(-amount, amount))};
|
static_cast<int>(rng.inRange(-amount, amount))};
|
||||||
};
|
};
|
||||||
|
moved = -1;
|
||||||
if (channels == "red_cyan") {
|
if (channels == "red_cyan") {
|
||||||
const Offset red = draw();
|
const Offset red = draw();
|
||||||
offsets[0] = red;
|
offsets[0] = red;
|
||||||
offsets[1] = offsets[2] = Offset{-red.dx, -red.dy};
|
offsets[1] = offsets[2] = Offset{-red.dx, -red.dy};
|
||||||
} else if (channels == "random") {
|
} else if (channels == "random") {
|
||||||
offsets[rng.below(2)] = draw();
|
moved = static_cast<int>(rng.below(2));
|
||||||
|
offsets[static_cast<std::size_t>(moved)] = draw();
|
||||||
} else {
|
} else {
|
||||||
for (auto& offset : offsets) offset = draw();
|
for (auto& offset : offsets) offset = draw();
|
||||||
}
|
}
|
||||||
return offsets;
|
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<double, 3>;
|
||||||
|
|
||||||
Image ChannelSplitEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
Motion spread(const Motion& motion, const std::string& channels, int moved) {
|
||||||
const ParamValues& values) const {
|
if (channels == "red_cyan") return {motion[0], -motion[0], -motion[0]};
|
||||||
|
if (moved >= 0) {
|
||||||
|
Motion only{};
|
||||||
|
only[static_cast<std::size_t>(moved)] = motion[0];
|
||||||
|
return only;
|
||||||
|
}
|
||||||
|
return motion;
|
||||||
|
}
|
||||||
|
|
||||||
|
double unit(Rng& rng) { return static_cast<double>(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<int>(std::max(0L, paramInt(values, "split_wobble", 4)))),
|
||||||
|
wobbleSpeed(std::max(0.0, paramDouble(values, "split_wobble_speed", 0.5))),
|
||||||
|
jitter(static_cast<int>(std::max(0L, paramInt(values, "split_jitter", 2)))),
|
||||||
|
roll(static_cast<int>(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<Image>& 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.");
|
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||||
|
|
||||||
const int amount = static_cast<int>(std::max(0L, paramInt(values, "split_amount", 12)));
|
const int amount = static_cast<int>(std::max(0L, paramInt(values, "split_amount", 12)));
|
||||||
@@ -71,20 +133,105 @@ Image ChannelSplitEffect::render(const std::vector<Image>& sources, int width, i
|
|||||||
const int band = perBand ? bandHeight : height;
|
const int band = perBand ? bandHeight : height;
|
||||||
const int bandCount = (height + band - 1) / band;
|
const int bandCount = (height + band - 1) / band;
|
||||||
std::vector<ChannelOffsets> plan;
|
std::vector<ChannelOffsets> plan;
|
||||||
|
std::vector<int> moved;
|
||||||
plan.reserve(static_cast<std::size_t>(bandCount));
|
plan.reserve(static_cast<std::size_t>(bandCount));
|
||||||
for (int i = 0; i < bandCount; ++i) plan.push_back(drawOffsets(rng, amount, channels));
|
moved.reserve(static_cast<std::size_t>(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<ChannelOffsets> rows(static_cast<std::size_t>(height));
|
||||||
|
std::vector<int> shift(static_cast<std::size_t>(height), 0);
|
||||||
|
for (int y = 0; y < height; ++y) rows[static_cast<std::size_t>(y)] = plan[static_cast<std::size_t>(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<double, 3> phase{};
|
||||||
|
std::array<double, 3> angle{};
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
phase[static_cast<std::size_t>(c)] = unit(rng) * 2.0 * std::numbers::pi;
|
||||||
|
angle[static_cast<std::size_t>(c)] = unit(rng) * 2.0 * std::numbers::pi;
|
||||||
|
}
|
||||||
|
Rng shake(rng.next() ^ (0x9e3779b97f4a7c15ULL * static_cast<std::uint64_t>(frame.index + 1)));
|
||||||
|
|
||||||
|
const double clip = static_cast<double>(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<double>(shake.inRange(-motion.jitter, motion.jitter));
|
||||||
|
// Mostly sideways, as a picture losing its horizontal hold.
|
||||||
|
jitterY[c] = static_cast<double>(shake.inRange(-motion.jitter, motion.jitter)) / 3.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const int only = moved[static_cast<std::size_t>(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<std::size_t>(y)];
|
||||||
|
for (std::size_t c = 0; c < 3; ++c) {
|
||||||
|
offsets[c].dx += static_cast<int>(std::lround(dx[c] + bar * apart[c]));
|
||||||
|
offsets[c].dy += static_cast<int>(std::lround(dy[c]));
|
||||||
|
}
|
||||||
|
shift[static_cast<std::size_t>(y)] = static_cast<int>(std::lround(bar / 2.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Image canvas(width, height);
|
Image canvas(width, height);
|
||||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||||
for (std::size_t y = begin; y < end; ++y) {
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
const ChannelOffsets& offsets = plan[y / static_cast<std::size_t>(band)];
|
const ChannelOffsets& offsets = rows[y];
|
||||||
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||||
for (int c = 0; c < 3; ++c) {
|
for (int c = 0; c < 3; ++c) {
|
||||||
const Offset& offset = offsets[static_cast<std::size_t>(c)];
|
const Offset& offset = offsets[static_cast<std::size_t>(c)];
|
||||||
|
const int dx = offset.dx + shift[y];
|
||||||
const int sy = std::clamp(static_cast<int>(y) - offset.dy, 0, height - 1);
|
const int sy = std::clamp(static_cast<int>(y) - offset.dy, 0, height - 1);
|
||||||
const std::uint8_t* in = input.row(sy);
|
const std::uint8_t* in = input.row(sy);
|
||||||
for (int x = 0; x < width; ++x) {
|
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<std::size_t>(x) * 3 + c] = in[static_cast<std::size_t>(sx) * 3 + c];
|
out[static_cast<std::size_t>(x) * 3 + c] = in[static_cast<std::size_t>(sx) * 3 + c];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,4 +241,21 @@ Image ChannelSplitEffect::render(const std::vector<Image>& sources, int width, i
|
|||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool ChannelSplitEffect::animated(const ParamValues& values) const {
|
||||||
|
return MotionSettings(values).moves();
|
||||||
|
}
|
||||||
|
|
||||||
|
Image ChannelSplitEffect::render(const std::vector<Image>& 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<Image>& sources, int width, int height,
|
||||||
|
Rng& rng, const ParamValues& values,
|
||||||
|
const FrameTime& frame) const {
|
||||||
|
return renderSplit(sources, width, height, rng, values, frame);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace effector
|
} // namespace effector
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ namespace effector {
|
|||||||
|
|
||||||
// Knocks the red, green and blue planes out of register, either the whole image
|
// 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.
|
// 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
|
// 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.
|
// sources loaded it starts from a crop of a randomly chosen one.
|
||||||
class ChannelSplitEffect : public Effect {
|
class ChannelSplitEffect : public Effect {
|
||||||
@@ -18,9 +21,12 @@ public:
|
|||||||
}
|
}
|
||||||
const std::vector<ParamSpec>& params() const override;
|
const std::vector<ParamSpec>& params() const override;
|
||||||
bool needsSources() const override { return false; }
|
bool needsSources() const override { return false; }
|
||||||
|
bool animated(const ParamValues& values) const override;
|
||||||
|
|
||||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values) const override;
|
const ParamValues& values) const override;
|
||||||
|
Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
|
const ParamValues& values, const FrameTime& frame) const override;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace effector
|
} // namespace effector
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ int runGui(const Options& initial, int argc, char** argv) {
|
|||||||
|
|
||||||
auto app = Gtk::Application::create("org.effector.Effector",
|
auto app = Gtk::Application::create("org.effector.Effector",
|
||||||
Gio::Application::Flags::NON_UNIQUE);
|
Gio::Application::Flags::NON_UNIQUE);
|
||||||
|
app->signal_startup().connect([] {
|
||||||
|
// An installed Effector finds its icon in the hicolor theme; one run
|
||||||
|
// straight from the build tree falls back to the copy in the sources.
|
||||||
|
const char* icon = "org.effector.Effector";
|
||||||
|
auto theme = Gtk::IconTheme::get_for_display(Gdk::Display::get_default());
|
||||||
|
if (!theme->has_icon(icon)) theme->add_search_path(EFFECTOR_SOURCE_ICON_DIR);
|
||||||
|
Gtk::Window::set_default_icon_name(icon);
|
||||||
|
});
|
||||||
return app->make_window_and_run<EffectorWindow>(0, nullptr, initial);
|
return app->make_window_and_run<EffectorWindow>(0, nullptr, initial);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+494
-12
@@ -14,6 +14,7 @@
|
|||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
#include "core/version.hpp"
|
#include "core/version.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -42,6 +43,20 @@ Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) {
|
|||||||
bytes, image.stride());
|
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<int>(std::max(width, height) * scale);
|
||||||
|
return std::clamp(side, kClipMinSide, kClipMaxSide);
|
||||||
|
}
|
||||||
|
|
||||||
Gtk::Label* sectionLabel(const char* text) {
|
Gtk::Label* sectionLabel(const char* text) {
|
||||||
auto* label = Gtk::make_managed<Gtk::Label>(text);
|
auto* label = Gtk::make_managed<Gtk::Label>(text);
|
||||||
label->set_xalign(0.0F);
|
label->set_xalign(0.0F);
|
||||||
@@ -97,6 +112,9 @@ EffectorWindow::EffectorWindow(const Options& initial) {
|
|||||||
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
|
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
|
||||||
loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress));
|
loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress));
|
||||||
loadFinished_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadFinished));
|
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();
|
buildUi();
|
||||||
applyInitial(initial);
|
applyInitial(initial);
|
||||||
@@ -116,9 +134,13 @@ EffectorWindow::~EffectorWindow() {
|
|||||||
chainSplitFit_.disconnect();
|
chainSplitFit_.disconnect();
|
||||||
recentChanged_.disconnect();
|
recentChanged_.disconnect();
|
||||||
loadWindowDelay_.disconnect();
|
loadWindowDelay_.disconnect();
|
||||||
|
playTimer_.disconnect();
|
||||||
loadCancel_.store(true, std::memory_order_relaxed);
|
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 (loader_.joinable()) loader_.join();
|
||||||
if (worker_.joinable()) worker_.join();
|
if (worker_.joinable()) worker_.join();
|
||||||
|
if (exporter_.joinable()) exporter_.join(); // cancelled: removes its half-written file
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------- UI ---
|
// ------------------------------------------------------------------- UI ---
|
||||||
@@ -334,6 +356,8 @@ Gtk::Widget& EffectorWindow::buildSetupTab() {
|
|||||||
updateSourcesLabel();
|
updateSourcesLabel();
|
||||||
tab->append(sourcesSection_);
|
tab->append(sourcesSection_);
|
||||||
|
|
||||||
|
tab->append(buildVideoSection());
|
||||||
|
|
||||||
tab->append(*sectionLabel("Canvas"));
|
tab->append(*sectionLabel("Canvas"));
|
||||||
tab->append(buildCanvasSection());
|
tab->append(buildCanvasSection());
|
||||||
|
|
||||||
@@ -392,6 +416,8 @@ Gtk::Widget& EffectorWindow::buildCanvasSection() {
|
|||||||
widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
||||||
widthSpin_.set_increments(1, 100);
|
widthSpin_.set_increments(1, 100);
|
||||||
widthSpin_.set_value(1920);
|
widthSpin_.set_value(1920);
|
||||||
|
widthSpin_.set_name("canvas-width");
|
||||||
|
heightSpin_.set_name("canvas-height");
|
||||||
heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
||||||
heightSpin_.set_increments(1, 100);
|
heightSpin_.set_increments(1, 100);
|
||||||
heightSpin_.set_value(1080);
|
heightSpin_.set_value(1080);
|
||||||
@@ -484,6 +510,220 @@ void EffectorWindow::syncCanvasWidgets() {
|
|||||||
if (!quiet) onParamChanged();
|
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::Box>(Gtk::Orientation::VERTICAL, 6);
|
||||||
|
|
||||||
|
auto* heading = Gtk::make_managed<Gtk::Box>(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<Glib::ustring> 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<Gtk::Grid>();
|
||||||
|
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<Gtk::Label>("Length (s)");
|
||||||
|
durationLabel->set_xalign(0.0F);
|
||||||
|
auto* fpsLabel = Gtk::make_managed<Gtk::Label>("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<int>(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<guint>(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<int>(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<int>(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<Image> frames;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> 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<int>(clip_.size()) - 1);
|
||||||
|
picture_.set_paintable(clip_[static_cast<std::size_t>(playFrame_)]);
|
||||||
|
suspendScrub_ = true;
|
||||||
|
playbackScale_.set_value(playFrame_);
|
||||||
|
suspendScrub_ = false;
|
||||||
|
playbackTime_.set_text(formatSeconds(static_cast<double>(playFrame_) / clipFps_) + " / " +
|
||||||
|
formatSeconds(static_cast<double>(clip_.size()) / clipFps_));
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::startPlayback() {
|
||||||
|
playTimer_.disconnect();
|
||||||
|
if (clip_.empty()) return;
|
||||||
|
playbackScale_.set_range(0, std::max(1, static_cast<int>(clip_.size()) - 1));
|
||||||
|
playbackBar_.set_visible(true);
|
||||||
|
showClipFrame(playFrame_);
|
||||||
|
if (!playing_) return;
|
||||||
|
playTimer_ = Glib::signal_timeout().connect(
|
||||||
|
[this] {
|
||||||
|
showClipFrame((playFrame_ + 1) % static_cast<int>(clip_.size()));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
static_cast<unsigned>(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() {
|
Gtk::Widget& EffectorWindow::buildPreview() {
|
||||||
picture_.set_can_shrink(true);
|
picture_.set_can_shrink(true);
|
||||||
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
|
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
|
||||||
@@ -516,6 +756,37 @@ Gtk::Widget& EffectorWindow::buildPreview() {
|
|||||||
status_.set_margin_top(6);
|
status_.set_margin_top(6);
|
||||||
status_.set_margin_bottom(8);
|
status_.set_margin_bottom(8);
|
||||||
status_.add_css_class("dim-label");
|
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<int>(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_);
|
box->append(status_);
|
||||||
return *box;
|
return *box;
|
||||||
}
|
}
|
||||||
@@ -557,6 +828,9 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const auto& spec : stage.effect->params()) {
|
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
|
// A styled param is read from the style while one is followed, so the
|
||||||
// widgets show what the stage will actually render with.
|
// widgets show what the stage will actually render with.
|
||||||
const bool shared = style != nullptr && spec.inStyle;
|
const bool shared = style != nullptr && spec.inStyle;
|
||||||
@@ -1011,6 +1285,7 @@ void EffectorWindow::applyInitial(const Options& initial) {
|
|||||||
widthSpin_.set_value(initial.width);
|
widthSpin_.set_value(initial.width);
|
||||||
heightSpin_.set_value(initial.height);
|
heightSpin_.set_value(initial.height);
|
||||||
setCanvasFormat(initial.format, initial.dpi); // a format fills the spins itself
|
setCanvasFormat(initial.format, initial.dpi); // a format fills the spins itself
|
||||||
|
setVideoSettings(initial.video);
|
||||||
if (initial.seed) {
|
if (initial.seed) {
|
||||||
seed_ = *initial.seed;
|
seed_ = *initial.seed;
|
||||||
seedEntry_.set_text(std::to_string(seed_));
|
seedEntry_.set_text(std::to_string(seed_));
|
||||||
@@ -1689,6 +1964,7 @@ void EffectorWindow::onParamChanged() {
|
|||||||
// styled one can rename every stage sharing the style.
|
// styled one can rename every stage sharing the style.
|
||||||
for (std::size_t i = 0; i < stageLabels_.size() && i < stages_.size(); ++i)
|
for (std::size_t i = 0; i < stageLabels_.size() && i < stages_.size(); ++i)
|
||||||
stageLabels_[i]->set_text(stageLabel(i));
|
stageLabels_[i]->set_text(stageLabel(i));
|
||||||
|
matchVideoPreset(); // the canvas may have left the preset's format
|
||||||
requestRender();
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1758,6 +2034,7 @@ std::string EffectorWindow::missingText() const {
|
|||||||
|
|
||||||
void EffectorWindow::showPlaceholder() {
|
void EffectorWindow::showPlaceholder() {
|
||||||
result_ = Image();
|
result_ = Image();
|
||||||
|
clearClip();
|
||||||
saveButton_.set_sensitive(false);
|
saveButton_.set_sensitive(false);
|
||||||
placeholder_.set_text(missingText());
|
placeholder_.set_text(missingText());
|
||||||
previewStack_.set_visible_child("placeholder");
|
previewStack_.set_visible_child("placeholder");
|
||||||
@@ -1786,9 +2063,13 @@ void EffectorWindow::startRender() {
|
|||||||
if (!canRender()) return;
|
if (!canRender()) return;
|
||||||
if (rendering_) {
|
if (rendering_) {
|
||||||
staleResult_ = true; // a fresh render follows this one
|
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;
|
return;
|
||||||
}
|
}
|
||||||
rendering_ = true;
|
rendering_ = true;
|
||||||
|
renderCancel_.store(false, std::memory_order_relaxed);
|
||||||
|
|
||||||
const auto sources = sources_;
|
const auto sources = sources_;
|
||||||
const auto stages = stages_; // a snapshot, like the sources
|
const auto stages = stages_; // a snapshot, like the sources
|
||||||
@@ -1796,17 +2077,46 @@ void EffectorWindow::startRender() {
|
|||||||
const int width = static_cast<int>(widthSpin_.get_value());
|
const int width = static_cast<int>(widthSpin_.get_value());
|
||||||
const int height = static_cast<int>(heightSpin_.get_value());
|
const int height = static_cast<int>(heightSpin_.get_value());
|
||||||
const std::uint64_t seed = seed_;
|
const std::uint64_t seed = seed_;
|
||||||
|
const VideoSettings video = videoSettings();
|
||||||
|
|
||||||
spinner_.start();
|
spinner_.start();
|
||||||
recreateButton_.set_sensitive(false);
|
recreateButton_.set_sensitive(false);
|
||||||
|
|
||||||
worker_ = std::thread([this, sources, stages, styles, width, height, seed] {
|
renderingVideo_ = video.enabled;
|
||||||
|
if (video.enabled) {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> 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;
|
Image image;
|
||||||
std::string error;
|
std::string error;
|
||||||
const auto start = std::chrono::steady_clock::now();
|
const auto start = std::chrono::steady_clock::now();
|
||||||
try {
|
try {
|
||||||
|
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<std::mutex> lock(resultMutex_);
|
||||||
|
pendingFrames_.push_back(std::move(small));
|
||||||
|
}
|
||||||
|
clipProgress_.emit();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
Rng rng(seed);
|
Rng rng(seed);
|
||||||
image = renderChain(stages, *sources, width, height, rng, styles);
|
image = renderChain(stages, *sources, width, height, rng, styles);
|
||||||
|
}
|
||||||
} catch (const std::exception& exception) {
|
} catch (const std::exception& exception) {
|
||||||
error = exception.what();
|
error = exception.what();
|
||||||
}
|
}
|
||||||
@@ -1826,6 +2136,9 @@ void EffectorWindow::startRender() {
|
|||||||
void EffectorWindow::onRenderFinished() {
|
void EffectorWindow::onRenderFinished() {
|
||||||
if (worker_.joinable()) worker_.join();
|
if (worker_.joinable()) worker_.join();
|
||||||
rendering_ = false;
|
rendering_ = false;
|
||||||
|
onClipProgress(); // whatever frames came in after the last wake-up
|
||||||
|
const bool wasVideo = renderingVideo_;
|
||||||
|
renderingVideo_ = false;
|
||||||
|
|
||||||
Image image;
|
Image image;
|
||||||
std::string error;
|
std::string error;
|
||||||
@@ -1839,6 +2152,7 @@ void EffectorWindow::onRenderFinished() {
|
|||||||
|
|
||||||
if (staleResult_) { // settings changed while this one was running
|
if (staleResult_) { // settings changed while this one was running
|
||||||
staleResult_ = false;
|
staleResult_ = false;
|
||||||
|
incomingClip_.clear();
|
||||||
startRender();
|
startRender();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1848,25 +2162,45 @@ void EffectorWindow::onRenderFinished() {
|
|||||||
|
|
||||||
if (!error.empty()) {
|
if (!error.empty()) {
|
||||||
result_ = Image();
|
result_ = Image();
|
||||||
|
clearClip();
|
||||||
saveButton_.set_sensitive(false);
|
saveButton_.set_sensitive(false);
|
||||||
showError(error);
|
showError(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearError();
|
clearError();
|
||||||
|
std::ostringstream status;
|
||||||
|
status.setf(std::ios::fixed);
|
||||||
|
status.precision(1);
|
||||||
|
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<int>(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);
|
result_ = std::move(image);
|
||||||
picture_.set_paintable(toTexture(result_));
|
picture_.set_paintable(toTexture(result_));
|
||||||
previewStack_.set_visible_child("picture");
|
previewStack_.set_visible_child("picture");
|
||||||
saveButton_.set_sensitive(true);
|
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.
|
// 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";
|
|
||||||
std::ostringstream details;
|
std::ostringstream details;
|
||||||
details << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · ";
|
details << chainTitle(stages_) << " · " << static_cast<int>(widthSpin_.get_value()) << "×"
|
||||||
|
<< static_cast<int>(heightSpin_.get_value()) << " · ";
|
||||||
|
if (wasVideo) {
|
||||||
|
const auto video = videoSettings();
|
||||||
|
details << formatSeconds(video.duration) << " at " << video.fps << " fps · ";
|
||||||
|
}
|
||||||
if (!sources_->empty()) {
|
if (!sources_->empty()) {
|
||||||
details << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · ";
|
details << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · ";
|
||||||
}
|
}
|
||||||
@@ -1945,6 +2279,7 @@ Project EffectorWindow::currentProject() const {
|
|||||||
project.height = static_cast<int>(heightSpin_.get_value());
|
project.height = static_cast<int>(heightSpin_.get_value());
|
||||||
project.format = canvasFormatKey();
|
project.format = canvasFormatKey();
|
||||||
project.dpi = static_cast<int>(dpiSpin_.get_value());
|
project.dpi = static_cast<int>(dpiSpin_.get_value());
|
||||||
|
project.video = videoSettings();
|
||||||
project.seed = seed_;
|
project.seed = seed_;
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
@@ -1965,6 +2300,7 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat
|
|||||||
widthSpin_.set_value(project.width);
|
widthSpin_.set_value(project.width);
|
||||||
heightSpin_.set_value(project.height);
|
heightSpin_.set_value(project.height);
|
||||||
setCanvasFormat(project.format, project.dpi);
|
setCanvasFormat(project.format, project.dpi);
|
||||||
|
setVideoSettings(project.video);
|
||||||
seed_ = project.seed.value_or(Rng::randomSeed());
|
seed_ = project.seed.value_or(Rng::randomSeed());
|
||||||
seedEntry_.set_text(std::to_string(seed_));
|
seedEntry_.set_text(std::to_string(seed_));
|
||||||
|
|
||||||
@@ -1988,6 +2324,7 @@ void EffectorWindow::clearSources() {
|
|||||||
if (loading_) dropLoadedImages_ = true;
|
if (loading_) dropLoadedImages_ = true;
|
||||||
|
|
||||||
result_ = Image();
|
result_ = Image();
|
||||||
|
clearClip();
|
||||||
saveButton_.set_sensitive(false);
|
saveButton_.set_sensitive(false);
|
||||||
previewStack_.set_visible_child("placeholder");
|
previewStack_.set_visible_child("placeholder");
|
||||||
}
|
}
|
||||||
@@ -2220,7 +2557,7 @@ void EffectorWindow::showAbout() {
|
|||||||
aboutDialog_->set_program_name("Effector");
|
aboutDialog_->set_program_name("Effector");
|
||||||
aboutDialog_->set_version(std::string(kVersion));
|
aboutDialog_->set_version(std::string(kVersion));
|
||||||
aboutDialog_->set_comments("Image effects, cascaded through a chain.");
|
aboutDialog_->set_comments("Image effects, cascaded through a chain.");
|
||||||
aboutDialog_->set_logo_icon_name("applications-graphics");
|
aboutDialog_->set_logo_icon_name("org.effector.Effector");
|
||||||
aboutDialog_->set_copyright("Copyright © 2026 Si");
|
aboutDialog_->set_copyright("Copyright © 2026 Si");
|
||||||
aboutDialog_->set_license_type(Gtk::License::GPL_3_0);
|
aboutDialog_->set_license_type(Gtk::License::GPL_3_0);
|
||||||
}
|
}
|
||||||
@@ -2287,11 +2624,13 @@ void EffectorWindow::updateTitle() {
|
|||||||
// --------------------------------------------------------------- saving ---
|
// --------------------------------------------------------------- saving ---
|
||||||
|
|
||||||
void EffectorWindow::onSave() {
|
void EffectorWindow::onSave() {
|
||||||
if (result_.empty()) return;
|
const bool video = isVideo();
|
||||||
|
if (video ? clip_.empty() || exporting_ : result_.empty()) return;
|
||||||
|
|
||||||
auto dialog = Gtk::FileDialog::create();
|
auto dialog = Gtk::FileDialog::create();
|
||||||
dialog->set_title("Save image");
|
dialog->set_title(video ? "Save video" : "Save image");
|
||||||
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) + ".png");
|
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) +
|
||||||
|
(video ? ".mp4" : ".png"));
|
||||||
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||||
onSaveChosen(result, dialog);
|
onSaveChosen(result, dialog);
|
||||||
});
|
});
|
||||||
@@ -2307,8 +2646,19 @@ void EffectorWindow::onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
|||||||
} catch (const Glib::Error&) {
|
} catch (const Glib::Error&) {
|
||||||
return; // cancelled
|
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 {
|
try {
|
||||||
result_.save(path.string());
|
result_.save(path.string());
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
@@ -2320,6 +2670,138 @@ void EffectorWindow::onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
|||||||
status_.set_tooltip_text("Saved " + path.string());
|
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<int>(widthSpin_.get_value());
|
||||||
|
const int height = static_cast<int>(heightSpin_.get_value());
|
||||||
|
const std::uint64_t seed = seed_;
|
||||||
|
const VideoSettings video = videoSettings();
|
||||||
|
exportTotal_ = video.frameCount();
|
||||||
|
|
||||||
|
exportWindow_ = std::make_unique<Gtk::Window>();
|
||||||
|
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::Box>(Gtk::Orientation::VERTICAL, 12);
|
||||||
|
box->set_margin(18);
|
||||||
|
auto* heading = Gtk::make_managed<Gtk::Label>("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<Gtk::ProgressBar>();
|
||||||
|
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<Gtk::Button>("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<std::mutex> 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<double>(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<std::mutex> 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 ---
|
// --------------------------------------------------------------- errors ---
|
||||||
|
|
||||||
void EffectorWindow::showError(const std::string& message) {
|
void EffectorWindow::showError(const std::string& message) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#include "core/image.hpp"
|
#include "core/image.hpp"
|
||||||
#include "core/project.hpp"
|
#include "core/project.hpp"
|
||||||
#include "core/style.hpp"
|
#include "core/style.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -87,6 +88,34 @@ private:
|
|||||||
void syncCanvasWidgets();
|
void syncCanvasWidgets();
|
||||||
void removeSource(Gtk::ListBoxRow* row);
|
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& buildChainSection();
|
||||||
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
||||||
void appendStage(const EffectPtr& effect);
|
void appendStage(const EffectPtr& effect);
|
||||||
@@ -205,6 +234,35 @@ private:
|
|||||||
Image pendingImage_;
|
Image pendingImage_;
|
||||||
std::string pendingError_;
|
std::string pendingError_;
|
||||||
double pendingMillis_ = 0.0;
|
double pendingMillis_ = 0.0;
|
||||||
|
// Set to stop a clip render early once its settings are out of date.
|
||||||
|
std::atomic<bool> 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<Image> pendingFrames_; // handed over under resultMutex_
|
||||||
|
std::vector<Glib::RefPtr<Gdk::Texture>> clip_;
|
||||||
|
std::vector<Glib::RefPtr<Gdk::Texture>> 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<bool> exportCancel_{false};
|
||||||
|
std::atomic<int> exportFrames_{0};
|
||||||
|
int exportTotal_ = 0;
|
||||||
|
bool exporting_ = false;
|
||||||
|
std::string exportPath_;
|
||||||
|
std::string exportError_; // written by the exporter before exportDone_
|
||||||
|
std::unique_ptr<Gtk::Window> exportWindow_;
|
||||||
|
Gtk::ProgressBar* exportBar_ = nullptr; // nulled with the window
|
||||||
|
|
||||||
// Widgets
|
// Widgets
|
||||||
Gtk::Label banner_;
|
Gtk::Label banner_;
|
||||||
@@ -224,6 +282,12 @@ private:
|
|||||||
Gtk::SpinButton heightSpin_;
|
Gtk::SpinButton heightSpin_;
|
||||||
Gtk::SpinButton dpiSpin_;
|
Gtk::SpinButton dpiSpin_;
|
||||||
Gtk::Label* dpiLabel_ = nullptr; // hidden along with the dpi spin
|
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
|
// 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.
|
// a second render on top of the one the format change asks for.
|
||||||
bool suspendCanvasSync_ = false;
|
bool suspendCanvasSync_ = false;
|
||||||
@@ -245,6 +309,10 @@ private:
|
|||||||
Gtk::Label placeholder_;
|
Gtk::Label placeholder_;
|
||||||
Gtk::Spinner spinner_;
|
Gtk::Spinner spinner_;
|
||||||
Gtk::Label status_;
|
Gtk::Label status_;
|
||||||
|
Gtk::Box playbackBar_; // under the preview of a video only
|
||||||
|
Gtk::Button playButton_;
|
||||||
|
Gtk::Scale playbackScale_;
|
||||||
|
Gtk::Label playbackTime_;
|
||||||
|
|
||||||
struct ParamWidget {
|
struct ParamWidget {
|
||||||
ParamSpec spec;
|
ParamSpec spec;
|
||||||
|
|||||||
@@ -13,12 +13,55 @@
|
|||||||
#include "core/image.hpp"
|
#include "core/image.hpp"
|
||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
#include "core/version.hpp"
|
#include "core/version.hpp"
|
||||||
#include "gui/app.hpp"
|
#include "gui/app.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
int renderVideo(const effector::Options& options, const std::vector<effector::ChainStage>& stages,
|
||||||
|
const std::vector<effector::Image>& 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<double>(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) {
|
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);
|
auto stages = effector::buildChain(options);
|
||||||
// A headless run always renders something, so with no -e the default
|
// 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
|
// 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());
|
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);
|
effector::Rng rng(seed);
|
||||||
|
|
||||||
const auto start = std::chrono::steady_clock::now();
|
const auto start = std::chrono::steady_clock::now();
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
#include "core/style.hpp"
|
#include "core/style.hpp"
|
||||||
#include "core/version.hpp"
|
#include "core/version.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
#include "effects/blur.hpp"
|
#include "effects/blur.hpp"
|
||||||
|
#include "effects/channelsplit.hpp"
|
||||||
#include "effects/grain.hpp"
|
#include "effects/grain.hpp"
|
||||||
#include "effects/monochrome.hpp"
|
#include "effects/monochrome.hpp"
|
||||||
#include "effects/overlay.hpp"
|
#include "effects/overlay.hpp"
|
||||||
@@ -1716,6 +1718,239 @@ int main() {
|
|||||||
std::filesystem::remove_all(dir);
|
std::filesystem::remove_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::cout << "video\n";
|
||||||
|
{
|
||||||
|
using effector::ChainStage;
|
||||||
|
using effector::FrameTime;
|
||||||
|
using effector::VideoSettings;
|
||||||
|
|
||||||
|
// Every preset names a canvas format this build offers, and its
|
||||||
|
// numbers are ones the project reader and the CLI accept.
|
||||||
|
int sound = 0;
|
||||||
|
for (const auto& preset : effector::videoPresets()) {
|
||||||
|
if (effector::canvasFormatByKey(preset.canvasFormat) != nullptr &&
|
||||||
|
preset.seconds >= effector::kMinDuration && preset.seconds <= effector::kMaxDuration &&
|
||||||
|
preset.fps >= effector::kMinFps && preset.fps <= effector::kMaxFps &&
|
||||||
|
effector::videoPresetByKey(preset.key) == &preset) {
|
||||||
|
++sound;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(sound == static_cast<int>(effector::videoPresets().size()),
|
||||||
|
"every video preset names a known canvas and a valid length and rate");
|
||||||
|
const auto* story = effector::videoPresetByKey("instagram-story");
|
||||||
|
check(story != nullptr && story->canvasFormat == "instagram-story" && story->seconds == 15.0,
|
||||||
|
"the Instagram story preset is a 15 s vertical clip");
|
||||||
|
VideoSettings clip;
|
||||||
|
clip.duration = 15.0;
|
||||||
|
clip.fps = 30;
|
||||||
|
check(clip.frameCount() == 450, "15 s at 30 fps is 450 frames");
|
||||||
|
clip.duration = 0.01;
|
||||||
|
check(clip.frameCount() == 3, "a clip below the shortest length is clamped to it");
|
||||||
|
check(effector::formatSeconds(7.5) == "7.5 s" && effector::formatSeconds(15) == "15 s",
|
||||||
|
"lengths read as 7.5 s and 15 s");
|
||||||
|
check(effector::isVideoPath("a/b.MP4") && effector::isVideoPath("x.webm") &&
|
||||||
|
effector::isVideoPath("x.gif") && !effector::isVideoPath("x.png") &&
|
||||||
|
!effector::isVideoPath("mp4"),
|
||||||
|
"a video is recognised by its extension");
|
||||||
|
|
||||||
|
const auto split = effector::effectByName("channelsplit");
|
||||||
|
const std::vector<effector::Image> sources = {gradient(96, 64, 40)};
|
||||||
|
|
||||||
|
// A still ignores every motion param: the picture is the one it
|
||||||
|
// always was, whatever they are set to.
|
||||||
|
auto calm = split->defaults();
|
||||||
|
calm["split_mode"] = std::string("per_band");
|
||||||
|
auto wild = calm;
|
||||||
|
wild["split_wobble"] = 40L;
|
||||||
|
wild["split_jitter"] = 30L;
|
||||||
|
wild["split_roll"] = 60L;
|
||||||
|
effector::Rng stillA(5);
|
||||||
|
effector::Rng stillB(5);
|
||||||
|
check(sameBytes(split->render(sources, 96, 64, stillA, calm),
|
||||||
|
split->renderFrame(sources, 96, 64, stillB, wild, FrameTime{})),
|
||||||
|
"motion params leave a still untouched");
|
||||||
|
|
||||||
|
check(split->animated(split->defaults()), "channel split moves by default in a video");
|
||||||
|
auto frozen = split->defaults();
|
||||||
|
frozen["split_wobble"] = 0L;
|
||||||
|
frozen["split_jitter"] = 0L;
|
||||||
|
frozen["split_roll"] = 0L;
|
||||||
|
check(!split->animated(frozen), "and holds still with every motion at 0");
|
||||||
|
|
||||||
|
// A moving stage leaves the shared rng in the same state on every
|
||||||
|
// frame, so what comes after it holds still.
|
||||||
|
effector::Rng frameA(9);
|
||||||
|
effector::Rng frameB(9);
|
||||||
|
split->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
|
||||||
|
split->renderFrame(sources, 96, 64, frameB, wild, FrameTime{17, 30, 30});
|
||||||
|
check(frameA.next() == frameB.next(), "every frame leaves the shared rng in one state");
|
||||||
|
|
||||||
|
auto stage = [](const std::string& name, effector::ParamValues values = {}) {
|
||||||
|
ChainStage built;
|
||||||
|
built.effect = effector::effectByName(name);
|
||||||
|
built.values = values.empty() ? built.effect->defaults() : std::move(values);
|
||||||
|
return built;
|
||||||
|
};
|
||||||
|
std::vector<ChainStage> chain = {stage("tiles"), stage("channelsplit", wild), stage("grain")};
|
||||||
|
VideoSettings video;
|
||||||
|
video.enabled = true;
|
||||||
|
video.duration = 1.0;
|
||||||
|
video.fps = 8;
|
||||||
|
|
||||||
|
std::vector<effector::Image> first;
|
||||||
|
const int handed = effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&& frame) {
|
||||||
|
first.push_back(std::move(frame));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
check(handed == 8 && first.size() == 8, "a clip hands out every one of its frames");
|
||||||
|
std::vector<effector::Image> second;
|
||||||
|
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&& frame) {
|
||||||
|
second.push_back(std::move(frame));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
bool repeatable = second.size() == first.size();
|
||||||
|
for (std::size_t i = 0; repeatable && i < first.size(); ++i) {
|
||||||
|
repeatable = sameBytes(first[i], second[i]);
|
||||||
|
}
|
||||||
|
check(repeatable, "a clip renders byte for byte again from its seed");
|
||||||
|
|
||||||
|
// The still stages before the moving one are rendered once and
|
||||||
|
// reused; that shortcut must give exactly what a frame on its own is.
|
||||||
|
bool exact = true;
|
||||||
|
for (const int index : {0, 3, 7}) {
|
||||||
|
effector::Rng rng(21);
|
||||||
|
const auto alone = effector::renderChain(chain, sources, 80, 48, rng, {},
|
||||||
|
FrameTime{index, 8, 8});
|
||||||
|
exact = exact && sameBytes(alone, first[static_cast<std::size_t>(index)]);
|
||||||
|
}
|
||||||
|
check(exact, "a clip's frame is exactly that frame rendered on its own");
|
||||||
|
check(!sameBytes(first[0], first[3]) && !sameBytes(first[3], first[7]),
|
||||||
|
"frames of a moving chain differ");
|
||||||
|
|
||||||
|
std::vector<ChainStage> steady = {stage("tiles"), stage("channelsplit", frozen), stage("grain")};
|
||||||
|
std::vector<effector::Image> held;
|
||||||
|
effector::renderFrames(steady, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&& frame) {
|
||||||
|
held.push_back(std::move(frame));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
bool same = held.size() == 8;
|
||||||
|
for (const auto& frame : held) same = same && sameBytes(frame, held[0]);
|
||||||
|
effector::Rng stillRng(21);
|
||||||
|
check(same && sameBytes(held[0], effector::renderChain(steady, sources, 80, 48, stillRng)),
|
||||||
|
"a chain that does not move renders its still on every frame");
|
||||||
|
|
||||||
|
int seen = 0;
|
||||||
|
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&&) { return ++seen < 3; });
|
||||||
|
check(seen == 3, "returning false stops a clip early");
|
||||||
|
|
||||||
|
chain[1].enabled = false;
|
||||||
|
std::vector<effector::Image> skipped;
|
||||||
|
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&& frame) {
|
||||||
|
skipped.push_back(std::move(frame));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
check(skipped.size() == 8 && sameBytes(skipped[0], skipped[7]),
|
||||||
|
"with the moving stage switched off the clip holds still");
|
||||||
|
|
||||||
|
const auto big = gradient(300, 120, 10);
|
||||||
|
const auto small = big.scaledToFit(100);
|
||||||
|
check(small.width() == 100 && small.height() == 40, "a preview frame keeps its shape");
|
||||||
|
check(big.scaledToFit(400).width() == 300, "a frame already small enough is left alone");
|
||||||
|
|
||||||
|
// The project file: a video says so, a still says nothing about time.
|
||||||
|
effector::Project project;
|
||||||
|
project.stages = {stage("channelsplit", wild)};
|
||||||
|
const auto stillJson = effector::projectToJson(project);
|
||||||
|
check(stillJson.find("\"video\"") == std::string::npos, "a still project writes no video");
|
||||||
|
check(!effector::projectFromJson(stillJson).video.enabled, "and reads back as a still");
|
||||||
|
project.video.enabled = true;
|
||||||
|
project.video.duration = 7.5;
|
||||||
|
project.video.fps = 24;
|
||||||
|
project.video.preset = "instagram-story";
|
||||||
|
const auto back = effector::projectFromJson(effector::projectToJson(project));
|
||||||
|
check(back.video.enabled && back.video.duration == 7.5 && back.video.fps == 24 &&
|
||||||
|
back.video.preset == "instagram-story",
|
||||||
|
"a video project keeps its length, rate and preset");
|
||||||
|
const auto motion = back.stages.at(0).values;
|
||||||
|
check(effector::paramInt(motion, "split_jitter", 0) == 30 &&
|
||||||
|
effector::paramInt(motion, "split_roll", 0) == 60,
|
||||||
|
"and its stages keep their motion");
|
||||||
|
const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": ";
|
||||||
|
check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(),
|
||||||
|
"an unknown preset reads as custom");
|
||||||
|
bool rejected = false;
|
||||||
|
try {
|
||||||
|
effector::projectFromJson(base + "2, \"fps\": 500}}");
|
||||||
|
} catch (const effector::EffectError&) {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
check(rejected, "a frame rate out of range is refused");
|
||||||
|
|
||||||
|
// The command line.
|
||||||
|
const auto preset = parsedOptions({"--video-preset", "instagram-story", "-o", "x.mp4"});
|
||||||
|
check(preset && preset->video.enabled && preset->width == 1080 && preset->height == 1920 &&
|
||||||
|
preset->video.duration == 15.0 && preset->video.fps == 30 &&
|
||||||
|
preset->video.preset == "instagram-story",
|
||||||
|
"--video-preset sets the canvas, length and rate");
|
||||||
|
const auto shorter = parsedOptions({"--duration", "5", "--video-preset", "instagram-story"});
|
||||||
|
check(shorter && shorter->video.duration == 5.0 && shorter->height == 1920 &&
|
||||||
|
shorter->video.preset.empty(),
|
||||||
|
"a length given alongside wins and leaves the preset custom");
|
||||||
|
const auto sized = parsedOptions({"--video-preset", "hd-clip", "-s", "320x240"});
|
||||||
|
check(sized && sized->width == 320 && sized->format.empty(),
|
||||||
|
"a size given alongside wins over the preset's canvas");
|
||||||
|
check(!parsedOptions({"--fps", "0"}) && !parsedOptions({"--duration", "abc"}) &&
|
||||||
|
!parsedOptions({"--duration", "400"}) && !parsedOptions({"--video-preset", "vhs"}),
|
||||||
|
"a bad rate, length or preset is refused");
|
||||||
|
check(!parsedOptions({"-o", "x.png"})->video.enabled, "without them the output is a still");
|
||||||
|
|
||||||
|
bool wrongName = false;
|
||||||
|
try {
|
||||||
|
effector::VideoWriter writer("clip.png", 16, 16, 30);
|
||||||
|
} catch (const effector::EffectError&) {
|
||||||
|
wrongName = true;
|
||||||
|
}
|
||||||
|
check(wrongName, "a video is not written under an image's name");
|
||||||
|
|
||||||
|
if (effector::ffmpegAvailable()) {
|
||||||
|
const auto dir = std::filesystem::temp_directory_path() / "effector-video-test";
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
const auto file = (dir / "clip.mp4").string();
|
||||||
|
{
|
||||||
|
// Odd sides: the writer pads them for the encoder.
|
||||||
|
effector::VideoWriter writer(file, 17, 9, 12);
|
||||||
|
for (int i = 0; i < 3; ++i) writer.write(gradient(17, 9, 40 * i));
|
||||||
|
writer.finish();
|
||||||
|
}
|
||||||
|
check(std::filesystem::exists(file) && std::filesystem::file_size(file) > 0,
|
||||||
|
"ffmpeg writes an .mp4 of odd size");
|
||||||
|
const auto dropped = (dir / "dropped.webm").string();
|
||||||
|
{
|
||||||
|
effector::VideoWriter writer(dropped, 16, 16, 12);
|
||||||
|
writer.write(gradient(16, 16, 10));
|
||||||
|
writer.cancel();
|
||||||
|
}
|
||||||
|
check(!std::filesystem::exists(dropped), "a cancelled video leaves no file behind");
|
||||||
|
bool wrongSize = false;
|
||||||
|
try {
|
||||||
|
effector::VideoWriter writer((dir / "sized.mp4").string(), 16, 16, 12);
|
||||||
|
writer.write(gradient(8, 8, 10));
|
||||||
|
} catch (const effector::EffectError&) {
|
||||||
|
wrongSize = true;
|
||||||
|
}
|
||||||
|
check(wrongSize && !std::filesystem::exists(dir / "sized.mp4"),
|
||||||
|
"a frame of the wrong size is refused and the file dropped");
|
||||||
|
std::filesystem::remove_all(dir);
|
||||||
|
} else {
|
||||||
|
std::cout << " skip writing a video (ffmpeg is not installed)\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
std::cout << "version\n";
|
std::cout << "version\n";
|
||||||
{
|
{
|
||||||
// A release is the version in CMakeLists.txt and its entry at the top
|
// A release is the version in CMakeLists.txt and its entry at the top
|
||||||
|
|||||||
@@ -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");
|
"opened projects are listed newest first, a shared name with its folder");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visible labels in the params grid saying `text`.
|
||||||
|
bool showsLabel(Gtk::Widget& root, const std::string& text) {
|
||||||
|
for (auto* label : all<Gtk::Label>(root)) {
|
||||||
|
if (label->get_text() == text && label->is_drawable()) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||||
|
std::cout << "video\n";
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
const auto path = dir / "moving.json";
|
||||||
|
effector::Project project;
|
||||||
|
project.stages.push_back({effector::effectByName("channelsplit"), {}}); // needs no sources
|
||||||
|
effector::saveProject(path.string(), project);
|
||||||
|
window.activate_action("win.open-recent",
|
||||||
|
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||||
|
pump(300);
|
||||||
|
|
||||||
|
auto* stack = all<Gtk::Stack>(window).front();
|
||||||
|
auto* toggle = named<Gtk::Switch>(window, "video");
|
||||||
|
auto* preset = named<Gtk::DropDown>(window, "video-preset");
|
||||||
|
auto* duration = named<Gtk::SpinButton>(window, "video-duration");
|
||||||
|
auto* width = named<Gtk::SpinButton>(window, "canvas-width");
|
||||||
|
auto* height = named<Gtk::SpinButton>(window, "canvas-height");
|
||||||
|
auto* playback = named<Gtk::Box>(window, "playback");
|
||||||
|
check(toggle != nullptr && preset != nullptr && duration != nullptr && width != nullptr &&
|
||||||
|
height != nullptr && playback != nullptr,
|
||||||
|
"the video switch, its settings and the playback bar are there");
|
||||||
|
if (toggle == nullptr || preset == nullptr || duration == nullptr || width == nullptr ||
|
||||||
|
height == nullptr || playback == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
check(isAncestor(*stack->get_child_by_name("setup"), *toggle), "the video switch is on Setup");
|
||||||
|
check(!toggle->get_active() && !preset->is_drawable() && !playback->get_visible(),
|
||||||
|
"a still project shows no video settings and no playback");
|
||||||
|
check(!showsLabel(window, "Wobble"), "and no motion params for the channel split");
|
||||||
|
|
||||||
|
toggle->set_active(true);
|
||||||
|
pump(200);
|
||||||
|
check(showsLabel(window, "Wobble") && showsLabel(window, "Tracking bar"),
|
||||||
|
"a video shows the channel split's motion params");
|
||||||
|
stack->set_visible_child("setup");
|
||||||
|
pump(100);
|
||||||
|
check(preset->is_drawable() && duration->is_drawable(), "and its preset and length");
|
||||||
|
|
||||||
|
preset->set_selected(1); // Instagram story, the first preset
|
||||||
|
pump(200);
|
||||||
|
check(width->get_value() == 1080 && height->get_value() == 1920 && duration->get_value() == 15,
|
||||||
|
"Instagram story picks a 1080 × 1920 canvas and 15 s");
|
||||||
|
duration->set_value(0.5);
|
||||||
|
pump(100);
|
||||||
|
check(preset->get_selected() == 0, "changing the length drops the preset to Custom");
|
||||||
|
|
||||||
|
for (int waited = 0; waited < 200 && !playback->get_visible(); ++waited) pump(50);
|
||||||
|
check(playback->get_visible(), "the clip is rendered and plays in the preview");
|
||||||
|
auto* scale = all<Gtk::Scale>(*playback).empty() ? nullptr : all<Gtk::Scale>(*playback).front();
|
||||||
|
const double before = scale != nullptr ? scale->get_value() : -1.0;
|
||||||
|
pump(250);
|
||||||
|
check(scale != nullptr && scale->get_value() != before, "and it moves on by itself");
|
||||||
|
|
||||||
|
window.activate_action("win.save-project");
|
||||||
|
pump(100);
|
||||||
|
const auto saved = effector::loadProject(path.string());
|
||||||
|
check(saved.video.enabled && saved.video.duration == 0.5 && saved.width == 1080,
|
||||||
|
"the project is saved as a video");
|
||||||
|
|
||||||
|
toggle->set_active(false);
|
||||||
|
pump(300);
|
||||||
|
check(!playback->get_visible() && !showsLabel(window, "Wobble"),
|
||||||
|
"switched back to a still, the playback and motion params go");
|
||||||
|
stack->set_visible_child("chain");
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
@@ -467,6 +541,7 @@ int main(int argc, char** argv) {
|
|||||||
runTests(*window);
|
runTests(*window);
|
||||||
testHelpWindows(*window);
|
testHelpWindows(*window);
|
||||||
testRecentProjects(*window, dataDir / "projects");
|
testRecentProjects(*window, dataDir / "projects");
|
||||||
|
testVideo(*window, dataDir / "video");
|
||||||
app->remove_window(*window);
|
app->remove_window(*window);
|
||||||
});
|
});
|
||||||
const int status = app->run(argc > 0 ? 1 : 0, argv);
|
const int status = app->run(argc > 0 ? 1 : 0, argv);
|
||||||
|
|||||||
Reference in New Issue
Block a user