Compare commits
21
Commits
v0.5.2
..
3c8c5bed8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c8c5bed8c | ||
|
|
ef8f728126 | ||
|
|
5402a50742 | ||
|
|
f9fe6e0fa5 | ||
|
|
8acf85ddb8 | ||
|
|
cfaa773cf7 | ||
|
|
53dca6b4b8 | ||
|
|
4b407281e2 | ||
|
|
a7cb2505d7 | ||
|
|
ce0207c41f | ||
|
|
37c02a8a7b | ||
|
|
0377cd4abb | ||
|
|
370d3e550e | ||
|
|
8537a3cf0a | ||
|
|
ac8b62208c | ||
|
|
6df4bcb079 | ||
|
|
1b30a079e6 | ||
|
|
cdaadffc89 | ||
|
|
ea59db5acd | ||
|
|
385617781e | ||
|
|
c069de3566 |
+142
@@ -4,6 +4,148 @@ Every release of Effector, newest first. The version lives in one place, the
|
||||
`project()` line of `CMakeLists.txt`, and each release is a `vX.Y.Z` tag on the
|
||||
commit that made it. The interface shows this file under Changelog in its menu.
|
||||
|
||||
## [0.8.2] - 2026-09-30
|
||||
|
||||
### Changed
|
||||
- The Swap rate of Tile swap is a slider marked with its range, and goes
|
||||
down in steps of 0.01 rather than 0.1, so a clip can swap its tiles much
|
||||
more slowly. Its top end is now 5 swaps a second (it was 30).
|
||||
- At a low Swap rate, Tile swap now moves only some of its tiles, fewer the
|
||||
lower the rate, instead of swapping every tile at least twice a clip (or,
|
||||
below that, none at all). Each step down the bottom of the slider now
|
||||
looks slower than the one above it.
|
||||
- The Swap rate slider is curved: its low end takes up most of its length
|
||||
(1 swap a second sits just past the middle), so slow rates are easy to set.
|
||||
|
||||
## [0.8.1] - 2026-09-26
|
||||
|
||||
### Added
|
||||
- The Font of a text stage (and of a text style) is picked from the fonts
|
||||
installed on this machine with a font chooser, rather than typed by name.
|
||||
The command line still takes any family, as `--font-family "DejaVu Serif"`.
|
||||
- Undo and redo: Ctrl+Z steps back through the edits to the project (the
|
||||
chain, params, styles, canvas, video settings, seed and images), and Ctrl+Y
|
||||
or Ctrl+Shift+Z steps forward again; both are in the menu as well. A burst
|
||||
of edits, like a spin held down or a word typed, is one step.
|
||||
|
||||
## [0.8.0] - 2026-09-26
|
||||
|
||||
### Added
|
||||
- Tile swap, a video effect: tiles cut from the photographs are laid over the
|
||||
canvas as Tiles lays them, and each one is swapped for another cut at a
|
||||
moment of its own, so the mosaic keeps changing piece by piece. On the
|
||||
command line it is `-e tileswap` with `--tileswap-rate`,
|
||||
`--tileswap-layout` and the rest.
|
||||
- Echo tunnel, a video effect: the picture nested inside itself as Feedback
|
||||
nests it, flying into the echoes (or backing out of them) without end, a
|
||||
new echo fading in at the far end as the nearest melts into the picture.
|
||||
On the command line it is `-e tunnel` with `--tunnel-speed`,
|
||||
`--tunnel-scale`, `--tunnel-direction` and the rest.
|
||||
- Warp flow, a video effect: the picture pushed or swirled through a field
|
||||
of noise as Warp does, with the field drifting so the picture ripples like
|
||||
heat haze or water. On the command line it is `-e flow` with
|
||||
`--flow-strength`, `--flow-travel`, `--flow-speed` and the rest.
|
||||
- Sort sweep, a video effect: runs of pixels sorted as Pixel sort sorts
|
||||
them, only within a window of brightness that sweeps from dark to bright
|
||||
and back, so the streaks spread over one part of the picture after
|
||||
another. On the command line it is `-e sweep` with `--sweep-from`,
|
||||
`--sweep-to`, `--sweep-width`, `--sweep-speed` and the rest.
|
||||
- Film gate, a video effect: film running through a projector, with grain
|
||||
drawn anew on every frame as Film grain draws it, the picture swaying in
|
||||
the gate, flicker, dust and scratches. On the command line it is
|
||||
`-e filmgate` with `--gate-grain`, `--gate-weave`, `--gate-flicker`,
|
||||
`--gate-dust` and `--gate-scratches`.
|
||||
- Datamosh, a video effect: blocks of the picture break as Corrupt breaks
|
||||
them, each for a stretch of the clip before it heals, and a displaced
|
||||
block's picture smears along while it shows. On the command line it is
|
||||
`-e datamosh` with `--mosh-rate`, `--mosh-hold`, `--mosh-drift`,
|
||||
`--mosh-speed` and the rest.
|
||||
- Strip roll, a video effect: the canvas cut into strips of the photographs
|
||||
as Scanlines cuts it, and the picture in every strip rolling up or down
|
||||
through its image, or sliding sideways, while the strips stay put. On the
|
||||
command line it is `-e striproll` with `--roll-speed`, `--roll-axis`,
|
||||
`--roll-direction` and the rest.
|
||||
- Focus pull, a video effect: a lens being focused, either the whole
|
||||
picture blurring as Blur blurs it and coming back sharp, or a band of
|
||||
sharpness moving down the picture and back up. On the command line it is
|
||||
`-e focuspull` with `--focus-mode`, `--focus-radius`, `--focus-band` and
|
||||
`--focus-speed`.
|
||||
- A Quality choice under Video on Setup: Small file, Standard or High, for
|
||||
how the saved video is encoded. It is kept in the project, and on the
|
||||
command line it is `--video-quality small|standard|high`. Standard writes
|
||||
videos exactly as before.
|
||||
|
||||
### Changed
|
||||
- Loading images shows its first pass too: the progress window counts the
|
||||
files as their sizes are checked ("Checking sizes: 3 of 40") before it
|
||||
counts the decodes, the headers are read in parallel, and Cancel works
|
||||
during that pass as well. The command line shows a `sizing` line likewise.
|
||||
- The video preview plays sooner: it renders every third frame first and
|
||||
plays that draft, each frame held for three, then fills in the frames
|
||||
between while it plays. The finished preview and the saved video are
|
||||
unchanged.
|
||||
- Save opens in the project's folder, and after the first save it offers the
|
||||
last saved file's name with a number counted on (`city.png`, then
|
||||
`city-1.png`, `city-2.png`), skipping names already taken.
|
||||
- The Add effect menu gives the video effects more room before their list
|
||||
scrolls, now that there are more of them.
|
||||
|
||||
## [0.7.0] - 2026-09-26
|
||||
|
||||
### Added
|
||||
- Effects are image effects or video effects. The Add effect menu lists them
|
||||
in two sections, each under its own heading, and offers video effects only
|
||||
in a video project. In the chain a video stage has a film icon, and in a
|
||||
still it is dimmed and skipped, like a stage that is switched off.
|
||||
- VHS tracking, the first video effect: the colour planes sway and shake, and
|
||||
a tracking bar rolls down the picture. On the command line it is
|
||||
`-e tracking` with `--tracking-wobble`, `--tracking-jitter`, `--tracking-bar`
|
||||
and the rest.
|
||||
- `--help` lists the video effects under a heading of their own, and
|
||||
`--list-effects` marks them. A video effect given for a still is skipped
|
||||
with a warning.
|
||||
|
||||
### Changed
|
||||
- Channel split is an image effect again. Its motion (Wobble, Jitter, Tracking
|
||||
bar) has moved to VHS tracking, so a 0.6 video project that moved its channel
|
||||
split needs a VHS tracking stage after it. Stills are unchanged.
|
||||
|
||||
## [0.6.1] - 2026-09-26
|
||||
|
||||
### Fixed
|
||||
- Opening a project whose images would not fit in memory no longer gets
|
||||
Effector killed by the system. The images are measured before any is read,
|
||||
and a batch that would not fit is refused with a message saying how much it
|
||||
needs; the images stay in the project, so saving it keeps them.
|
||||
- A canvas too large to render in the free memory is refused the same way,
|
||||
in the interface and on the command line, instead of taking the machine's
|
||||
memory down with it.
|
||||
|
||||
## [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
|
||||
|
||||
### 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
|
||||
per-test filter: run `./build/tests/effector_tests` directly to see the section
|
||||
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
|
||||
`image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment
|
||||
`image io`, `batch loading`, `video`, `version`) and the `ok`/`FAIL` lines. The
|
||||
`video` section writes real files through `ffmpeg` when it is on the PATH and
|
||||
prints a `skip` line otherwise. To iterate on one area, comment
|
||||
out the other blocks in `tests/core_tests.cpp` or add a new
|
||||
`std::cout << "section\n"` block — checks are plain
|
||||
`check(condition, "description")` calls and `main()` returns the failure count.
|
||||
@@ -71,11 +73,19 @@ GUI or the parser — add a `ParamSpec` instead. A `Path` param names a file on
|
||||
disk; like a source path it is written relative to the project folder. A `Text`
|
||||
param is free-form text the user types, and a `Color` param is a colour written
|
||||
`#rrggbb` (`parseColor`/`formatColor`/`paramColor` in `src/core/params.hpp`),
|
||||
shown as a colour button in the interface. Values
|
||||
shown as a colour button in the interface. A `Font` param is a font family
|
||||
named as the machine names it ("DejaVu Serif"), a family-only font chooser in
|
||||
the interface and a plain string everywhere else. Values
|
||||
travel as `ParamValues` (a
|
||||
`map<string, variant<long,double,bool,string>>`), read back with
|
||||
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
|
||||
|
||||
An Int or Double param marked `.slider()` is shown in
|
||||
the interface as a slider with its minimum and maximum marked instead of a spin
|
||||
button; nothing else changes. `.slider(curve)` bends it: the scale runs over the
|
||||
slider's travel and `sliderValue`/`sliderPosition` map that onto the number as
|
||||
`position^curve`, so a curve above 1 gives the low end more room.
|
||||
|
||||
A param can also be marked `.styled()`, which says a *named style* carries it
|
||||
rather than the stage it sits on: the font and the colour of a text stage, but
|
||||
never its words or its placement. The pool a style comes from is named by
|
||||
@@ -93,7 +103,8 @@ first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`)
|
||||
just asks the first stage — a chain led by blur renders with no images loaded at
|
||||
all, and both front-ends gate on that rather than on "are there any images".
|
||||
|
||||
Adding an effect: new `src/effects/<name>.{hpp,cpp}` subclassing `Effect`, one
|
||||
Adding an effect: new `src/effects/<name>.{hpp,cpp}` subclassing `Effect` (or
|
||||
`VideoEffect` for one that moves, see "Video projects"), one
|
||||
line in the registry list in `src/core/registry.cpp`, one line in the
|
||||
`effector_core` sources in `CMakeLists.txt`. Nothing else — it shows up in the
|
||||
CLI, in the chain menu and as a cascade stage on its own.
|
||||
@@ -170,11 +181,91 @@ the `effector` application name on each entry; the GUI tests point
|
||||
`XDG_DATA_HOME` at a temporary folder so they never touch the user's list. Opening one replaces the sources, so a decode still in flight
|
||||
is discarded rather than mixed into the new project (`dropLoadedImages_`).
|
||||
|
||||
Undo and redo (Ctrl+Z, Ctrl+Y / Ctrl+Shift+Z, caught in the capture phase so
|
||||
an entry never undoes its own text instead) keep whole snapshots of the
|
||||
project: `captureState` is `currentProject()` plus the `sources_` list itself,
|
||||
so stepping back never decodes again, compared by `projectToJson`. There is
|
||||
nothing to call per edit: `requestRender()` calls `noteChange()`, which commits
|
||||
a step once the controls have been still for `kHistoryPause`; an edit that
|
||||
renders nothing (renaming a style) calls `noteChange()` itself. New and Open
|
||||
start the history over, after the project's images are in
|
||||
(`historyAfterLoad_`).
|
||||
|
||||
### 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.
|
||||
|
||||
Effects are of two kinds, and `Effect::video()` says which. An *image effect*
|
||||
(the default) makes a picture and holds still in a clip; it overrides only
|
||||
`render`, which `Effect::renderFrame` falls back to. A *video effect* is motion
|
||||
and nothing else. `enabledStages(stages, video)` is where the kinds meet the
|
||||
chain: in a still, a video stage is left out exactly like a disabled one,
|
||||
drawing nothing from the rng. So `renderChain` (by `frame.video()`),
|
||||
`chainNeedsSources`, `chainTitle` and `chainSlug` all take whether the render is
|
||||
a video. The interface builds the split from `video()` alone: two sections in
|
||||
the Add effect menu, with the video section insensitive in a still, a
|
||||
`camera-video-symbolic` icon on a video stage's row, and the row dimmed while a
|
||||
still skips it. The CLI groups `--help` the same way and warns when it skips one.
|
||||
|
||||
A video effect subclasses `VideoEffect` (`src/effects/video_effect.hpp`), not
|
||||
`Effect`, and a transform writes only `animate(input, values, const Moment& at)`. The base
|
||||
keeps the rules. It takes the input crop and exactly one value from the shared
|
||||
rng, the same on every frame, and hands out randomness through the `Moment`:
|
||||
`at.steady()` gives the same draws every frame (phases, directions),
|
||||
`at.fresh()` gives new draws per frame (shake, noise). It hands out time as
|
||||
`at.cycle(perSecond)`, rounded through `loopRate` to a whole number of cycles so
|
||||
the clip loops without a jump. `TrackingEffect` (`src/effects/tracking.cpp`,
|
||||
"VHS tracking") is the reference: copy its layout (params, a `Settings` struct,
|
||||
steady draws, time, fresh draws, one parallel pixel pass) for the next one.
|
||||
A video effect that builds its frames out of the sources rather than
|
||||
transforming a crop answers `needsSources()` and writes `compose(sources,
|
||||
width, height, values, at)` instead of `animate`: the base then takes no crop,
|
||||
only the seed, and like an image effect it sees the photographs when it leads
|
||||
the chain and the stage before it otherwise. `TileSwapEffect`
|
||||
(`src/effects/tileswap.cpp`, "Tile swap") is the reference for that kind; it
|
||||
shares `Tile`/`planSpans`/`paintTiles` with the tiles effect through
|
||||
`src/effects/tile_plan.hpp`.
|
||||
Registering it is the same one line in `registry.cpp`, after the comment that
|
||||
starts the video effects. There is no per-param video flag: motion is a video
|
||||
effect of its own, never extra params on an image effect.
|
||||
|
||||
`renderFrames` (`src/core/chain.cpp`) renders a clip. Every frame starts from
|
||||
`Rng(seed)`, so an image effect draws the same values every frame and holds
|
||||
still, and because `VideoEffect` leaves the shared rng in one state, the stages
|
||||
after a video effect hold still too. The stages before the first video effect
|
||||
are rendered once and their image and rng state reused. The `video` tests
|
||||
assert that frame k is exactly `renderChain(..., FrameTime{k, ...})`, so that
|
||||
shortcut can never change output.
|
||||
The determinism contract covers the frames; the encoded file is whatever the
|
||||
machine's ffmpeg makes of them.
|
||||
|
||||
`VideoWriter` pipes raw `rgb24` frames into the `ffmpeg` program
|
||||
(`posix_spawnp` with an argv — no shell; `SIGPIPE` ignored; stderr captured for
|
||||
the error message; codec by extension). `cancel()` SIGKILLs it — SIGTERM makes
|
||||
ffmpeg flush its encoder first — and removes the file; a writer destroyed
|
||||
unfinished cancels itself.
|
||||
|
||||
In the interface, a video render (`startRender`) renders every frame at full
|
||||
size, shrinks each (`Image::scaledToFit`, sized by `clipSide` to a memory
|
||||
budget) and hands them over through `pendingFrames_`/`clipProgress_`; the
|
||||
clip on screen keeps looping until the new one is complete, and
|
||||
`renderCancel_` stops a clip whose settings went stale. Save As… renders again
|
||||
at full size on `exporter_` into a `VideoWriter`, with an `exportWindow_`
|
||||
progress window (its `exportBar_` nulled in `closeExportWindow()`). Under Xvfb
|
||||
the GTK file dialog goes through the desktop portal and may open on the real
|
||||
session instead; set `GDK_DEBUG=no-portals` to keep it in the Xvfb display.
|
||||
|
||||
### Two link targets, deliberately
|
||||
|
||||
`effector_core` (static) holds image IO, params, effects, rng and links only
|
||||
`gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need
|
||||
no display) + Threads — **no gtkmm, no display**. `effector_gui` (static) adds
|
||||
`effector_core` (static) holds image IO, params, effects, rng, video writing
|
||||
and links only `gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango
|
||||
and Cairo need no display) + Threads — **no gtkmm, no display**; `ffmpeg` is a
|
||||
program it runs, not a library it links. `effector_gui` (static) adds
|
||||
`src/cli/` and `src/gui/` and links gtkmm, and the `effector` executable is
|
||||
`src/main.cpp` on top of it; the GUI tests link `effector_gui` too. Keep GTK out of
|
||||
`src/core/` and `src/effects/`; the tests link the core alone and rely on this.
|
||||
@@ -214,6 +305,21 @@ then copies pixels in parallel.
|
||||
copy-on-write via `mutateSources()`, so a render in flight can never race with
|
||||
an add/remove.
|
||||
|
||||
### Memory is checked before it is taken
|
||||
|
||||
Running out of memory does not fail cleanly on Linux: the kernel's OOM killer
|
||||
ends the program (and often the terminal or session it runs in), leaving no
|
||||
core dump. So bulk allocations are sized first and refused with an
|
||||
`OutOfMemoryError` (`src/core/memory.hpp`) when they would take more than three
|
||||
quarters of `availableMemory()` — the system's MemAvailable, or less under a
|
||||
cgroup `memory.max`. `loadImages` sizes the whole batch from the file headers
|
||||
(`imageFilePixels`) and refuses it before decoding anything, and holds the
|
||||
decodes it runs at once to what is left; `renderChain` and `renderFrames`
|
||||
refuse a canvas at 24 bytes a pixel (13 measured, plus the interface's copy),
|
||||
plus any picture a `Path` param loads. The interface keeps a refused batch's
|
||||
paths in `refusedSources_`, so saving the project never drops them; the tests
|
||||
fake a small machine with `limitAvailableMemory`.
|
||||
|
||||
### Image buffer
|
||||
|
||||
`Image` is tightly packed 8-bit RGB with stride exactly `width * 3` — effects
|
||||
|
||||
+18
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(effector VERSION 0.5.2 LANGUAGES CXX)
|
||||
project(effector VERSION 0.8.2 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -35,16 +35,22 @@ add_library(effector_core STATIC
|
||||
src/core/chain.cpp
|
||||
src/core/effect.cpp
|
||||
src/core/image.cpp
|
||||
src/core/memory.cpp
|
||||
src/core/params.cpp
|
||||
src/core/project.cpp
|
||||
src/core/registry.cpp
|
||||
src/core/rng.cpp
|
||||
src/core/style.cpp
|
||||
src/core/video.cpp
|
||||
src/effects/bitcrush.cpp
|
||||
src/effects/blur.cpp
|
||||
src/effects/channelsplit.cpp
|
||||
src/effects/corrupt.cpp
|
||||
src/effects/datamosh.cpp
|
||||
src/effects/feedback.cpp
|
||||
src/effects/filmgate.cpp
|
||||
src/effects/focuspull.cpp
|
||||
src/effects/flow.cpp
|
||||
src/effects/grain.cpp
|
||||
src/effects/monochrome.cpp
|
||||
src/effects/overlay.cpp
|
||||
@@ -52,8 +58,14 @@ add_library(effector_core STATIC
|
||||
src/effects/scanlines.cpp
|
||||
src/effects/shatter.cpp
|
||||
src/effects/slitshift.cpp
|
||||
src/effects/striproll.cpp
|
||||
src/effects/sweep.cpp
|
||||
src/effects/text.cpp
|
||||
src/effects/tiles.cpp
|
||||
src/effects/tileswap.cpp
|
||||
src/effects/tracking.cpp
|
||||
src/effects/tunnel.cpp
|
||||
src/effects/video_effect.cpp
|
||||
src/effects/warp.cpp
|
||||
)
|
||||
target_include_directories(effector_core PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
||||
@@ -70,6 +82,9 @@ add_library(effector_gui STATIC
|
||||
)
|
||||
target_link_libraries(effector_gui PUBLIC effector_core PkgConfig::GTKMM)
|
||||
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 --------------------------------------------------------------
|
||||
add_executable(effector src/main.cpp)
|
||||
@@ -79,6 +94,8 @@ target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic)
|
||||
install(TARGETS effector RUNTIME DESTINATION bin)
|
||||
# The launcher entry, so the interface shows up in the desktop's app menu.
|
||||
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 --------------------------------------------------------------------
|
||||
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
|
||||
`gdk-pixbuf-2.0` development packages. On Arch: `gcc cmake ninja gtkmm-4.0`.
|
||||
Writing videos needs the `ffmpeg` program at run time (on Arch: `ffmpeg`);
|
||||
without it everything else works and the video tests are skipped.
|
||||
|
||||
```bash
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
@@ -58,6 +60,37 @@ Custom hands them back, keeping whatever the format last worked out to. A
|
||||
format is saved with the project, so an A4 project reopens as A4 rather than as
|
||||
the pixels it happened to be.
|
||||
|
||||
### Video
|
||||
|
||||
A project can be a video clip instead of a still: switch **Video** on under
|
||||
Setup and give it a length and a frame rate, or pick a preset that sets the
|
||||
canvas too — Instagram story (1080×1920, 15 s), Instagram reel (30 s),
|
||||
Instagram feed video, TikTok / YouTube Short, Square loop (6 s) and Full HD
|
||||
clip. The preview renders the whole clip first, shrunk to fit in memory, and
|
||||
then loops it, with play/pause and a scale to look at any one frame. **Save
|
||||
As…** then writes the video at full size through `ffmpeg`, which has to be
|
||||
installed: `.mp4`, `.mov` and `.mkv` as H.264, `.webm` as VP9, `.gif` as a
|
||||
palette GIF.
|
||||
|
||||
```bash
|
||||
build/effector -o story.mp4 --video-preset instagram-story -e channelsplit -e tracking --seed 1 photo.jpg
|
||||
build/effector -o loop.gif -s 480x480 --duration 3 --fps 15 -e tracking --tracking-bar 40 photo.jpg
|
||||
```
|
||||
|
||||
`--duration SECONDS` and `--fps N` make any run a video; a preset fills in
|
||||
whatever was not given alongside it.
|
||||
|
||||
Effects come in two kinds. **Image effects** make a picture and hold still in a
|
||||
clip. **Video effects** are motion: the Add effect menu lists them under a
|
||||
heading of their own, and they can only be added once the project is a video.
|
||||
In a still project a video stage is marked as skipped and the render leaves it
|
||||
out, as it would a stage that is switched off. For now there is one video
|
||||
effect, **VHS tracking**: the colour planes sway and shake, and a tracking bar
|
||||
rolls down the picture, like a worn tape on an old tube. Put it after the
|
||||
image effects it should shake. Every frame starts from the same seed, so the
|
||||
image effects hold still while a video effect moves them, and a clip renders
|
||||
frame for frame the same again.
|
||||
|
||||
Any option can also be passed when launching the interface; it becomes the
|
||||
starting state (images, canvas size, effect settings, seed).
|
||||
|
||||
|
||||
@@ -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
|
||||
Comment=Cascade glitch and collage effects over your photographs
|
||||
Exec=effector %F
|
||||
Icon=applications-graphics
|
||||
Icon=org.effector.Effector
|
||||
Terminal=false
|
||||
Categories=Graphics;2DGraphics;RasterGraphics;GTK;
|
||||
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
|
||||
# test checks it). From this folder: makepkg -si
|
||||
pkgname=effector
|
||||
pkgver=0.5.2
|
||||
pkgver=0.8.2
|
||||
pkgrel=1
|
||||
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
||||
arch=('x86_64' 'aarch64')
|
||||
url='https://git.plan9.uber.space/hans/effector'
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('gtkmm-4.0' 'gdk-pixbuf2' 'pango' 'cairo' 'glib2' 'glibc' 'gcc-libs')
|
||||
optdepends=('ffmpeg: write video projects')
|
||||
makedepends=('cmake' 'ninja' 'git')
|
||||
checkdepends=('xorg-server-xvfb')
|
||||
source=("$pkgname::git+file://${startdir%/packaging/arch}#tag=v$pkgver")
|
||||
|
||||
+98
-8
@@ -55,6 +55,11 @@ std::string requireValue(int argc, char** argv, int& i, const std::string& flag)
|
||||
|
||||
Options parseOptions(int argc, char** argv) {
|
||||
Options options;
|
||||
// What was given explicitly, so that a --video-preset fills in only the
|
||||
// rest, whichever order the flags come in.
|
||||
bool sizeGiven = false;
|
||||
bool durationGiven = false;
|
||||
bool fpsGiven = false;
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string arg = argv[i];
|
||||
@@ -73,6 +78,7 @@ Options parseOptions(int argc, char** argv) {
|
||||
options.output = requireValue(argc, argv, i, arg);
|
||||
} else if (arg == "-s" || arg == "--size") {
|
||||
const std::string value = requireValue(argc, argv, i, arg);
|
||||
sizeGiven = true;
|
||||
if (!parseSize(value, options.width, options.height)) {
|
||||
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with "
|
||||
"each side between " + std::to_string(kMinCanvasSide) + " and " +
|
||||
@@ -92,6 +98,47 @@ Options parseOptions(int argc, char** argv) {
|
||||
std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi));
|
||||
}
|
||||
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 == "--video-quality") {
|
||||
const std::string value = requireValue(argc, argv, i, arg);
|
||||
if (videoQualityByKey(value) == nullptr) {
|
||||
throw EffectError("unknown video quality '" + value + "', see --help");
|
||||
}
|
||||
options.video.quality = value;
|
||||
} else if (arg == "--disable") {
|
||||
const std::string name = requireValue(argc, argv, i, arg);
|
||||
effectByName(name); // validate now, exactly as -e does
|
||||
@@ -135,6 +182,7 @@ Options parseOptions(int argc, char** argv) {
|
||||
}
|
||||
case ParamSpec::Type::Path:
|
||||
case ParamSpec::Type::Text:
|
||||
case ParamSpec::Type::Font:
|
||||
options.params[spec->key] = requireValue(argc, argv, i, arg);
|
||||
break;
|
||||
case ParamSpec::Type::Color: {
|
||||
@@ -162,6 +210,18 @@ Options parseOptions(int argc, char** argv) {
|
||||
options.images.push_back(arg);
|
||||
}
|
||||
}
|
||||
// A preset fills in whatever was not given on its own: the length, the
|
||||
// rate and -- unless -s or --format named one -- the canvas. It keeps its
|
||||
// name only while the settings still are the preset's.
|
||||
if (const auto* preset = videoPresetByKey(options.video.preset)) {
|
||||
if (!durationGiven) options.video.duration = preset->seconds;
|
||||
if (!fpsGiven) options.video.fps = preset->fps;
|
||||
if (options.format.empty() && !sizeGiven) options.format = preset->canvasFormat;
|
||||
if (options.video.duration != preset->seconds || options.video.fps != preset->fps ||
|
||||
options.format != preset->canvasFormat) {
|
||||
options.video.preset.clear();
|
||||
}
|
||||
}
|
||||
// Applied once everything is parsed, so --dpi may come before --format and
|
||||
// so a format always wins over an -s given alongside it.
|
||||
if (const auto* format = canvasFormatByKey(options.format)) {
|
||||
@@ -183,6 +243,13 @@ std::string usage(const char* programName) {
|
||||
<< " --format NAME canvas format, overrides --size (see below)\n"
|
||||
<< " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi
|
||||
<< " (default: " << kDefaultDpi << ")\n"
|
||||
<< " --duration SECS render a video this long, " << formatSeconds(kMinDuration)
|
||||
<< " to " << formatSeconds(kMaxDuration) << "\n"
|
||||
<< " --fps N frames per second of a video, " << kMinFps << "-" << kMaxFps
|
||||
<< " (default: " << kDefaultFps << ")\n"
|
||||
<< " --video-preset NAME a video's canvas and length in one (see below)\n"
|
||||
<< " --video-quality NAME how a video file is encoded (default: "
|
||||
<< kDefaultVideoQuality << ", see below)\n"
|
||||
<< " --disable NAME keep this effect in the chain but skip it\n"
|
||||
<< " --seed N random seed (default: random)\n"
|
||||
<< " --list-effects list available effects and exit\n"
|
||||
@@ -197,14 +264,37 @@ std::string usage(const char* programName) {
|
||||
<< (format.print ? " at --dpi" : "") << "\n";
|
||||
}
|
||||
|
||||
for (const auto& effect : effects()) {
|
||||
out << "\n" << effect->title() << " options:\n";
|
||||
for (const auto& spec : effect->params()) {
|
||||
std::string flag = spec.flag();
|
||||
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
|
||||
out << " " << flag;
|
||||
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
|
||||
out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n";
|
||||
out << "\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";
|
||||
}
|
||||
out << "\nVideo qualities (--video-quality):\n";
|
||||
for (const auto& quality : videoQualities()) {
|
||||
out << " " << quality.key;
|
||||
if (quality.key.size() < 20) out << std::string(20 - quality.key.size(), ' ');
|
||||
out << " " << quality.label << ", " << quality.description << "\n";
|
||||
}
|
||||
|
||||
// Image effects first, then the video effects under a heading of their
|
||||
// own: those run only in a clip.
|
||||
for (const bool video : {false, true}) {
|
||||
if (video) {
|
||||
out << "\nVideo effects, which run only in a video (--duration or --video-preset):\n";
|
||||
}
|
||||
for (const auto& effect : effects()) {
|
||||
if (effect->video() != video) continue;
|
||||
out << "\n" << effect->title() << " options (-e " << effect->name() << "):\n";
|
||||
for (const auto& spec : effect->params()) {
|
||||
std::string flag = spec.flag();
|
||||
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
|
||||
out << " " << flag;
|
||||
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
|
||||
out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "core/canvas.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/params.hpp"
|
||||
#include "core/video.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -23,6 +24,9 @@ struct Options {
|
||||
int height = 1080;
|
||||
std::string format; // a named canvas format, empty for plain pixels
|
||||
int dpi = kDefaultDpi; // only a print format is rendered at a resolution
|
||||
// Enabled by --duration, --fps or --video-preset: the output is then a
|
||||
// video, and --output has to name one.
|
||||
VideoSettings video;
|
||||
std::optional<std::uint64_t> seed;
|
||||
ParamValues params; // only the keys the user actually passed
|
||||
bool listEffects = false;
|
||||
|
||||
@@ -33,6 +33,7 @@ const std::vector<CanvasFormat>& canvasFormats() {
|
||||
{"instagram-post", "Instagram post", false, 1080, 1080, 0.0, 0.0},
|
||||
{"instagram-portrait", "Instagram portrait", false, 1080, 1350, 0.0, 0.0},
|
||||
{"instagram-story", "Instagram story", false, 1080, 1920, 0.0, 0.0},
|
||||
{"vertical-hd", "Vertical Full HD", false, 1080, 1920, 0.0, 0.0},
|
||||
{"hd", "Full HD", false, 1920, 1080, 0.0, 0.0},
|
||||
{"uhd", "4K UHD", false, 3840, 2160, 0.0, 0.0},
|
||||
{"a5", "A5", true, 0, 0, 148.0, 210.0},
|
||||
|
||||
+120
-17
@@ -1,12 +1,18 @@
|
||||
#include "core/chain.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "core/memory.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages) {
|
||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages, bool video) {
|
||||
std::vector<const ChainStage*> running;
|
||||
running.reserve(stages.size());
|
||||
for (const auto& stage : stages) {
|
||||
if (stage.enabled && stage.effect) running.push_back(&stage);
|
||||
if (stage.enabled && stage.effect && (video || !stage.effect->video())) {
|
||||
running.push_back(&stage);
|
||||
}
|
||||
}
|
||||
return running;
|
||||
}
|
||||
@@ -29,8 +35,10 @@ ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
|
||||
return values;
|
||||
}
|
||||
|
||||
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||
int width, int height, Rng& rng, const StyleSet& styles) {
|
||||
namespace {
|
||||
|
||||
// The stages that run, checked: throws for a chain with nothing to render.
|
||||
std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stages, bool video) {
|
||||
if (stages.empty()) throw EffectError("The effect chain is empty.");
|
||||
for (const auto& stage : stages) {
|
||||
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
||||
@@ -38,46 +46,141 @@ 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
|
||||
// image and draws nothing from the shared rng, so switching one off gives
|
||||
// back exactly the picture the chain made before it was added.
|
||||
const auto running = enabledStages(stages);
|
||||
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
|
||||
// back exactly the picture the chain made before it was added. A video
|
||||
// effect in a still is left out the same way.
|
||||
auto running = enabledStages(stages, video);
|
||||
if (running.empty()) {
|
||||
const bool videoLeft = std::any_of(stages.begin(), stages.end(), [](const ChainStage& stage) {
|
||||
return stage.enabled && stage.effect->video();
|
||||
});
|
||||
throw EffectError(videoLeft ? "Video effects only run in a video, and the chain has "
|
||||
"no image effect switched on."
|
||||
: "Every effect in the chain is disabled.");
|
||||
}
|
||||
return running;
|
||||
}
|
||||
|
||||
Image image;
|
||||
for (std::size_t i = 0; i < running.size(); ++i) {
|
||||
// Throws OutOfMemoryError for a render that would not fit. A render holds a
|
||||
// few canvases at once -- the stage before, the one being drawn, the input
|
||||
// a transform copies -- and the file it is saved to; 13 bytes a pixel is
|
||||
// what the heaviest chains were measured at, and the interface keeps one
|
||||
// more copy to show, so 24 leaves room. A picture a stage loads itself (an
|
||||
// overlay, a text box's picture) comes on top, with its alpha and pixbuf.
|
||||
void ensureRenderFits(const std::vector<const ChainStage*>& running, int width, int height,
|
||||
const StyleSet& styles) {
|
||||
std::size_t needed = static_cast<std::size_t>(std::max(width, 0)) *
|
||||
static_cast<std::size_t>(std::max(height, 0)) * 24;
|
||||
for (const auto* stage : running) {
|
||||
const auto values = stageValues(*stage, styles);
|
||||
for (const auto& spec : stage->effect->params()) {
|
||||
if (spec.type != ParamSpec::Type::Path) continue;
|
||||
const std::string path = paramText(values, spec.key, "");
|
||||
if (!path.empty()) needed += imageFilePixels(path) * 8;
|
||||
}
|
||||
}
|
||||
ensureMemory(needed, "Rendering a " + std::to_string(width) + " × " + std::to_string(height) +
|
||||
" canvas");
|
||||
}
|
||||
|
||||
// Runs running[first, last) on `image`, which holds the sources' place for
|
||||
// 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 values = stageValues(stage, styles);
|
||||
|
||||
if (i == 0) {
|
||||
image = stage.effect->render(sources, width, height, rng, values);
|
||||
image = stage.effect->renderFrame(sources, width, height, rng, values, frame);
|
||||
} else {
|
||||
// Feeding the previous result back in as the only source is what
|
||||
// cascading means here; effects need to know nothing about it.
|
||||
const std::vector<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;
|
||||
}
|
||||
|
||||
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
|
||||
const auto running = enabledStages(stages);
|
||||
} // 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, frame.video());
|
||||
ensureRenderFits(running, width, height, styles);
|
||||
return runStages(running, 0, running.size(), sources, Image(), width, height, rng, styles,
|
||||
frame);
|
||||
}
|
||||
|
||||
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 std::vector<int>& order) {
|
||||
const auto running = runningStages(stages, true);
|
||||
ensureRenderFits(running, width, height, styles);
|
||||
const int count = video.frameCount();
|
||||
const int fps = std::clamp(video.fps, kMinFps, kMaxFps);
|
||||
|
||||
// Everything before the first video effect 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->video()) ++still;
|
||||
Rng prefixRng(seed);
|
||||
const Image prefix = runStages(running, 0, still, sources, Image(), width, height, prefixRng,
|
||||
styles, FrameTime{0, count, fps});
|
||||
|
||||
const int total = order.empty() ? count : static_cast<int>(order.size());
|
||||
int handed = 0;
|
||||
for (int position = 0; position < total; ++position) {
|
||||
const int index = order.empty() ? position : order[static_cast<std::size_t>(position)];
|
||||
if (index < 0 || index >= count) continue;
|
||||
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);
|
||||
++handed;
|
||||
if (!onFrame(index, std::move(image))) break;
|
||||
}
|
||||
return handed;
|
||||
}
|
||||
|
||||
std::vector<int> draftFirstOrder(int count, int step) {
|
||||
step = std::max(1, step);
|
||||
std::vector<int> order;
|
||||
order.reserve(static_cast<std::size_t>(std::max(0, count)));
|
||||
for (int index = 0; index < count; index += step) order.push_back(index);
|
||||
for (int index = 0; index < count; ++index) {
|
||||
if (index % step != 0) order.push_back(index);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
bool chainNeedsSources(const std::vector<ChainStage>& stages, bool video) {
|
||||
const auto running = enabledStages(stages, video);
|
||||
if (running.empty()) return false;
|
||||
return running.front()->effect->needsSources();
|
||||
}
|
||||
|
||||
std::string chainTitle(const std::vector<ChainStage>& stages) {
|
||||
std::string chainTitle(const std::vector<ChainStage>& stages, bool video) {
|
||||
std::string text;
|
||||
for (const auto* stage : enabledStages(stages)) {
|
||||
for (const auto* stage : enabledStages(stages, video)) {
|
||||
if (!text.empty()) text += " → ";
|
||||
text += stage->effect->title();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string chainSlug(const std::vector<ChainStage>& stages) {
|
||||
std::string chainSlug(const std::vector<ChainStage>& stages, bool video) {
|
||||
std::string text;
|
||||
for (const auto* stage : enabledStages(stages)) {
|
||||
for (const auto* stage : enabledStages(stages, video)) {
|
||||
if (!text.empty()) text += "-";
|
||||
text += stage->effect->name();
|
||||
}
|
||||
|
||||
+41
-10
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -8,6 +9,7 @@
|
||||
#include "core/params.hpp"
|
||||
#include "core/rng.hpp"
|
||||
#include "core/style.hpp"
|
||||
#include "core/video.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -38,11 +40,12 @@ struct ChainStage {
|
||||
// that style carries. The one place a style is resolved.
|
||||
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
|
||||
|
||||
// The stages that actually run, in order, as pointers into `stages`. A
|
||||
// disabled stage draws nothing from the rng and is handed no image, so a chain
|
||||
// renders exactly as it would if that stage had never been added -- this is
|
||||
// the one place the rest of the code asks what "disabled" means.
|
||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages);
|
||||
// The stages that actually run, in order, as pointers into `stages`: those
|
||||
// switched on, and of those the video effects only in a video. A stage left
|
||||
// out draws nothing from the rng and is handed no image, so a chain renders
|
||||
// exactly as it would if that stage had never been added -- this is the one
|
||||
// place the rest of the code asks what "disabled" means.
|
||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages, bool video);
|
||||
|
||||
// Renders the enabled stages in order. The first one sees the loaded sources;
|
||||
// every later one sees the previous stage's output as its only source, so blur
|
||||
@@ -52,19 +55,47 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
|
||||
// reproduces the cascade byte for byte. Throws when no stage is left to run.
|
||||
// `styles` is the project's pool; a chain built without one -- the command
|
||||
// line's -- passes none and renders from the stages alone.
|
||||
//
|
||||
// `frame` says where in a video the render sits, a still by default, in
|
||||
// which the video effects are skipped; every stage that runs is handed it,
|
||||
// and only a video effect makes anything of it.
|
||||
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 = {});
|
||||
|
||||
// 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 video effect are rendered once, and
|
||||
// their picture and the rng state they leave are reused for every frame.
|
||||
// Returns the number of frames handed out.
|
||||
//
|
||||
// `order` lists the frame indices to render, in the order to render them;
|
||||
// empty means every frame from the first. Each frame is the same whichever
|
||||
// order it comes in, so a preview can render a draft first (draftFirstOrder).
|
||||
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 std::vector<int>& order = {});
|
||||
|
||||
// Every frame of a clip of `count`, draft first: every `step`th frame from
|
||||
// the first, which held for `step` frames each plays the whole clip roughly,
|
||||
// and then the frames between them, in order.
|
||||
std::vector<int> draftFirstOrder(int count, int step);
|
||||
|
||||
// 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
|
||||
// is simply whether that effect declares that it needs them.
|
||||
bool chainNeedsSources(const std::vector<ChainStage>& stages);
|
||||
bool chainNeedsSources(const std::vector<ChainStage>& stages, bool video);
|
||||
|
||||
// "Scanlines → Blur", for window titles and status lines. Both of these name
|
||||
// what was rendered, so a disabled stage is left out of them.
|
||||
std::string chainTitle(const std::vector<ChainStage>& stages);
|
||||
// what was rendered, so a stage that did not run is left out of them.
|
||||
std::string chainTitle(const std::vector<ChainStage>& stages, bool video);
|
||||
|
||||
// "scanlines-blur", for file names.
|
||||
std::string chainSlug(const std::vector<ChainStage>& stages);
|
||||
std::string chainSlug(const std::vector<ChainStage>& stages, bool video);
|
||||
|
||||
} // namespace effector
|
||||
|
||||
@@ -16,6 +16,19 @@ public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// Where a render sits in time. The default -- no frame rate -- is a still
|
||||
// picture; a video hands every frame its index, the clip's length in frames
|
||||
// and its rate, so an effect that moves works out where it is from these
|
||||
// alone and a frame can be rendered on its own, in any order.
|
||||
struct FrameTime {
|
||||
int index = 0;
|
||||
int count = 1;
|
||||
int fps = 0;
|
||||
|
||||
bool video() const noexcept { return fps > 0; }
|
||||
double seconds() const noexcept { return video() ? static_cast<double>(index) / fps : 0.0; }
|
||||
};
|
||||
|
||||
// Base class for every effect. Canvas size and the random source are common to
|
||||
// all of them; params() covers only the effect-specific knobs.
|
||||
class Effect {
|
||||
@@ -45,9 +58,28 @@ public:
|
||||
return title();
|
||||
}
|
||||
|
||||
// Whether this is a video effect rather than an image effect. An image
|
||||
// effect makes a picture and holds still in a clip; a video effect is
|
||||
// motion and nothing else, so it is offered only in a video project and a
|
||||
// still skips it whole, as it skips a stage that is switched off. Video
|
||||
// effects derive from VideoEffect (effects/video_effect.hpp), which
|
||||
// answers true here and keeps the rules a moving stage has to follow.
|
||||
virtual bool video() const { return false; }
|
||||
|
||||
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const = 0;
|
||||
|
||||
// One frame of a video, or a still when `frame` says so. An image effect
|
||||
// needs only render(), which this falls back to; a video effect renders
|
||||
// here, and 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;
|
||||
};
|
||||
|
||||
|
||||
+111
-33
@@ -11,6 +11,8 @@
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
|
||||
#include "core/memory.hpp"
|
||||
|
||||
namespace effector {
|
||||
namespace {
|
||||
|
||||
@@ -91,6 +93,39 @@ Image Image::load(const std::string& path, std::vector<std::uint8_t>* alphaOut)
|
||||
return image;
|
||||
}
|
||||
|
||||
Image Image::scaledToFit(int maxSide) const {
|
||||
const int longest = std::max(width_, height_);
|
||||
if (empty() || maxSide < 1 || longest <= maxSide) return *this;
|
||||
|
||||
const double scale = static_cast<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 {
|
||||
if (empty()) throw std::runtime_error("nothing to save");
|
||||
|
||||
@@ -119,48 +154,91 @@ void Image::save(const std::string& path) const {
|
||||
if (saved == FALSE) throw std::runtime_error(takeError(error));
|
||||
}
|
||||
|
||||
std::size_t imageFilePixels(const std::string& path) {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
if (gdk_pixbuf_get_file_info(path.c_str(), &width, &height) == nullptr) return 0;
|
||||
return static_cast<std::size_t>(std::max(width, 0)) * static_cast<std::size_t>(std::max(height, 0));
|
||||
}
|
||||
|
||||
std::vector<Image> loadImages(const std::vector<std::string>& paths,
|
||||
std::vector<LoadFailure>& failures,
|
||||
const LoadProgress* progress) {
|
||||
// Decoding is the dominant cost of a headless run, and the files are
|
||||
// independent, so they are decoded in parallel. Threads take the next path
|
||||
// from a shared cursor rather than a fixed slice, so one huge file cannot
|
||||
// stall the files behind it and progress arrives at a steady rate. Slots
|
||||
// keep the result in the order the paths were given.
|
||||
std::vector<Image> slots(paths.size());
|
||||
std::vector<std::string> errors(paths.size());
|
||||
std::atomic<std::size_t> cursor{0};
|
||||
std::atomic<std::size_t> done{0};
|
||||
const auto cancelled = [progress] {
|
||||
return progress != nullptr && progress->cancel != nullptr &&
|
||||
progress->cancel->load(std::memory_order_relaxed);
|
||||
};
|
||||
// Runs `work(i)` for every path on `workers` threads. Threads take the next
|
||||
// path from a shared cursor rather than a fixed slice, so one huge or slow
|
||||
// file cannot stall the files behind it and progress arrives at a steady
|
||||
// rate. A cancel stops every thread before its next path.
|
||||
const auto forEachPath = [&](std::size_t workers, const auto& work) {
|
||||
std::atomic<std::size_t> cursor{0};
|
||||
const auto run = [&] {
|
||||
for (;;) {
|
||||
if (cancelled()) return;
|
||||
const std::size_t i = cursor.fetch_add(1, std::memory_order_relaxed);
|
||||
if (i >= paths.size()) return;
|
||||
work(i);
|
||||
}
|
||||
};
|
||||
std::vector<std::thread> threads;
|
||||
threads.reserve(workers > 0 ? workers - 1 : 0);
|
||||
for (std::size_t w = 1; w < workers; ++w) threads.emplace_back(run);
|
||||
run();
|
||||
for (auto& thread : threads) thread.join();
|
||||
};
|
||||
|
||||
unsigned hardware = std::thread::hardware_concurrency();
|
||||
if (hardware == 0) hardware = 1;
|
||||
const std::size_t workers = std::min<std::size_t>(hardware, std::max<std::size_t>(paths.size(), 1));
|
||||
std::size_t workers = std::min<std::size_t>(hardware, std::max<std::size_t>(paths.size(), 1));
|
||||
|
||||
const auto decode = [&] {
|
||||
for (;;) {
|
||||
if (progress != nullptr && progress->cancel != nullptr &&
|
||||
progress->cancel->load(std::memory_order_relaxed)) {
|
||||
return;
|
||||
}
|
||||
const std::size_t i = cursor.fetch_add(1, std::memory_order_relaxed);
|
||||
if (i >= paths.size()) return;
|
||||
try {
|
||||
slots[i] = Image::load(paths[i]);
|
||||
} catch (const std::exception& error) {
|
||||
errors[i] = error.what();
|
||||
}
|
||||
if (progress != nullptr && progress->onFile) {
|
||||
progress->onFile(done.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(),
|
||||
paths[i]);
|
||||
}
|
||||
// What the batch holds once decoded, and what one decode needs on top
|
||||
// while it runs: the pixbuf (with alpha, at worst) and its rotated copy.
|
||||
// Reading a header can mean a round trip to a slow disk or a network
|
||||
// share, so the headers are read in parallel too, and reported.
|
||||
std::vector<std::size_t> pixels(paths.size(), 0);
|
||||
std::atomic<std::size_t> sized{0};
|
||||
forEachPath(workers, [&](std::size_t i) {
|
||||
pixels[i] = imageFilePixels(paths[i]);
|
||||
if (progress != nullptr && progress->onSized) {
|
||||
progress->onSized(sized.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(),
|
||||
paths[i]);
|
||||
}
|
||||
};
|
||||
});
|
||||
if (cancelled()) return {};
|
||||
|
||||
std::vector<std::thread> threads;
|
||||
threads.reserve(workers > 0 ? workers - 1 : 0);
|
||||
for (std::size_t w = 1; w < workers; ++w) threads.emplace_back(decode);
|
||||
decode();
|
||||
for (auto& thread : threads) thread.join();
|
||||
std::size_t kept = 0;
|
||||
std::size_t largest = 0;
|
||||
for (const std::size_t count : pixels) {
|
||||
kept += count * 3;
|
||||
largest = std::max(largest, count * 8);
|
||||
}
|
||||
const std::string what = paths.size() == 1
|
||||
? std::string("Loading this image")
|
||||
: "Loading these " + std::to_string(paths.size()) + " images";
|
||||
ensureMemory(kept + largest, what);
|
||||
const std::size_t spare = usableMemory() - std::min(usableMemory(), kept);
|
||||
|
||||
// Decoding is the dominant cost of a headless run, and the files are
|
||||
// independent, so they are decoded in parallel. Slots keep the result in
|
||||
// the order the paths were given.
|
||||
std::vector<Image> slots(paths.size());
|
||||
std::vector<std::string> errors(paths.size());
|
||||
std::atomic<std::size_t> done{0};
|
||||
if (largest > 0) workers = std::clamp<std::size_t>(spare / largest, 1, workers);
|
||||
|
||||
forEachPath(workers, [&](std::size_t i) {
|
||||
try {
|
||||
slots[i] = Image::load(paths[i]);
|
||||
} catch (const std::exception& error) {
|
||||
errors[i] = error.what();
|
||||
}
|
||||
if (progress != nullptr && progress->onFile) {
|
||||
progress->onFile(done.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(),
|
||||
paths[i]);
|
||||
}
|
||||
});
|
||||
|
||||
std::vector<Image> images;
|
||||
images.reserve(paths.size());
|
||||
|
||||
+19
-2
@@ -40,6 +40,11 @@ public:
|
||||
// which is what an effect compositing the image needs.
|
||||
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.
|
||||
void save(const std::string& path) const;
|
||||
|
||||
@@ -56,16 +61,28 @@ struct LoadFailure {
|
||||
};
|
||||
|
||||
// Lets a caller follow a long batch decode and abort it. Both members are
|
||||
// touched from the decoding threads, so `onFile` must be thread-safe and must
|
||||
// not block: the GUI only bumps a counter from it and wakes the main thread.
|
||||
// touched from the decoding threads, so `onSized` and `onFile` must be
|
||||
// thread-safe and must not block: the GUI only bumps a counter from them and
|
||||
// wakes the main thread. `onSized` follows the first pass, which reads every
|
||||
// header to size the batch; `onFile` follows the decodes after it.
|
||||
struct LoadProgress {
|
||||
std::function<void(std::size_t done, std::size_t total, const std::string& path)> onSized;
|
||||
std::function<void(std::size_t done, std::size_t total, const std::string& path)> onFile;
|
||||
std::atomic<bool>* cancel = nullptr;
|
||||
};
|
||||
|
||||
// The pixel count of an image file from its header alone, without decoding
|
||||
// it; 0 when the header cannot be read.
|
||||
std::size_t imageFilePixels(const std::string& path);
|
||||
|
||||
// Loads every path, collecting rather than throwing on individual failures.
|
||||
// When `progress->cancel` is set the remaining paths are skipped, so the result
|
||||
// is short and should be discarded rather than used.
|
||||
//
|
||||
// Before decoding anything the whole batch is sized from the file headers,
|
||||
// and a batch that would not fit in the free memory throws OutOfMemoryError
|
||||
// (core/memory.hpp) with nothing loaded; the decodes that run at once are
|
||||
// also held to what is left over.
|
||||
std::vector<Image> loadImages(const std::vector<std::string>& paths,
|
||||
std::vector<LoadFailure>& failures,
|
||||
const LoadProgress* progress = nullptr);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "core/memory.hpp"
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
|
||||
namespace effector {
|
||||
namespace {
|
||||
|
||||
std::atomic<std::size_t> cap{0};
|
||||
|
||||
// MemAvailable from /proc/meminfo, falling back on the free pages; 0 when
|
||||
// neither says.
|
||||
std::size_t systemAvailable() {
|
||||
std::ifstream meminfo("/proc/meminfo");
|
||||
std::string key;
|
||||
std::size_t kilobytes = 0;
|
||||
std::string unit;
|
||||
while (meminfo >> key >> kilobytes >> unit) {
|
||||
if (key == "MemAvailable:") return kilobytes * 1024;
|
||||
}
|
||||
const long pages = sysconf(_SC_AVPHYS_PAGES);
|
||||
const long pageSize = sysconf(_SC_PAGESIZE);
|
||||
if (pages > 0 && pageSize > 0) {
|
||||
return static_cast<std::size_t>(pages) * static_cast<std::size_t>(pageSize);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A number from a cgroup file, or nothing for "max" or a file that is not
|
||||
// there.
|
||||
bool readCount(const std::filesystem::path& file, std::size_t& out) {
|
||||
std::ifstream in(file);
|
||||
std::string word;
|
||||
if (!(in >> word) || word == "max") return false;
|
||||
try {
|
||||
out = static_cast<std::size_t>(std::stoull(word));
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t inactiveFile(const std::filesystem::path& dir) {
|
||||
std::ifstream in(dir / "memory.stat");
|
||||
std::string key;
|
||||
std::size_t value = 0;
|
||||
while (in >> key >> value) {
|
||||
if (key == "inactive_file") return value;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The room left under the tightest memory.max on the way from this
|
||||
// process's cgroup (v2) up to the root, counting the page cache it could
|
||||
// drop as room; the largest value when nothing caps it.
|
||||
std::size_t cgroupAvailable() {
|
||||
std::size_t room = std::numeric_limits<std::size_t>::max();
|
||||
std::ifstream self("/proc/self/cgroup");
|
||||
std::string line;
|
||||
while (std::getline(self, line)) {
|
||||
if (line.rfind("0::", 0) != 0) continue;
|
||||
// "0::/user.slice/app.slice/x.scope", walked from the scope upwards.
|
||||
for (std::filesystem::path group = line.substr(4); !group.empty();
|
||||
group = group.parent_path()) {
|
||||
const auto dir = std::filesystem::path("/sys/fs/cgroup") / group;
|
||||
std::size_t max = 0;
|
||||
std::size_t current = 0;
|
||||
if (!readCount(dir / "memory.max", max) || !readCount(dir / "memory.current", current)) {
|
||||
continue;
|
||||
}
|
||||
const std::size_t used = current - std::min(current, inactiveFile(dir));
|
||||
room = std::min(room, max - std::min(max, used));
|
||||
}
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::size_t availableMemory() {
|
||||
std::size_t available = systemAvailable();
|
||||
const std::size_t room = cgroupAvailable();
|
||||
if (room != std::numeric_limits<std::size_t>::max()) {
|
||||
available = available == 0 ? room : std::min(available, room);
|
||||
}
|
||||
const std::size_t capped = cap.load(std::memory_order_relaxed);
|
||||
if (capped != 0 && (available == 0 || capped < available)) available = capped;
|
||||
return available;
|
||||
}
|
||||
|
||||
void limitAvailableMemory(std::size_t bytes) { cap.store(bytes, std::memory_order_relaxed); }
|
||||
|
||||
std::size_t usableMemory() {
|
||||
const std::size_t available = availableMemory();
|
||||
return available == 0 ? std::numeric_limits<std::size_t>::max() : available / 4 * 3;
|
||||
}
|
||||
|
||||
void ensureMemory(std::size_t needed, const std::string& what) {
|
||||
const std::size_t available = availableMemory();
|
||||
if (available == 0 || needed <= available / 4 * 3) return;
|
||||
throw OutOfMemoryError(what + " needs about " + formatBytes(needed) +
|
||||
" of memory, more than is safe to take with " + formatBytes(available) +
|
||||
" free.");
|
||||
}
|
||||
|
||||
std::string formatBytes(std::size_t bytes) {
|
||||
char text[32];
|
||||
const double gb = static_cast<double>(bytes) / (1024.0 * 1024.0 * 1024.0);
|
||||
if (gb >= 1.0) {
|
||||
std::snprintf(text, sizeof text, "%.1f GB", gb);
|
||||
} else {
|
||||
std::snprintf(text, sizeof text, "%.0f MB", static_cast<double>(bytes) / (1024.0 * 1024.0));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Thrown instead of starting work that would not fit in memory. Decoding a
|
||||
// batch of photographs or rendering a huge canvas past what the machine has
|
||||
// free does not fail cleanly: the kernel's OOM killer ends the program, and
|
||||
// often others with it.
|
||||
class OutOfMemoryError : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// The bytes this process could still allocate without swapping: the
|
||||
// system's MemAvailable, or less when a cgroup (a systemd unit, a container)
|
||||
// caps the process below it. Zero when neither can be read.
|
||||
std::size_t availableMemory();
|
||||
|
||||
// Caps what availableMemory() reports, for the tests; 0 lifts the cap.
|
||||
void limitAvailableMemory(std::size_t bytes);
|
||||
|
||||
// Throws OutOfMemoryError when `needed` more bytes would take more than three
|
||||
// quarters of availableMemory() -- the rest is left to the desktop and to
|
||||
// whatever else the program holds. `what` names the work in the message.
|
||||
// Does nothing when the free memory cannot be read.
|
||||
void ensureMemory(std::size_t needed, const std::string& what);
|
||||
|
||||
// The bytes ensureMemory() would let through right now; the largest value
|
||||
// when the free memory cannot be read.
|
||||
std::size_t usableMemory();
|
||||
|
||||
// "4.6 GB", "380 MB".
|
||||
std::string formatBytes(std::size_t bytes);
|
||||
|
||||
} // namespace effector
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "core/params.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -16,6 +17,27 @@ ParamSpec ParamSpec::styled() const {
|
||||
return copy;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::slider(double curve) const {
|
||||
ParamSpec copy = *this;
|
||||
copy.onSlider = true;
|
||||
copy.curve = std::max(1e-3, curve);
|
||||
return copy;
|
||||
}
|
||||
|
||||
double ParamSpec::sliderValue(double position) const {
|
||||
const double span = maximum - minimum;
|
||||
const double raw = minimum + span * std::pow(std::clamp(position, 0.0, 1.0), curve);
|
||||
const double unit = type == Type::Int ? std::max(1.0, std::round(step)) : step;
|
||||
const double stepped = unit > 0.0 ? minimum + std::round((raw - minimum) / unit) * unit : raw;
|
||||
return std::clamp(stepped, minimum, maximum);
|
||||
}
|
||||
|
||||
double ParamSpec::sliderPosition(double value) const {
|
||||
const double span = maximum - minimum;
|
||||
if (span <= 0.0) return 0.0;
|
||||
return std::pow(std::clamp((value - minimum) / span, 0.0, 1.0), 1.0 / curve);
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||
long maximum, std::string help, long step) {
|
||||
ParamSpec spec;
|
||||
@@ -100,6 +122,17 @@ ParamSpec ParamSpec::makeColor(std::string key, std::string label, std::string d
|
||||
return spec;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::makeFont(std::string key, std::string label, std::string defaultFamily,
|
||||
std::string help) {
|
||||
ParamSpec spec;
|
||||
spec.type = Type::Font;
|
||||
spec.key = std::move(key);
|
||||
spec.label = std::move(label);
|
||||
spec.help = std::move(help);
|
||||
spec.defaultChoice = std::move(defaultFamily);
|
||||
return spec;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
const ParamValue* find(const ParamValues& values, const std::string& key) {
|
||||
@@ -121,6 +154,7 @@ ParamValue paramDefault(const ParamSpec& spec) {
|
||||
case ParamSpec::Type::Path:
|
||||
case ParamSpec::Type::Text:
|
||||
case ParamSpec::Type::Color:
|
||||
case ParamSpec::Type::Font:
|
||||
break;
|
||||
}
|
||||
return spec.defaultChoice;
|
||||
|
||||
+25
-1
@@ -11,7 +11,7 @@ namespace effector {
|
||||
// Declarative description of one effect knob. The GUI turns these into widgets
|
||||
// and the CLI turns them into flags, so neither knows about any effect.
|
||||
struct ParamSpec {
|
||||
enum class Type { Int, Double, Bool, Choice, Path, Text, Color };
|
||||
enum class Type { Int, Double, Bool, Choice, Path, Text, Color, Font };
|
||||
|
||||
Type type = Type::Int;
|
||||
std::string key;
|
||||
@@ -27,6 +27,13 @@ struct ParamSpec {
|
||||
// Whether a named style carries this param rather than the stage it sits
|
||||
// on: see core/style.hpp.
|
||||
bool inStyle = false;
|
||||
// Whether the interface shows this number as a slider marked with its
|
||||
// range rather than as a spin button: see slider().
|
||||
bool onSlider = false;
|
||||
// How a slider's travel is spread over its range: the value is
|
||||
// minimum + (maximum - minimum) * position^curve, so 1 is even and a
|
||||
// larger curve gives the low end more of the slider. See slider().
|
||||
double curve = 1.0;
|
||||
|
||||
std::string flag() const; // "strip_height" -> "--strip-height"
|
||||
|
||||
@@ -35,6 +42,18 @@ struct ParamSpec {
|
||||
// signatures they have.
|
||||
ParamSpec styled() const;
|
||||
|
||||
// A copy of this Int or Double spec shown as a slider in the interface, for
|
||||
// a number whose place between its minimum and maximum says more than its
|
||||
// digits do. A `curve` above 1 spreads the low end of the range over more
|
||||
// of the slider, for a number whose small values differ the most. The
|
||||
// command line and the project file are unchanged.
|
||||
ParamSpec slider(double curve = 1.0) const;
|
||||
|
||||
// Where a slider at `position` (0 to 1) puts this number, rounded to its
|
||||
// step and kept in range, and where on the slider `value` sits.
|
||||
double sliderValue(double position) const;
|
||||
double sliderPosition(double value) const;
|
||||
|
||||
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||
long maximum, std::string help = {}, long step = 1);
|
||||
static ParamSpec makeDouble(std::string key, std::string label, double defaultValue,
|
||||
@@ -56,6 +75,11 @@ struct ParamSpec {
|
||||
// spelling as a flag value.
|
||||
static ParamSpec makeColor(std::string key, std::string label, std::string defaultColor,
|
||||
std::string help = {});
|
||||
// A font family as it is named on this machine ("Sans", "DejaVu Serif"):
|
||||
// a font chooser in the interface, a flag taking any string on the
|
||||
// command line. A family the machine lacks is kept as written.
|
||||
static ParamSpec makeFont(std::string key, std::string label, std::string defaultFamily,
|
||||
std::string help = {});
|
||||
};
|
||||
|
||||
using ParamValue = std::variant<long, double, bool, std::string>;
|
||||
|
||||
@@ -319,6 +319,25 @@ int canvasDpi(const Json& value) {
|
||||
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::filesystem::path file(path);
|
||||
if (file.is_absolute() || directory.empty()) return path;
|
||||
@@ -347,6 +366,7 @@ void writeParams(std::ostream& out, const std::vector<ParamSpec>& specs, const P
|
||||
break;
|
||||
case ParamSpec::Type::Choice:
|
||||
case ParamSpec::Type::Text:
|
||||
case ParamSpec::Type::Font:
|
||||
out << quote(paramText(values, spec.key, spec.defaultChoice));
|
||||
break;
|
||||
case ParamSpec::Type::Color: {
|
||||
@@ -393,6 +413,7 @@ void readParams(const std::vector<ParamSpec>& specs, const Json& params,
|
||||
}
|
||||
break;
|
||||
case ParamSpec::Type::Text:
|
||||
case ParamSpec::Type::Font:
|
||||
if (value->kind == Json::Kind::String) values[spec.key] = value->text;
|
||||
break;
|
||||
case ParamSpec::Type::Color: {
|
||||
@@ -435,6 +456,21 @@ std::string projectToJson(const Project& project, const std::string& directory)
|
||||
if (format->print) out << ", \"dpi\": " << project.dpi;
|
||||
}
|
||||
out << "},\n";
|
||||
// Only a video says anything about time: a still project reads exactly as
|
||||
// it did before there were videos.
|
||||
if (project.video.enabled) {
|
||||
out << " \"video\": {\"duration\": " << number(project.video.duration, 3)
|
||||
<< ", \"fps\": " << project.video.fps;
|
||||
if (const auto* preset = videoPresetByKey(project.video.preset)) {
|
||||
out << ", \"preset\": " << quote(preset->key);
|
||||
}
|
||||
// Written only when it is not the standard one.
|
||||
if (const auto* quality = videoQualityByKey(project.video.quality);
|
||||
quality != nullptr && quality->key != kDefaultVideoQuality) {
|
||||
out << ", \"quality\": " << quote(quality->key);
|
||||
}
|
||||
out << "},\n";
|
||||
}
|
||||
if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
|
||||
|
||||
out << " \"sources\": [";
|
||||
@@ -506,6 +542,28 @@ 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;
|
||||
}
|
||||
// An unknown quality is the standard one, as it is everywhere else.
|
||||
if (const auto* name = video->member("quality"); name != nullptr &&
|
||||
name->kind == Json::Kind::String) {
|
||||
if (const auto* quality = videoQualityByKey(name->text)) {
|
||||
project.video.quality = quality->key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto* seed = root.member("seed")) {
|
||||
// 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
|
||||
@@ -616,4 +674,22 @@ Project loadProject(const std::string& path) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string nextExportPath(const std::string& previous) {
|
||||
const std::filesystem::path path(previous);
|
||||
std::string stem = path.stem().string();
|
||||
long count = 0;
|
||||
const auto dash = stem.rfind('-');
|
||||
if (dash != std::string::npos && dash + 1 < stem.size() && stem.size() - dash <= 10 &&
|
||||
stem.find_first_not_of("0123456789", dash + 1) == std::string::npos) {
|
||||
count = std::strtol(stem.c_str() + dash + 1, nullptr, 10);
|
||||
stem.resize(dash);
|
||||
}
|
||||
std::filesystem::path next;
|
||||
std::error_code error;
|
||||
do {
|
||||
next = path.parent_path() / (stem + "-" + std::to_string(++count) + path.extension().string());
|
||||
} while (std::filesystem::exists(next, error));
|
||||
return next.string();
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "core/chain.hpp"
|
||||
#include "core/params.hpp"
|
||||
#include "core/style.hpp"
|
||||
#include "core/video.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -30,6 +31,8 @@ struct Project {
|
||||
// resolution for.
|
||||
std::string format;
|
||||
int dpi = kDefaultDpi;
|
||||
// Whether the project is a video, and how long and how fast.
|
||||
VideoSettings video;
|
||||
std::optional<std::uint64_t> seed;
|
||||
};
|
||||
|
||||
@@ -51,4 +54,9 @@ Project projectFromJson(const std::string& text, const std::string& directory =
|
||||
void saveProject(const std::string& path, const Project& project);
|
||||
Project loadProject(const std::string& path);
|
||||
|
||||
// The name to offer for the next export after `previous`: its stem with the
|
||||
// trailing `-N` counted on (`city.png` → `city-1.png`, `city-4.png` →
|
||||
// `city-5.png`), skipping any that already exist in its folder.
|
||||
std::string nextExportPath(const std::string& previous);
|
||||
|
||||
} // namespace effector
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
#include "effects/blur.hpp"
|
||||
#include "effects/channelsplit.hpp"
|
||||
#include "effects/corrupt.hpp"
|
||||
#include "effects/datamosh.hpp"
|
||||
#include "effects/feedback.hpp"
|
||||
#include "effects/filmgate.hpp"
|
||||
#include "effects/focuspull.hpp"
|
||||
#include "effects/flow.hpp"
|
||||
#include "effects/grain.hpp"
|
||||
#include "effects/monochrome.hpp"
|
||||
#include "effects/overlay.hpp"
|
||||
@@ -12,8 +16,13 @@
|
||||
#include "effects/scanlines.hpp"
|
||||
#include "effects/shatter.hpp"
|
||||
#include "effects/slitshift.hpp"
|
||||
#include "effects/striproll.hpp"
|
||||
#include "effects/sweep.hpp"
|
||||
#include "effects/text.hpp"
|
||||
#include "effects/tiles.hpp"
|
||||
#include "effects/tileswap.hpp"
|
||||
#include "effects/tracking.hpp"
|
||||
#include "effects/tunnel.hpp"
|
||||
#include "effects/warp.hpp"
|
||||
|
||||
namespace effector {
|
||||
@@ -36,6 +45,16 @@ const std::vector<EffectPtr>& effects() {
|
||||
std::make_shared<WarpEffect>(),
|
||||
std::make_shared<BitCrushEffect>(),
|
||||
std::make_shared<FeedbackEffect>(),
|
||||
// Video effects, offered only in a video project.
|
||||
std::make_shared<TrackingEffect>(),
|
||||
std::make_shared<TileSwapEffect>(),
|
||||
std::make_shared<TunnelEffect>(),
|
||||
std::make_shared<FlowEffect>(),
|
||||
std::make_shared<SweepEffect>(),
|
||||
std::make_shared<FilmGateEffect>(),
|
||||
std::make_shared<DatamoshEffect>(),
|
||||
std::make_shared<StripRollEffect>(),
|
||||
std::make_shared<FocusPullEffect>(),
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
const std::vector<VideoQuality>& videoQualities() {
|
||||
// Standard is what every video was written with before there was a
|
||||
// choice: CRF 18 is close to what the eye can tell from the frames.
|
||||
static const std::vector<VideoQuality> qualities = {
|
||||
{"small", "Small file", "smaller and quicker to share, with visible softening", 26, "fast",
|
||||
40, 128},
|
||||
{kDefaultVideoQuality, "Standard", "close to the rendered frames at a moderate size", 18,
|
||||
"medium", 32, 256},
|
||||
{"high", "High", "as faithful as it gets, larger and slower to write", 12, "slow", 24, 256},
|
||||
};
|
||||
return qualities;
|
||||
}
|
||||
|
||||
const VideoQuality* videoQualityByKey(const std::string& key) {
|
||||
for (const auto& quality : videoQualities()) {
|
||||
if (quality.key == key) return &quality;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const VideoQuality& videoQualityOrDefault(const std::string& key) {
|
||||
if (const auto* quality = videoQualityByKey(key)) return *quality;
|
||||
return *videoQualityByKey(kDefaultVideoQuality);
|
||||
}
|
||||
|
||||
// In the C locale whatever the user's is, for the same reason as the
|
||||
// millimetres of a paper size: "7.5 s", never "7,5 s".
|
||||
std::string formatSeconds(double seconds) {
|
||||
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,
|
||||
const std::string& quality)
|
||||
: 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); });
|
||||
|
||||
const VideoQuality& squeeze = videoQualityOrDefault(quality);
|
||||
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=max_colors=" +
|
||||
std::to_string(squeeze.gifColors) +
|
||||
"[p];[a][p]paletteuse",
|
||||
"-loop", "0"});
|
||||
} else {
|
||||
// 4:2:0 wants even sides; a pixel of padding beats refusing the canvas.
|
||||
args.insert(args.end(), {"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"});
|
||||
if (muxer == "webm") {
|
||||
args.insert(args.end(), {"-c:v", "libvpx-vp9", "-crf", std::to_string(squeeze.vp9Crf),
|
||||
"-b:v", "0",
|
||||
"-row-mt", "1"});
|
||||
} else {
|
||||
args.insert(args.end(), {"-c:v", "libx264", "-preset", squeeze.h264Preset, "-crf",
|
||||
std::to_string(squeeze.h264Crf)});
|
||||
}
|
||||
args.insert(args.end(), {"-pix_fmt", "yuv420p"});
|
||||
if (muxer == "mp4" || muxer == "mov") args.insert(args.end(), {"-movflags", "+faststart"});
|
||||
}
|
||||
// "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,134 @@
|
||||
#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;
|
||||
// How hard the file is squeezed: a key of videoQualities(), empty or
|
||||
// unknown for the standard one. Only the encoded file hears of it; the
|
||||
// frames are the same at every quality.
|
||||
std::string quality;
|
||||
|
||||
// The frames the clip has: the length at the rate, never fewer than one.
|
||||
int frameCount() const;
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
// How a video file is encoded, from small and quick to large and faithful.
|
||||
// The numbers go to ffmpeg by the codec the file's extension picks.
|
||||
struct VideoQuality {
|
||||
std::string key; // written in a project and given to --video-quality
|
||||
std::string label; // shown in the interface
|
||||
std::string description; // for the tooltip and --help
|
||||
int h264Crf = 18; // .mp4, .mov, .mkv: lower is better and larger
|
||||
std::string h264Preset; // x264's speed preset: slower packs tighter
|
||||
int vp9Crf = 32; // .webm
|
||||
int gifColors = 256; // .gif palette size
|
||||
};
|
||||
|
||||
// In the order they are offered, smallest first.
|
||||
const std::vector<VideoQuality>& videoQualities();
|
||||
|
||||
// The standard quality, which an empty or unknown key stands for.
|
||||
inline constexpr const char* kDefaultVideoQuality = "standard";
|
||||
|
||||
// Null when the key names no quality.
|
||||
const VideoQuality* videoQualityByKey(const std::string& key);
|
||||
|
||||
// The quality a key stands for: the standard one for an empty or unknown key.
|
||||
const VideoQuality& videoQualityOrDefault(const std::string& key);
|
||||
|
||||
// "15 s", "7.5 s": a length as the interface and --help show it.
|
||||
std::string formatSeconds(double seconds);
|
||||
|
||||
// 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, squeezed as the quality says. The encoded bytes are ffmpeg's business; the frames going in are the
|
||||
// ones the seed decides.
|
||||
//
|
||||
// Throws EffectError when ffmpeg is missing, fails, or quits early, with what
|
||||
// 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:
|
||||
// The quality is a key of videoQualities(); empty means the standard one.
|
||||
VideoWriter(const std::string& path, int width, int height, int fps,
|
||||
const std::string& quality = {});
|
||||
~VideoWriter();
|
||||
VideoWriter(const VideoWriter&) = delete;
|
||||
VideoWriter& operator=(const VideoWriter&) = delete;
|
||||
|
||||
// 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
-64
@@ -1,10 +1,9 @@
|
||||
#include "effects/blur.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/box_blur.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
@@ -23,57 +22,6 @@ const std::vector<ParamSpec>& BlurEffect::params() const {
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One separable box blur pass. `stride` is the distance in bytes between two
|
||||
// neighbouring samples along the blurred axis, `count` how many of them there
|
||||
// are; the window is clamped at both ends.
|
||||
void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride, int radius) {
|
||||
const int window = 2 * radius + 1;
|
||||
int sum[3] = {0, 0, 0};
|
||||
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sum[c] = in[c] * (radius + 1);
|
||||
for (int i = 1; i <= radius; ++i) {
|
||||
sum[c] += in[static_cast<std::size_t>(std::min(i, count - 1)) * stride + c];
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; ++i) {
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
out[static_cast<std::size_t>(i) * stride + c] =
|
||||
static_cast<std::uint8_t>(sum[c] / window);
|
||||
}
|
||||
const int add = std::min(i + radius + 1, count - 1);
|
||||
const int drop = std::max(i - radius, 0);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sum[c] += in[static_cast<std::size_t>(add) * stride + c] -
|
||||
in[static_cast<std::size_t>(drop) * stride + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void blurHorizontal(const Image& src, Image& dst, int radius) {
|
||||
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
|
||||
radius);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void blurVertical(const Image& src, Image& dst, int radius) {
|
||||
const std::size_t stride = src.stride();
|
||||
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t x = begin; x < end; ++x) {
|
||||
const std::size_t offset = x * 3;
|
||||
boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image BlurEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
@@ -94,17 +42,7 @@ Image BlurEffect::render(const std::vector<Image>& sources, int width, int heigh
|
||||
fillFromSource(canvas, src, sx, sy);
|
||||
}
|
||||
|
||||
Image scratch(width, height);
|
||||
for (int pass = 0; pass < passes; ++pass) {
|
||||
if (direction != "vertical") {
|
||||
blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1)));
|
||||
std::swap(canvas, scratch);
|
||||
}
|
||||
if (direction != "horizontal") {
|
||||
blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1)));
|
||||
std::swap(canvas, scratch);
|
||||
}
|
||||
}
|
||||
boxBlur(canvas, radius, passes, direction);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// One separable box blur pass. `stride` is the distance in bytes between two
|
||||
// neighbouring samples along the blurred axis, `count` how many of them there
|
||||
// are; the window is clamped at both ends.
|
||||
inline void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride,
|
||||
int radius) {
|
||||
const int window = 2 * radius + 1;
|
||||
int sum[3] = {0, 0, 0};
|
||||
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sum[c] = in[c] * (radius + 1);
|
||||
for (int i = 1; i <= radius; ++i) {
|
||||
sum[c] += in[static_cast<std::size_t>(std::min(i, count - 1)) * stride + c];
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; ++i) {
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
out[static_cast<std::size_t>(i) * stride + c] =
|
||||
static_cast<std::uint8_t>(sum[c] / window);
|
||||
}
|
||||
const int add = std::min(i + radius + 1, count - 1);
|
||||
const int drop = std::max(i - radius, 0);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sum[c] += in[static_cast<std::size_t>(add) * stride + c] -
|
||||
in[static_cast<std::size_t>(drop) * stride + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void blurHorizontal(const Image& src, Image& dst, int radius) {
|
||||
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
|
||||
radius);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
inline void blurVertical(const Image& src, Image& dst, int radius) {
|
||||
const std::size_t stride = src.stride();
|
||||
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t x = begin; x < end; ++x) {
|
||||
const std::size_t offset = x * 3;
|
||||
boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Blurs `canvas` in place: `passes` box blurs of `radius` along the axes
|
||||
// `direction` names ("both", "horizontal" or "vertical"). Shared by the blur
|
||||
// and the focus pull effects.
|
||||
inline void boxBlur(Image& canvas, int radius, int passes, const std::string& direction) {
|
||||
const int width = canvas.width();
|
||||
const int height = canvas.height();
|
||||
Image scratch(width, height);
|
||||
for (int pass = 0; pass < passes; ++pass) {
|
||||
if (direction != "vertical") {
|
||||
blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1)));
|
||||
std::swap(canvas, scratch);
|
||||
}
|
||||
if (direction != "horizontal") {
|
||||
blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1)));
|
||||
std::swap(canvas, scratch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
+3
-75
@@ -1,10 +1,9 @@
|
||||
#include "effects/corrupt.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/corrupt_blocks.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
@@ -28,19 +27,6 @@ const std::vector<ParamSpec>& CorruptEffect::params() const {
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
enum class Damage : std::uint8_t { None, Repeat, Displace, Colour };
|
||||
|
||||
struct Block {
|
||||
Damage damage = Damage::None;
|
||||
int sx = 0; // displace: where the block is read from
|
||||
int sy = 0;
|
||||
int shift[3] = {0, 0, 0}; // colour: added to each channel
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image CorruptEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
@@ -55,23 +41,6 @@ Image CorruptEffect::render(const std::vector<Image>& sources, int width, int he
|
||||
const int cols = (width + size - 1) / size;
|
||||
const int rows = (height + size - 1) / size;
|
||||
|
||||
auto drawBlock = [&](int bx) {
|
||||
Block block;
|
||||
if (kinds == "repeat") block.damage = Damage::Repeat;
|
||||
else if (kinds == "displace") block.damage = Damage::Displace;
|
||||
else if (kinds == "colour") block.damage = Damage::Colour;
|
||||
else block.damage = static_cast<Damage>(1 + rng.below(2));
|
||||
// The first block of a row has nothing to its left to repeat.
|
||||
if (block.damage == Damage::Repeat && bx == 0) block.damage = Damage::Displace;
|
||||
if (block.damage == Damage::Displace) {
|
||||
block.sx = static_cast<int>(rng.inRange(0, std::max(0, width - size)));
|
||||
block.sy = static_cast<int>(rng.inRange(0, std::max(0, height - size)));
|
||||
} else if (block.damage == Damage::Colour) {
|
||||
for (int& shift : block.shift) shift = static_cast<int>(rng.inRange(-128, 128));
|
||||
}
|
||||
return block;
|
||||
};
|
||||
|
||||
// The whole damage map is drawn up front, block by block in reading order.
|
||||
std::vector<Block> plan(static_cast<std::size_t>(cols) * rows);
|
||||
for (int by = 0; by < rows; ++by) {
|
||||
@@ -88,54 +57,13 @@ Image CorruptEffect::render(const std::vector<Image>& sources, int width, int he
|
||||
continue;
|
||||
}
|
||||
if (!chance(rng.next(), rate)) continue;
|
||||
block = drawBlock(bx);
|
||||
block = drawDamage(rng, kinds, bx, width, height, size);
|
||||
if (bleed) carried = █
|
||||
}
|
||||
}
|
||||
|
||||
Image canvas = input;
|
||||
// Block rows are independent (a repeat only ever reads to its left), so
|
||||
// they run in parallel and each walks its row in order.
|
||||
parallelFor(static_cast<std::size_t>(rows), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t by = begin; by < end; ++by) {
|
||||
const int y0 = static_cast<int>(by) * size;
|
||||
const int h = std::min(size, height - y0);
|
||||
for (int bx = 0; bx < cols; ++bx) {
|
||||
const Block& block = plan[by * static_cast<std::size_t>(cols) + bx];
|
||||
if (block.damage == Damage::None) continue;
|
||||
const int x0 = bx * size;
|
||||
const int w = std::min(size, width - x0);
|
||||
const std::size_t bytes = static_cast<std::size_t>(w) * 3;
|
||||
|
||||
for (int line = 0; line < h; ++line) {
|
||||
std::uint8_t* out = canvas.row(y0 + line) + static_cast<std::size_t>(x0) * 3;
|
||||
switch (block.damage) {
|
||||
case Damage::Repeat:
|
||||
std::memmove(out, out - static_cast<std::size_t>(size) * 3, bytes);
|
||||
break;
|
||||
case Damage::Displace: {
|
||||
const int sy = std::min(block.sy + line, height - 1);
|
||||
const std::uint8_t* in = input.row(sy);
|
||||
for (int x = 0; x < w; ++x) {
|
||||
const int sx = (block.sx + x) % width;
|
||||
std::memcpy(out + static_cast<std::size_t>(x) * 3,
|
||||
in + static_cast<std::size_t>(sx) * 3, 3);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Damage::Colour:
|
||||
for (std::size_t i = 0; i < bytes; ++i) {
|
||||
out[i] = static_cast<std::uint8_t>(
|
||||
std::clamp(out[i] + block.shift[i % 3], 0, 255));
|
||||
}
|
||||
break;
|
||||
case Damage::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
paintBlocks(canvas, input, plan, cols, rows, size);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
#include "core/rng.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The damage a codec does to one block of the picture. Shared by the corrupt
|
||||
// and the datamosh effects.
|
||||
enum class Damage : std::uint8_t { None, Repeat, Displace, Colour };
|
||||
|
||||
struct Block {
|
||||
Damage damage = Damage::None;
|
||||
int sx = 0; // displace: where the block is read from
|
||||
int sy = 0;
|
||||
int shift[3] = {0, 0, 0}; // colour: added to each channel
|
||||
};
|
||||
|
||||
// Draws the damage of one block, of the kind `kinds` names ("mixed" picks
|
||||
// one), for a block `bx` blocks from the left of a canvas `width` by `height`.
|
||||
inline Block drawDamage(Rng& rng, const std::string& kinds, int bx, int width, int height,
|
||||
int size) {
|
||||
Block block;
|
||||
if (kinds == "repeat") block.damage = Damage::Repeat;
|
||||
else if (kinds == "displace") block.damage = Damage::Displace;
|
||||
else if (kinds == "colour") block.damage = Damage::Colour;
|
||||
else block.damage = static_cast<Damage>(1 + rng.below(2));
|
||||
// The first block of a row has nothing to its left to repeat.
|
||||
if (block.damage == Damage::Repeat && bx == 0) block.damage = Damage::Displace;
|
||||
if (block.damage == Damage::Displace) {
|
||||
block.sx = static_cast<int>(rng.inRange(0, std::max(0, width - size)));
|
||||
block.sy = static_cast<int>(rng.inRange(0, std::max(0, height - size)));
|
||||
} else if (block.damage == Damage::Colour) {
|
||||
for (int& shift : block.shift) shift = static_cast<int>(rng.inRange(-128, 128));
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
// Lays the damage map `plan` (`cols` by `rows` blocks of `size` pixels, in
|
||||
// reading order) over `canvas`, which starts as a copy of `input`. A displaced
|
||||
// block reads `input` from (sx, sy) on, wrapping across its width.
|
||||
inline void paintBlocks(Image& canvas, const Image& input, const std::vector<Block>& plan, int cols,
|
||||
int rows, int size) {
|
||||
const int width = canvas.width();
|
||||
const int height = canvas.height();
|
||||
// Block rows are independent (a repeat only ever reads to its left), so
|
||||
// they run in parallel and each walks its row in order.
|
||||
parallelFor(static_cast<std::size_t>(rows), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t by = begin; by < end; ++by) {
|
||||
const int y0 = static_cast<int>(by) * size;
|
||||
const int h = std::min(size, height - y0);
|
||||
for (int bx = 0; bx < cols; ++bx) {
|
||||
const Block& block = plan[by * static_cast<std::size_t>(cols) + bx];
|
||||
if (block.damage == Damage::None) continue;
|
||||
const int x0 = bx * size;
|
||||
const int w = std::min(size, width - x0);
|
||||
const std::size_t bytes = static_cast<std::size_t>(w) * 3;
|
||||
|
||||
for (int line = 0; line < h; ++line) {
|
||||
std::uint8_t* out = canvas.row(y0 + line) + static_cast<std::size_t>(x0) * 3;
|
||||
switch (block.damage) {
|
||||
case Damage::Repeat:
|
||||
std::memmove(out, out - static_cast<std::size_t>(size) * 3, bytes);
|
||||
break;
|
||||
case Damage::Displace: {
|
||||
const int sy = std::min(block.sy + line, height - 1);
|
||||
const std::uint8_t* in = input.row(sy);
|
||||
for (int x = 0; x < w; ++x) {
|
||||
const int sx = (block.sx + x) % width;
|
||||
std::memcpy(out + static_cast<std::size_t>(x) * 3,
|
||||
in + static_cast<std::size_t>(sx) * 3, 3);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Damage::Colour:
|
||||
for (std::size_t i = 0; i < bytes; ++i) {
|
||||
out[i] = static_cast<std::uint8_t>(
|
||||
std::clamp(out[i] + block.shift[i % 3], 0, 255));
|
||||
}
|
||||
break;
|
||||
case Damage::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "effects/datamosh.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "effects/corrupt_blocks.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& DatamoshEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("mosh_block", "Block size", 16, 2, 256,
|
||||
"Width and height in pixels of one block, as a codec would cut them."),
|
||||
ParamSpec::makeDouble("mosh_rate", "Rate", 0.1, 0.0, 1.0,
|
||||
"Chance of each block breaking at some moment of the cycle."),
|
||||
ParamSpec::makeChoice("mosh_kinds", "Damage", "mixed",
|
||||
{"mixed", "repeat", "displace", "colour"},
|
||||
"repeat freezes a block into a copy of the one to its left, "
|
||||
"displace pulls one in from elsewhere in the image, colour knocks "
|
||||
"it off colour; mixed picks one of them for every block."),
|
||||
ParamSpec::makeDouble("mosh_hold", "Hold", 0.3, 0.01, 1.0,
|
||||
"How much of each cycle a broken block stays broken, give or take "
|
||||
"half; 1 keeps it broken throughout."),
|
||||
ParamSpec::makeInt("mosh_drift", "Drift", 24, 0, 4096,
|
||||
"How far in pixels a displaced block's picture slides while it "
|
||||
"shows."),
|
||||
ParamSpec::makeDouble("mosh_speed", "Speed", 0.5, 0.0, 10.0,
|
||||
"Cycles per second, rounded so that the clip loops without a jump; "
|
||||
"0 holds still.",
|
||||
0.05, 2),
|
||||
ParamSpec::makeBool("mosh_bleed", "Bleed", false,
|
||||
"Carry each broken block's fault on to the end of its row, the way "
|
||||
"one broken bit spoils the rest of a stream."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
int size;
|
||||
double rate;
|
||||
std::string kinds;
|
||||
double hold;
|
||||
int drift;
|
||||
double speed;
|
||||
bool bleed;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: size(static_cast<int>(std::max(2L, paramInt(values, "mosh_block", 16)))),
|
||||
rate(std::clamp(paramDouble(values, "mosh_rate", 0.1), 0.0, 1.0)),
|
||||
kinds(paramChoice(values, "mosh_kinds", "mixed")),
|
||||
hold(std::clamp(paramDouble(values, "mosh_hold", 0.3), 0.01, 1.0)),
|
||||
drift(static_cast<int>(std::max(0L, paramInt(values, "mosh_drift", 24)))),
|
||||
speed(std::max(0.0, paramDouble(values, "mosh_speed", 0.5))),
|
||||
bleed(paramBool(values, "mosh_bleed", false)) {}
|
||||
};
|
||||
|
||||
// A block that breaks at some moment of the cycle: how, from when, for how
|
||||
// long (both as parts of a cycle), and which way a displaced one smears.
|
||||
struct Break {
|
||||
Block block;
|
||||
double start = 0.0;
|
||||
double length = 0.0;
|
||||
double driftX = 0.0;
|
||||
double driftY = 0.0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image DatamoshEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const int width = input.width();
|
||||
const int height = input.height();
|
||||
const int size = settings.size;
|
||||
const int cols = (width + size - 1) / size;
|
||||
const int rows = (height + size - 1) / size;
|
||||
|
||||
// What stays put for the whole clip: the whole map of breaks, block by
|
||||
// block in reading order, as Corrupt draws its damage.
|
||||
Rng steady = at.steady();
|
||||
std::vector<Break> breaks(static_cast<std::size_t>(cols) * rows);
|
||||
bool any = false;
|
||||
for (int by = 0; by < rows; ++by) {
|
||||
const Break* carried = nullptr;
|
||||
for (int bx = 0; bx < cols; ++bx) {
|
||||
Break& piece = breaks[static_cast<std::size_t>(by) * cols + bx];
|
||||
if (carried != nullptr) {
|
||||
piece = *carried;
|
||||
// A displaced run keeps moving along with the row, so the
|
||||
// stretch reads as one shifted strip.
|
||||
if (piece.block.damage == Damage::Displace) piece.block.sx += size;
|
||||
carried = &piece;
|
||||
continue;
|
||||
}
|
||||
if (!chance(steady.next(), settings.rate)) continue;
|
||||
piece.block = drawDamage(steady, settings.kinds, bx, width, height, size);
|
||||
piece.start = unitDouble(steady.next());
|
||||
piece.length = std::min(1.0, settings.hold * (0.5 + unitDouble(steady.next())));
|
||||
sinCosDegrees(unitDouble(steady.next()) * 360.0, piece.driftY, piece.driftX);
|
||||
if (settings.bleed) carried = &piece;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (!any) return input;
|
||||
|
||||
// Where this frame is in time: which blocks are broken, and how far
|
||||
// through its stretch each displaced one is.
|
||||
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||
std::vector<Block> plan(breaks.size());
|
||||
for (std::size_t i = 0; i < breaks.size(); ++i) {
|
||||
const Break& piece = breaks[i];
|
||||
if (piece.block.damage == Damage::None) continue;
|
||||
const double into = t - piece.start - std::floor(t - piece.start);
|
||||
if (piece.length < 1.0 && into >= piece.length) continue;
|
||||
Block& block = plan[i];
|
||||
block = piece.block;
|
||||
if (block.damage == Damage::Displace && settings.drift > 0) {
|
||||
const double slide = settings.drift * into / piece.length;
|
||||
const int sx = block.sx + static_cast<int>(std::lround(slide * piece.driftX));
|
||||
block.sx = (sx % width + width) % width;
|
||||
block.sy = std::clamp(block.sy + static_cast<int>(std::lround(slide * piece.driftY)), 0,
|
||||
height - 1);
|
||||
}
|
||||
}
|
||||
|
||||
Image canvas = input;
|
||||
paintBlocks(canvas, input, plan, cols, rows, size);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The corrupt effect in motion: blocks of the picture break as a damaged
|
||||
// stream breaks them, and each broken block holds for a stretch of the clip
|
||||
// before it heals, a displaced one smearing its picture along while it shows.
|
||||
//
|
||||
// Which blocks break, how, and when in the cycle each is broken is drawn from
|
||||
// at.steady(); a frame only asks which of them are broken at its moment, so
|
||||
// the clip loops without a jump.
|
||||
class DatamoshEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "datamosh"; }
|
||||
std::string title() const override { return "Datamosh"; }
|
||||
std::string description() const override {
|
||||
return "Breaks blocks of the picture as Corrupt does, each for a stretch of the clip, "
|
||||
"displaced blocks smearing along while they show.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,168 @@
|
||||
#include "effects/filmgate.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/grain_field.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& FilmGateEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeDouble("gate_grain", "Grain", 0.3, 0.0, 1.0,
|
||||
"How far a grain may push a pixel, as a fraction of full scale; "
|
||||
"the grain is new on every frame."),
|
||||
ParamSpec::makeInt("gate_grain_size", "Grain size", 2, 1, 32,
|
||||
"Width in pixels of one grain; larger sizes look coarser."),
|
||||
ParamSpec::makeInt("gate_weave", "Weave", 2, 0, 64,
|
||||
"Furthest in pixels the picture sways sideways in the gate, and half "
|
||||
"that up and down."),
|
||||
ParamSpec::makeDouble("gate_flicker", "Flicker", 0.06, 0.0, 1.0,
|
||||
"How far the brightness may jump from one frame to the next, as a "
|
||||
"fraction of itself."),
|
||||
ParamSpec::makeInt("gate_dust", "Dust", 3, 0, 500,
|
||||
"Specks of dust on each frame, every one there for that frame alone."),
|
||||
ParamSpec::makeInt("gate_scratches", "Scratches", 1, 0, 16,
|
||||
"Thin bright lines running down the film, wandering a little and "
|
||||
"now and then missing a frame."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
double grain;
|
||||
int grainSize;
|
||||
int weave;
|
||||
double flicker;
|
||||
int dust;
|
||||
int scratches;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: grain(std::clamp(paramDouble(values, "gate_grain", 0.3), 0.0, 1.0)),
|
||||
grainSize(static_cast<int>(std::max(1L, paramInt(values, "gate_grain_size", 2)))),
|
||||
weave(static_cast<int>(std::max(0L, paramInt(values, "gate_weave", 2)))),
|
||||
flicker(std::clamp(paramDouble(values, "gate_flicker", 0.06), 0.0, 1.0)),
|
||||
dust(static_cast<int>(std::max(0L, paramInt(values, "gate_dust", 3)))),
|
||||
scratches(static_cast<int>(std::max(0L, paramInt(values, "gate_scratches", 1)))) {}
|
||||
};
|
||||
|
||||
double unit(Rng& rng) { return unitDouble(rng.next()); }
|
||||
|
||||
// The swings the sway is made of: roughly how often each comes round a
|
||||
// second, and how much of the sway it carries.
|
||||
constexpr std::array<double, 3> kSwingRate = {0.7, 1.9, 4.3};
|
||||
constexpr std::array<double, 3> kSwingShare = {0.5, 0.3, 0.2};
|
||||
|
||||
// Blends a pixel towards `shade` by `mix` out of 256.
|
||||
void towards(std::uint8_t* px, int shade, int mix) {
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
px[c] = static_cast<std::uint8_t>(px[c] + (shade - px[c]) * mix / 256);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image FilmGateEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const int width = input.width();
|
||||
const int height = input.height();
|
||||
constexpr double tau = 2.0 * std::numbers::pi;
|
||||
|
||||
// What stays put for the whole clip: where each swing of the sway
|
||||
// starts, and where each scratch runs down the film.
|
||||
Rng steady = at.steady();
|
||||
std::array<double, 3> phaseX{};
|
||||
std::array<double, 3> phaseY{};
|
||||
for (std::size_t i = 0; i < kSwingRate.size(); ++i) {
|
||||
phaseX[i] = unit(steady) * tau;
|
||||
phaseY[i] = unit(steady) * tau;
|
||||
}
|
||||
std::vector<double> scratchAt(static_cast<std::size_t>(settings.scratches));
|
||||
for (double& x : scratchAt) x = unit(steady);
|
||||
|
||||
// Where this frame is in time: how far the picture has swayed.
|
||||
double swayX = 0.0;
|
||||
double swayY = 0.0;
|
||||
for (std::size_t i = 0; i < kSwingRate.size(); ++i) {
|
||||
const double turn = at.cycle(kSwingRate[i]) * tau;
|
||||
swayX += kSwingShare[i] * std::sin(turn + phaseX[i]);
|
||||
swayY += kSwingShare[i] * std::sin(turn + phaseY[i]);
|
||||
}
|
||||
const int dx = static_cast<int>(std::lround(swayX * settings.weave));
|
||||
const int dy = static_cast<int>(std::lround(swayY * settings.weave * 0.5));
|
||||
|
||||
// What changes on every frame, all drawn before a pixel is touched: the
|
||||
// flicker, the grain, the dust, and which scratches show and where.
|
||||
Rng fresh = at.fresh();
|
||||
const double flicker = settings.flicker * (unit(fresh) * 2.0 - 1.0);
|
||||
const int gain = static_cast<int>(std::lround((1.0 + flicker) * 256.0));
|
||||
GrainField grain;
|
||||
if (settings.grain > 0.0) grain = drawGrain(fresh, width, height, settings.grainSize, false);
|
||||
struct Speck {
|
||||
int x;
|
||||
int y;
|
||||
int radius;
|
||||
};
|
||||
std::vector<Speck> specks(static_cast<std::size_t>(settings.dust));
|
||||
for (Speck& speck : specks) {
|
||||
speck.x = static_cast<int>(fresh.inRange(0, width - 1));
|
||||
speck.y = static_cast<int>(fresh.inRange(0, height - 1));
|
||||
speck.radius = static_cast<int>(fresh.inRange(0, 2));
|
||||
}
|
||||
std::vector<int> scratchX;
|
||||
for (const double x : scratchAt) {
|
||||
const int wander = static_cast<int>(fresh.inRange(-2, 2));
|
||||
if (unit(fresh) < 0.85) scratchX.push_back(static_cast<int>(x * (width - 1)) + wander);
|
||||
}
|
||||
|
||||
// The picture moved in the gate, the edges it leaves held from the last
|
||||
// row and column, and lit up or down by the flicker.
|
||||
Image frame(width, height);
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
const int sy = std::clamp(static_cast<int>(y) - dy, 0, height - 1);
|
||||
const std::uint8_t* in = input.row(sy);
|
||||
std::uint8_t* out = frame.row(static_cast<int>(y));
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const int sx = std::clamp(x - dx, 0, width - 1);
|
||||
const std::uint8_t* from = in + static_cast<std::size_t>(sx) * 3;
|
||||
std::uint8_t* to = out + static_cast<std::size_t>(x) * 3;
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
to[c] = static_cast<std::uint8_t>(std::min(255, from[c] * gain >> 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (settings.grain > 0.0) applyGrain(frame, grain, settings.grain, true);
|
||||
|
||||
// Dust lies dark on the picture; a scratch through the emulsion lets the
|
||||
// light through.
|
||||
for (const Speck& speck : specks) {
|
||||
for (int y = speck.y - speck.radius; y <= speck.y + speck.radius; ++y) {
|
||||
if (y < 0 || y >= height) continue;
|
||||
for (int x = speck.x - speck.radius; x <= speck.x + speck.radius; ++x) {
|
||||
if (x < 0 || x >= width) continue;
|
||||
const int ox = x - speck.x;
|
||||
const int oy = y - speck.y;
|
||||
if (ox * ox + oy * oy > speck.radius * speck.radius) continue;
|
||||
towards(frame.row(y) + static_cast<std::size_t>(x) * 3, 16, 220);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const int x : scratchX) {
|
||||
if (x < 0 || x >= width) continue;
|
||||
for (int y = 0; y < height; ++y) {
|
||||
towards(frame.row(y) + static_cast<std::size_t>(x) * 3, 255, 150);
|
||||
}
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Film running through a projector: the grain is new on every frame, the
|
||||
// picture sways a little in the gate and its brightness flickers, and dust
|
||||
// and scratches pass over it.
|
||||
//
|
||||
// The sway is a sum of slow swings, each a whole number of times in the
|
||||
// clip, so it loops; grain, flicker and dust are drawn from at.fresh() and
|
||||
// are different on every frame, as they are on film.
|
||||
class FilmGateEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "filmgate"; }
|
||||
std::string title() const override { return "Film gate"; }
|
||||
std::string description() const override {
|
||||
return "Film running through a projector: grain new on every frame, as Film grain draws "
|
||||
"it, a picture swaying in the gate, flicker, dust and scratches.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,118 @@
|
||||
#include "effects/flow.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/noise_field.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& FlowEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("flow_strength", "Strength", 30, 0, 4096,
|
||||
"How far in pixels the field pushes the image at its strongest."),
|
||||
ParamSpec::makeInt("flow_scale", "Scale", 160, 4, 8192,
|
||||
"Size in pixels of the largest features in the field."),
|
||||
ParamSpec::makeInt("flow_octaves", "Octaves", 3, 1, 6,
|
||||
"Finer layers of noise laid over the first; more give a turbulent "
|
||||
"edge to the flow."),
|
||||
ParamSpec::makeChoice("flow_mode", "Mode", "displace", {"displace", "swirl"},
|
||||
"displace pushes along the field; swirl flows along its contour "
|
||||
"lines, which curls the image into eddies."),
|
||||
ParamSpec::makeInt("flow_travel", "Travel", 80, 0, 4096,
|
||||
"Across how many pixels the field drifts in each cycle; the finer "
|
||||
"layers drift half as far and turn faster."),
|
||||
ParamSpec::makeDouble("flow_speed", "Speed", 0.25, 0.0, 10.0,
|
||||
"Cycles per second, rounded so that the clip loops without a jump; "
|
||||
"0 holds still.",
|
||||
0.05, 2),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
double strength;
|
||||
double scale;
|
||||
int octaves;
|
||||
bool swirl;
|
||||
int travel;
|
||||
double speed;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: strength(static_cast<double>(std::max(0L, paramInt(values, "flow_strength", 30)))),
|
||||
scale(static_cast<double>(std::max(4L, paramInt(values, "flow_scale", 160)))),
|
||||
octaves(static_cast<int>(std::clamp(paramInt(values, "flow_octaves", 3), 1L, 6L))),
|
||||
swirl(paramChoice(values, "flow_mode", "displace") == "swirl"),
|
||||
travel(static_cast<int>(std::max(0L, paramInt(values, "flow_travel", 80)))),
|
||||
speed(std::max(0.0, paramDouble(values, "flow_speed", 0.25))) {}
|
||||
};
|
||||
|
||||
// Which way round, and from where, one layer of a field drifts.
|
||||
struct Orbit {
|
||||
double start; // degrees
|
||||
double turn; // +1 or -1
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image FlowEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const int width = input.width();
|
||||
const int height = input.height();
|
||||
if (settings.strength <= 0.0) return input;
|
||||
|
||||
// What stays put for the whole clip: the field, drawn a circle's radius
|
||||
// larger on every side than the picture, so it can drift without running
|
||||
// out, and where each of its layers starts its circle, and which way.
|
||||
const double reach = settings.travel * 0.5;
|
||||
const int margin = static_cast<int>(reach) + 2;
|
||||
Rng steady = at.steady();
|
||||
const WarpField field(steady, width + 2 * margin, height + 2 * margin, settings.scale,
|
||||
settings.octaves, settings.swirl);
|
||||
std::vector<Orbit> orbits(static_cast<std::size_t>(settings.octaves) * 2);
|
||||
for (Orbit& orbit : orbits) {
|
||||
orbit.start = unitDouble(steady.next()) * 360.0;
|
||||
orbit.turn = (steady.next() >> 63) != 0 ? 1.0 : -1.0;
|
||||
}
|
||||
|
||||
// Where this frame is in time: layer i has gone i + 1 times round a
|
||||
// circle half as wide as the one before in a whole cycle.
|
||||
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||
std::vector<NoiseShift> firstShifts(static_cast<std::size_t>(settings.octaves));
|
||||
std::vector<NoiseShift> secondShifts(static_cast<std::size_t>(settings.octaves));
|
||||
double radius = reach;
|
||||
for (int i = 0; i < settings.octaves; ++i) {
|
||||
for (int f = 0; f < 2; ++f) {
|
||||
const Orbit& orbit = orbits[static_cast<std::size_t>(i * 2 + f)];
|
||||
double sine = 0.0;
|
||||
double cosine = 1.0;
|
||||
sinCosDegrees(orbit.start + orbit.turn * (i + 1) * 360.0 * t, sine, cosine);
|
||||
NoiseShift& shift = (f == 0 ? firstShifts : secondShifts)[static_cast<std::size_t>(i)];
|
||||
shift.x = margin + radius * cosine;
|
||||
shift.y = margin + radius * sine;
|
||||
}
|
||||
radius *= 0.5;
|
||||
}
|
||||
|
||||
Image frame(width, height);
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t row = begin; row < end; ++row) {
|
||||
std::uint8_t* out = frame.row(static_cast<int>(row));
|
||||
const double y = static_cast<double>(row);
|
||||
for (int col = 0; col < width; ++col) {
|
||||
const double x = col;
|
||||
double dx = 0.0;
|
||||
double dy = 0.0;
|
||||
field.push(x, y, settings.strength, dx, dy, &firstShifts, &secondShifts);
|
||||
sampleBilinear(input, x + dx, y + dy, out + static_cast<std::size_t>(col) * 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The warp effect in motion: the same field of noise pushes or swirls the
|
||||
// picture, and its layers drift, each round a circle of its own, so the
|
||||
// picture ripples like heat haze or water under wind.
|
||||
//
|
||||
// Every layer of the field goes round its circle a whole number of times in a
|
||||
// cycle -- the finer layers smaller and faster -- so the field, and with it
|
||||
// the clip, comes back to where it started without a jump.
|
||||
class FlowEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "flow"; }
|
||||
std::string title() const override { return "Warp flow"; }
|
||||
std::string description() const override {
|
||||
return "Pushes the picture through a field of noise, as Warp does, and sets the field "
|
||||
"drifting so the picture ripples.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,108 @@
|
||||
#include "effects/focuspull.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/box_blur.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& FocusPullEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeChoice("focus_mode", "Mode", "breathe", {"breathe", "band"},
|
||||
"breathe pulls the whole picture out of focus and back; band "
|
||||
"moves a band of sharpness down the picture and back up."),
|
||||
ParamSpec::makeInt("focus_radius", "Radius", 12, 0, 256,
|
||||
"Half width in pixels of the blur at its strongest."),
|
||||
ParamSpec::makeDouble("focus_band", "Band", 0.3, 0.05, 1.0,
|
||||
"Height of the sharp band as a share of the picture, with as much "
|
||||
"again fading into the blur; band mode only."),
|
||||
ParamSpec::makeDouble("focus_speed", "Speed", 0.25, 0.0, 10.0,
|
||||
"Pulls there and back per second, rounded so that the clip loops "
|
||||
"without a jump; 0 holds still.",
|
||||
0.05, 2),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Two passes of a box blur come close enough to a lens.
|
||||
constexpr int kPasses = 2;
|
||||
|
||||
struct Settings {
|
||||
bool band;
|
||||
int radius;
|
||||
double bandShare;
|
||||
double speed;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: band(paramChoice(values, "focus_mode", "breathe") == "band"),
|
||||
radius(static_cast<int>(std::max(0L, paramInt(values, "focus_radius", 12)))),
|
||||
bandShare(std::clamp(paramDouble(values, "focus_band", 0.3), 0.05, 1.0)),
|
||||
speed(std::max(0.0, paramDouble(values, "focus_speed", 0.25))) {}
|
||||
};
|
||||
|
||||
Image blurred(const Image& input, int radius) {
|
||||
Image out = input;
|
||||
if (radius > 0) boxBlur(out, radius, kPasses, "both");
|
||||
return out;
|
||||
}
|
||||
|
||||
// Lays `over` on `under` in place, row y at `mix(y)` out of 256.
|
||||
template <typename Mix>
|
||||
void blendRows(Image& under, const Image& over, Mix mix) {
|
||||
const std::size_t bytes = static_cast<std::size_t>(under.width()) * 3;
|
||||
parallelFor(static_cast<std::size_t>(under.height()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
const int weight = mix(static_cast<int>(y));
|
||||
if (weight <= 0) continue;
|
||||
std::uint8_t* out = under.row(static_cast<int>(y));
|
||||
const std::uint8_t* in = over.row(static_cast<int>(y));
|
||||
for (std::size_t i = 0; i < bytes; ++i) {
|
||||
out[i] = static_cast<std::uint8_t>(out[i] + (in[i] - out[i]) * weight / 256);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image FocusPullEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
if (settings.radius <= 0) return input;
|
||||
const int height = input.height();
|
||||
|
||||
// Where this frame is in time: how far the pull has gone, easing out from
|
||||
// the start and back to it once a cycle.
|
||||
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||
const double pull = (1.0 - std::cos(2.0 * std::numbers::pi * t)) * 0.5;
|
||||
|
||||
if (!settings.band) {
|
||||
// A radius between two whole ones is the two blurs mixed, so the
|
||||
// focus drifts smoothly rather than in steps.
|
||||
const double radius = settings.radius * pull;
|
||||
const int lower = static_cast<int>(radius);
|
||||
const int mix = static_cast<int>(std::lround((radius - lower) * 256.0));
|
||||
Image frame = blurred(input, lower);
|
||||
if (mix > 0) blendRows(frame, blurred(input, lower + 1), [mix](int) { return mix; });
|
||||
return frame;
|
||||
}
|
||||
|
||||
// The band's middle goes from the top to the bottom and back; the blur
|
||||
// grows over as much again as the band is high on either side of it.
|
||||
const double middle = pull * (height - 1);
|
||||
const double half = settings.bandShare * height * 0.5;
|
||||
const double fade = std::max(1.0, settings.bandShare * height);
|
||||
const Image soft = blurred(input, settings.radius);
|
||||
blendRows(input, soft, [&](int y) {
|
||||
const double away = std::abs(y - middle) - half;
|
||||
return static_cast<int>(std::lround(std::clamp(away / fade, 0.0, 1.0) * 256.0));
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The blur effect in motion, as a lens being focused: either the whole
|
||||
// picture drifts out of focus and back, or a band of sharpness travels down
|
||||
// the picture and back up with the rest blurred, as a tilted lens shows it.
|
||||
//
|
||||
// The pull goes there and back once a cycle, so the clip loops without a
|
||||
// jump; nothing in it is random.
|
||||
class FocusPullEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "focuspull"; }
|
||||
std::string title() const override { return "Focus pull"; }
|
||||
std::string description() const override {
|
||||
return "Blurs the picture as Blur does, pulling it out of focus and back, or moving a "
|
||||
"band of sharpness up and down it.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
+3
-38
@@ -4,7 +4,7 @@
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/grain_field.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
@@ -23,14 +23,6 @@ const std::vector<ParamSpec>& GrainEffect::params() const {
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
|
||||
// roughly how grain behaves on film.
|
||||
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
|
||||
|
||||
} // namespace
|
||||
|
||||
Image GrainEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
@@ -54,35 +46,8 @@ Image GrainEffect::render(const std::vector<Image>& sources, int width, int heig
|
||||
|
||||
// The whole grain field is drawn serially from the rng, exactly like the
|
||||
// scanlines plan, and only the pixel arithmetic afterwards is parallel.
|
||||
const int cellsX = (width + size - 1) / size;
|
||||
const int cellsY = (height + size - 1) / size;
|
||||
const int channels = colour ? 3 : 1;
|
||||
std::vector<std::int8_t> field(static_cast<std::size_t>(cellsX) * cellsY * channels);
|
||||
for (auto& cell : field) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
|
||||
|
||||
// 127 * 255 is the largest product of a grain and a midtone weight; scaling
|
||||
// by `amount` there keeps the whole thing in integers.
|
||||
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
|
||||
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||
const std::size_t rowCell =
|
||||
static_cast<std::size_t>(static_cast<int>(y) / size) * cellsX;
|
||||
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const std::size_t cell = (rowCell + static_cast<std::size_t>(x / size)) * channels;
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
const int value = out[static_cast<std::size_t>(x) * 3 + c];
|
||||
const int grain = field[cell + (colour ? static_cast<std::size_t>(c) : 0)];
|
||||
const int weight = midtones ? midtoneWeight(value) : 255;
|
||||
const int delta = grain * weight / 255 * scale / 65536;
|
||||
out[static_cast<std::size_t>(x) * 3 + c] =
|
||||
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const GrainField field = drawGrain(rng, width, height, size, colour);
|
||||
applyGrain(canvas, field, amount, midtones);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <vector>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
#include "core/rng.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// A field of grain over a canvas: one value in [-127, 127] per cell of
|
||||
// `size` pixels square, per channel when the grain is coloured. Shared by the
|
||||
// film grain and the film gate effects.
|
||||
struct GrainField {
|
||||
int size = 1;
|
||||
int cellsX = 0;
|
||||
bool colour = false;
|
||||
std::vector<std::int8_t> cells;
|
||||
};
|
||||
|
||||
// Draws the whole field serially from the rng, so that only the pixel
|
||||
// arithmetic that applies it runs in parallel.
|
||||
inline GrainField drawGrain(Rng& rng, int width, int height, int size, bool colour) {
|
||||
GrainField field;
|
||||
field.size = size;
|
||||
field.colour = colour;
|
||||
field.cellsX = (width + size - 1) / size;
|
||||
const int cellsY = (height + size - 1) / size;
|
||||
field.cells.resize(static_cast<std::size_t>(field.cellsX) * cellsY * (colour ? 3 : 1));
|
||||
for (auto& cell : field.cells) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
|
||||
return field;
|
||||
}
|
||||
|
||||
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
|
||||
// roughly how grain behaves on film.
|
||||
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
|
||||
|
||||
// Pushes every pixel by its grain, at most `amount` of full scale, and less in
|
||||
// the shadows and highlights when `midtones` is set.
|
||||
inline void applyGrain(Image& canvas, const GrainField& field, double amount, bool midtones) {
|
||||
const int width = canvas.width();
|
||||
const int channels = field.colour ? 3 : 1;
|
||||
// 127 * 255 is the largest product of a grain and a midtone weight;
|
||||
// scaling by `amount` there keeps the whole thing in integers.
|
||||
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
|
||||
|
||||
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||
const std::size_t rowCell =
|
||||
static_cast<std::size_t>(static_cast<int>(y) / field.size) * field.cellsX;
|
||||
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const std::size_t cell =
|
||||
(rowCell + static_cast<std::size_t>(x / field.size)) * channels;
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
const int value = out[static_cast<std::size_t>(x) * 3 + c];
|
||||
const std::size_t plane = field.colour ? static_cast<std::size_t>(c) : 0;
|
||||
const int grain = field.cells[cell + plane];
|
||||
const int weight = midtones ? midtoneWeight(value) : 255;
|
||||
const int delta = grain * weight / 255 * scale / 65536;
|
||||
out[static_cast<std::size_t>(x) * 3 + c] =
|
||||
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,141 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
#include "core/rng.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// One layer of value noise: random heights on a lattice `spacing` pixels
|
||||
// apart, blended smoothly between the lattice points. Positions are read from
|
||||
// (0, 0) to the width and height it was built for.
|
||||
class NoiseLayer {
|
||||
public:
|
||||
NoiseLayer(Rng& rng, int width, int height, double spacing)
|
||||
: spacing_(spacing),
|
||||
cols_(static_cast<int>(width / spacing) + 2),
|
||||
rows_(static_cast<int>(height / spacing) + 2),
|
||||
lattice_(static_cast<std::size_t>(cols_) * rows_) {
|
||||
for (auto& value : lattice_) value = unitDouble(rng.next()) * 2.0 - 1.0;
|
||||
}
|
||||
|
||||
// The height at (x, y) in [-1, 1], and its slope per pixel along x and y.
|
||||
void sample(double x, double y, double& value, double& slopeX, double& slopeY) const {
|
||||
const double gx = x / spacing_;
|
||||
const double gy = y / spacing_;
|
||||
const int ix = std::min(static_cast<int>(gx), cols_ - 2);
|
||||
const int iy = std::min(static_cast<int>(gy), rows_ - 2);
|
||||
const double fx = gx - ix;
|
||||
const double fy = gy - iy;
|
||||
const double sx = fx * fx * (3.0 - 2.0 * fx);
|
||||
const double sy = fy * fy * (3.0 - 2.0 * fy);
|
||||
|
||||
const double a = at(ix, iy);
|
||||
const double b = at(ix + 1, iy);
|
||||
const double c = at(ix, iy + 1);
|
||||
const double d = at(ix + 1, iy + 1);
|
||||
const double cross = a - b - c + d;
|
||||
value = a + (b - a) * sx + (c - a) * sy + cross * sx * sy;
|
||||
slopeX = 6.0 * fx * (1.0 - fx) * ((b - a) + cross * sy) / spacing_;
|
||||
slopeY = 6.0 * fy * (1.0 - fy) * ((c - a) + cross * sx) / spacing_;
|
||||
}
|
||||
|
||||
private:
|
||||
double at(int x, int y) const { return lattice_[static_cast<std::size_t>(y) * cols_ + x]; }
|
||||
|
||||
double spacing_;
|
||||
int cols_;
|
||||
int rows_;
|
||||
std::vector<double> lattice_;
|
||||
};
|
||||
|
||||
// Where one layer of a field is read from, against where it is asked for.
|
||||
struct NoiseShift {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
};
|
||||
|
||||
// Octaves of noise summed, each twice as fine and half as strong as the one
|
||||
// before, and scaled back so the total stays in [-1, 1].
|
||||
class NoiseField {
|
||||
public:
|
||||
NoiseField(Rng& rng, int width, int height, double scale, int octaves) {
|
||||
double spacing = scale;
|
||||
double weight = 1.0;
|
||||
for (int i = 0; i < octaves; ++i) {
|
||||
layers_.emplace_back(rng, width, height, std::max(1.0, spacing));
|
||||
weights_.push_back(weight);
|
||||
total_ += weight;
|
||||
spacing *= 0.5;
|
||||
weight *= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// As above, with each layer read that far off from (x, y), one shift per
|
||||
// octave -- which is how a field is set moving: its layers slide apart.
|
||||
void sample(double x, double y, double& value, double& slopeX, double& slopeY,
|
||||
const std::vector<NoiseShift>* shifts = nullptr) const {
|
||||
value = slopeX = slopeY = 0.0;
|
||||
for (std::size_t i = 0; i < layers_.size(); ++i) {
|
||||
double v = 0.0, sx = 0.0, sy = 0.0;
|
||||
if (shifts != nullptr) {
|
||||
layers_[i].sample(x + (*shifts)[i].x, y + (*shifts)[i].y, v, sx, sy);
|
||||
} else {
|
||||
layers_[i].sample(x, y, v, sx, sy);
|
||||
}
|
||||
value += v * weights_[i];
|
||||
slopeX += sx * weights_[i];
|
||||
slopeY += sy * weights_[i];
|
||||
}
|
||||
value /= total_;
|
||||
slopeX /= total_;
|
||||
slopeY /= total_;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<NoiseLayer> layers_;
|
||||
std::vector<double> weights_;
|
||||
double total_ = 0.0;
|
||||
};
|
||||
|
||||
// The push the warp effects give a pixel: one field for a swirl, which flows
|
||||
// along its contours, two for a push along x and y. The fields are drawn in
|
||||
// full, in that order, when this is built.
|
||||
class WarpField {
|
||||
public:
|
||||
WarpField(Rng& rng, int width, int height, double scale, int octaves, bool swirl)
|
||||
: first_(rng, width, height, scale, octaves), scale_(scale), swirl_(swirl) {
|
||||
if (!swirl) second_.emplace_back(rng, width, height, scale, octaves);
|
||||
}
|
||||
|
||||
// How far the pixel at (x, y) is pushed, at `strength` pixels at most;
|
||||
// `shifts` (one list per field) slides the fields' layers as NoiseField does.
|
||||
void push(double x, double y, double strength, double& dx, double& dy,
|
||||
const std::vector<NoiseShift>* firstShifts = nullptr,
|
||||
const std::vector<NoiseShift>* secondShifts = nullptr) const {
|
||||
double value = 0.0, slopeX = 0.0, slopeY = 0.0;
|
||||
first_.sample(x, y, value, slopeX, slopeY, firstShifts);
|
||||
if (swirl_) {
|
||||
// Along the contour: the slope turned a quarter, which never piles
|
||||
// pixels up or tears them apart. The slope of a field `scale` wide
|
||||
// is about 1/scale, hence the factor.
|
||||
dx = slopeY * scale_ * strength * 0.5;
|
||||
dy = -slopeX * scale_ * strength * 0.5;
|
||||
} else {
|
||||
double other = 0.0;
|
||||
second_.front().sample(x, y, other, slopeX, slopeY, secondShifts);
|
||||
dx = value * strength;
|
||||
dy = other * strength;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
NoiseField first_;
|
||||
std::vector<NoiseField> second_;
|
||||
double scale_;
|
||||
bool swirl_;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/sort_runs.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
@@ -34,39 +35,6 @@ const std::vector<ParamSpec>& PixelSortEffect::params() const {
|
||||
|
||||
namespace {
|
||||
|
||||
int luma(const std::uint8_t* px) { return (px[0] * 54 + px[1] * 183 + px[2] * 19) >> 8; }
|
||||
|
||||
// Hue on a 0..1535 scale (six sectors of 256), in integers.
|
||||
int hue(const std::uint8_t* px) {
|
||||
const int r = px[0], g = px[1], b = px[2];
|
||||
const int hi = std::max({r, g, b});
|
||||
const int lo = std::min({r, g, b});
|
||||
const int range = hi - lo;
|
||||
if (range == 0) return 0;
|
||||
if (hi == r) return g >= b ? (g - b) * 255 / range : 1535 - (b - g) * 255 / range;
|
||||
if (hi == g) return 512 + (b - r) * 255 / range;
|
||||
return 1024 + (r - g) * 255 / range;
|
||||
}
|
||||
|
||||
int saturation(const std::uint8_t* px) {
|
||||
return std::max({px[0], px[1], px[2]}) - std::min({px[0], px[1], px[2]});
|
||||
}
|
||||
|
||||
// The image with rows and columns swapped, so vertical sorting is horizontal
|
||||
// sorting of the transpose.
|
||||
Image transposed(const Image& src) {
|
||||
Image out(src.height(), src.width());
|
||||
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t x = begin; x < end; ++x) {
|
||||
std::uint8_t* row = out.row(static_cast<int>(x));
|
||||
for (int y = 0; y < src.height(); ++y) {
|
||||
std::memcpy(row + static_cast<std::size_t>(y) * 3, src.row(y) + x * 3, 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
struct LinePlan {
|
||||
bool sorted = false;
|
||||
std::uint64_t seed = 0; // for the run lengths, when they are limited
|
||||
@@ -99,49 +67,19 @@ Image PixelSortEffect::render(const std::vector<Image>& sources, int width, int
|
||||
if (maxRun > 0) line.seed = rng.next();
|
||||
}
|
||||
|
||||
auto keyOf = [&](const std::uint8_t* px) {
|
||||
if (keyName == "hue") return hue(px);
|
||||
if (keyName == "saturation") return saturation(px);
|
||||
return luma(px);
|
||||
};
|
||||
SortRule rule;
|
||||
rule.low = low;
|
||||
rule.high = high;
|
||||
rule.key = sortKeyNamed(keyName);
|
||||
rule.reverse = reverse;
|
||||
rule.maxRun = maxRun;
|
||||
|
||||
parallelFor(static_cast<std::size_t>(lines), [&](std::size_t begin, std::size_t end) {
|
||||
std::vector<std::pair<int, std::array<std::uint8_t, 3>>> run;
|
||||
for (std::size_t line = begin; line < end; ++line) {
|
||||
if (!plan[line].sorted) continue;
|
||||
Rng runRng(plan[line].seed);
|
||||
std::uint8_t* row = canvas.row(static_cast<int>(line));
|
||||
|
||||
auto flush = [&](int start) {
|
||||
// A stable sort, so equal keys keep their order on every
|
||||
// standard library.
|
||||
std::stable_sort(run.begin(), run.end(), [&](const auto& a, const auto& b) {
|
||||
return reverse ? a.first > b.first : a.first < b.first;
|
||||
});
|
||||
for (std::size_t i = 0; i < run.size(); ++i) {
|
||||
std::memcpy(row + (static_cast<std::size_t>(start) + i) * 3, run[i].second.data(), 3);
|
||||
}
|
||||
run.clear();
|
||||
};
|
||||
|
||||
int start = 0;
|
||||
int limit = maxRun > 0 ? static_cast<int>(runRng.inRange(1, maxRun)) : span;
|
||||
for (int x = 0; x < span; ++x) {
|
||||
const std::uint8_t* px = row + static_cast<std::size_t>(x) * 3;
|
||||
const int brightness = luma(px);
|
||||
if (brightness < low || brightness > high) {
|
||||
flush(start);
|
||||
start = x + 1;
|
||||
continue;
|
||||
}
|
||||
if (static_cast<int>(run.size()) >= limit) {
|
||||
flush(start);
|
||||
start = x;
|
||||
if (maxRun > 0) limit = static_cast<int>(runRng.inRange(1, maxRun));
|
||||
}
|
||||
run.push_back({keyOf(px), {px[0], px[1], px[2]}});
|
||||
}
|
||||
flush(start);
|
||||
sortLine(canvas.row(static_cast<int>(line)), span, rule, runRng, run);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
#include "core/rng.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// What orders the pixels inside a run. Shared by the pixel sort and the sort
|
||||
// sweep effects.
|
||||
enum class SortKey { Luma, Hue, Saturation };
|
||||
|
||||
inline SortKey sortKeyNamed(const std::string& name) {
|
||||
if (name == "hue") return SortKey::Hue;
|
||||
if (name == "saturation") return SortKey::Saturation;
|
||||
return SortKey::Luma;
|
||||
}
|
||||
|
||||
inline int sortLuma(const std::uint8_t* px) { return (px[0] * 54 + px[1] * 183 + px[2] * 19) >> 8; }
|
||||
|
||||
// Hue on a 0..1535 scale (six sectors of 256), in integers.
|
||||
inline int sortHue(const std::uint8_t* px) {
|
||||
const int r = px[0], g = px[1], b = px[2];
|
||||
const int hi = std::max({r, g, b});
|
||||
const int lo = std::min({r, g, b});
|
||||
const int range = hi - lo;
|
||||
if (range == 0) return 0;
|
||||
if (hi == r) return g >= b ? (g - b) * 255 / range : 1535 - (b - g) * 255 / range;
|
||||
if (hi == g) return 512 + (b - r) * 255 / range;
|
||||
return 1024 + (r - g) * 255 / range;
|
||||
}
|
||||
|
||||
inline int sortSaturation(const std::uint8_t* px) {
|
||||
return std::max({px[0], px[1], px[2]}) - std::min({px[0], px[1], px[2]});
|
||||
}
|
||||
|
||||
inline int sortKeyOf(SortKey key, const std::uint8_t* px) {
|
||||
switch (key) {
|
||||
case SortKey::Hue: return sortHue(px);
|
||||
case SortKey::Saturation: return sortSaturation(px);
|
||||
case SortKey::Luma: break;
|
||||
}
|
||||
return sortLuma(px);
|
||||
}
|
||||
|
||||
// The image with rows and columns swapped, so vertical sorting is horizontal
|
||||
// sorting of the transpose.
|
||||
inline Image transposed(const Image& src) {
|
||||
Image out(src.height(), src.width());
|
||||
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t x = begin; x < end; ++x) {
|
||||
std::uint8_t* row = out.row(static_cast<int>(x));
|
||||
for (int y = 0; y < src.height(); ++y) {
|
||||
std::memcpy(row + static_cast<std::size_t>(y) * 3, src.row(y) + x * 3, 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// How one line is sorted: the brightness a pixel must lie within (0..255) to
|
||||
// join a run, what orders a run, and the longest a run may grow (0 for no
|
||||
// limit), its lengths drawn from `runRng`.
|
||||
struct SortRule {
|
||||
int low = 0;
|
||||
int high = 255;
|
||||
SortKey key = SortKey::Luma;
|
||||
bool reverse = false;
|
||||
int maxRun = 0;
|
||||
};
|
||||
|
||||
// Sorts every run of one line of `span` pixels in place. `run` is scratch
|
||||
// space, reused from line to line.
|
||||
inline void sortLine(std::uint8_t* row, int span, const SortRule& rule, Rng& runRng,
|
||||
std::vector<std::pair<int, std::array<std::uint8_t, 3>>>& run) {
|
||||
auto flush = [&](int start) {
|
||||
// A stable sort, so equal keys keep their order on every standard
|
||||
// library.
|
||||
std::stable_sort(run.begin(), run.end(), [&](const auto& a, const auto& b) {
|
||||
return rule.reverse ? a.first > b.first : a.first < b.first;
|
||||
});
|
||||
for (std::size_t i = 0; i < run.size(); ++i) {
|
||||
std::memcpy(row + (static_cast<std::size_t>(start) + i) * 3, run[i].second.data(), 3);
|
||||
}
|
||||
run.clear();
|
||||
};
|
||||
|
||||
int start = 0;
|
||||
int limit = rule.maxRun > 0 ? static_cast<int>(runRng.inRange(1, rule.maxRun)) : span;
|
||||
for (int x = 0; x < span; ++x) {
|
||||
const std::uint8_t* px = row + static_cast<std::size_t>(x) * 3;
|
||||
const int brightness = sortLuma(px);
|
||||
if (brightness < rule.low || brightness > rule.high) {
|
||||
flush(start);
|
||||
start = x + 1;
|
||||
continue;
|
||||
}
|
||||
if (static_cast<int>(run.size()) >= limit) {
|
||||
flush(start);
|
||||
start = x;
|
||||
if (rule.maxRun > 0) limit = static_cast<int>(runRng.inRange(1, rule.maxRun));
|
||||
}
|
||||
run.push_back({sortKeyOf(rule.key, px), {px[0], px[1], px[2]}});
|
||||
}
|
||||
flush(start);
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,143 @@
|
||||
#include "effects/striproll.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& StripRollEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("roll_height", "Strip height", 24, 1, 512,
|
||||
"Height in pixels of each strip."),
|
||||
ParamSpec::makeInt("roll_jitter", "Height jitter", 0, 0, 256,
|
||||
"Random +/- variation added to the strip height (0 = uniform)."),
|
||||
ParamSpec::makeBool("roll_keep_order", "Cycle images in order", false,
|
||||
"Take strips from the images in order instead of picking randomly."),
|
||||
ParamSpec::makeChoice("roll_axis", "Axis", "vertical", {"vertical", "horizontal"},
|
||||
"vertical rolls the picture in each strip up or down through its "
|
||||
"image; horizontal slides it sideways."),
|
||||
ParamSpec::makeChoice("roll_direction", "Direction", "alternate",
|
||||
{"alternate", "same", "random"},
|
||||
"alternate rolls every other strip the other way; same rolls them "
|
||||
"all one way; random picks for each strip."),
|
||||
ParamSpec::makeInt("roll_speed", "Speed", 120, 0, 8192,
|
||||
"Pixels per second the picture rolls, rounded so that each strip goes "
|
||||
"round its image a whole number of times in the clip; 0 holds still."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
int stripHeight;
|
||||
int jitter;
|
||||
bool keepOrder;
|
||||
bool horizontal;
|
||||
std::string direction;
|
||||
int speed;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: stripHeight(static_cast<int>(std::max(1L, paramInt(values, "roll_height", 24)))),
|
||||
jitter(static_cast<int>(std::max(0L, paramInt(values, "roll_jitter", 0)))),
|
||||
keepOrder(paramBool(values, "roll_keep_order", false)),
|
||||
horizontal(paramChoice(values, "roll_axis", "vertical") == "horizontal"),
|
||||
direction(paramChoice(values, "roll_direction", "alternate")),
|
||||
speed(static_cast<int>(std::max(0L, paramInt(values, "roll_speed", 120)))) {}
|
||||
};
|
||||
|
||||
// One strip of the canvas: where it lies, the image it shows, where in that
|
||||
// image its picture starts, and which way it rolls.
|
||||
struct Strip {
|
||||
int dstY;
|
||||
int height;
|
||||
std::size_t sourceIndex;
|
||||
int sx;
|
||||
int sy;
|
||||
int way; // +1 or -1
|
||||
};
|
||||
|
||||
int wrap(long value, int span) { return static_cast<int>((value % span + span) % span); }
|
||||
|
||||
} // namespace
|
||||
|
||||
Image StripRollEffect::compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
|
||||
// What stays put for the whole clip: the strips, drawn as Scanlines draws
|
||||
// them, and the way each one rolls.
|
||||
Rng steady = at.steady();
|
||||
std::vector<Strip> strips;
|
||||
strips.reserve(static_cast<std::size_t>(height / settings.stripHeight) + 8);
|
||||
for (int y = 0; y < height;) {
|
||||
Strip strip{};
|
||||
strip.dstY = y;
|
||||
strip.sourceIndex = settings.keepOrder ? strips.size() % sources.size()
|
||||
: steady.below(sources.size() - 1);
|
||||
const Image& src = sources[strip.sourceIndex];
|
||||
int h = settings.stripHeight;
|
||||
if (settings.jitter > 0) {
|
||||
h += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
||||
}
|
||||
strip.height = std::max(1, std::min(h, height - y));
|
||||
strip.sy = static_cast<int>(steady.inRange(0, src.height() - 1));
|
||||
strip.sx = static_cast<int>(steady.inRange(0, src.width() - 1));
|
||||
if (settings.direction == "random") {
|
||||
strip.way = (steady.next() >> 63) != 0 ? 1 : -1;
|
||||
} else if (settings.direction == "alternate") {
|
||||
strip.way = strips.size() % 2 == 0 ? 1 : -1;
|
||||
} else {
|
||||
strip.way = 1;
|
||||
}
|
||||
strips.push_back(strip);
|
||||
y += strip.height;
|
||||
}
|
||||
|
||||
// Where this frame is in time: how far round its image each strip has
|
||||
// rolled, a whole number of rounds in the clip, the nearest to the speed.
|
||||
std::vector<long> offsets(strips.size(), 0);
|
||||
if (settings.speed > 0) {
|
||||
for (std::size_t i = 0; i < strips.size(); ++i) {
|
||||
const Image& src = sources[strips[i].sourceIndex];
|
||||
const int span = settings.horizontal ? src.width() : src.height();
|
||||
const double rounds = std::max(1.0, std::round(settings.speed * at.length() / span));
|
||||
const double into = std::fmod(at.seconds() * rounds / at.length(), 1.0);
|
||||
offsets[i] = strips[i].way * std::lround(into * span);
|
||||
}
|
||||
}
|
||||
|
||||
Image canvas(width, height);
|
||||
parallelFor(strips.size(), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
const Strip& strip = strips[i];
|
||||
const Image& src = sources[strip.sourceIndex];
|
||||
const long down = settings.horizontal ? 0 : offsets[i];
|
||||
const long across = settings.horizontal ? offsets[i] : 0;
|
||||
const int startX = wrap(strip.sx + across, src.width());
|
||||
for (int line = 0; line < strip.height; ++line) {
|
||||
const std::uint8_t* in = src.row(wrap(strip.sy + line + down, src.height()));
|
||||
std::uint8_t* out = canvas.row(strip.dstY + line);
|
||||
// Round the image's width as often as the canvas needs.
|
||||
int x = 0;
|
||||
int from = startX;
|
||||
while (x < width) {
|
||||
const int run = std::min(width - x, src.width() - from);
|
||||
std::memcpy(out + static_cast<std::size_t>(x) * 3,
|
||||
in + static_cast<std::size_t>(from) * 3,
|
||||
static_cast<std::size_t>(run) * 3);
|
||||
x += run;
|
||||
from = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The scanlines effect in motion: the canvas is cut into strips as Scanlines
|
||||
// cuts it, each showing a slice of one of the source images, and the picture
|
||||
// in every strip rolls through its image -- up or down, or sideways -- while
|
||||
// the strips themselves stay put.
|
||||
//
|
||||
// Built from the sources, as TileSwapEffect is: it answers needsSources() and
|
||||
// writes compose(). A strip goes round its image a whole number of times in
|
||||
// the clip, so the clip loops without a jump.
|
||||
class StripRollEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "striproll"; }
|
||||
std::string title() const override { return "Strip roll"; }
|
||||
std::string description() const override {
|
||||
return "Cuts the canvas into strips of the source images, as Scanlines does, and rolls "
|
||||
"the picture in each strip through its image.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return true; }
|
||||
|
||||
protected:
|
||||
Image compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "effects/sweep.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/sort_runs.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& SweepEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeDouble("sweep_from", "From", 0.1, 0.0, 1.0,
|
||||
"Brightness the middle of the window starts the sweep at."),
|
||||
ParamSpec::makeDouble("sweep_to", "To", 0.9, 0.0, 1.0,
|
||||
"Brightness the middle of the window turns back at."),
|
||||
ParamSpec::makeDouble("sweep_width", "Window", 0.35, 0.0, 1.0,
|
||||
"How wide a band of brightness is sorted at once; pixels outside "
|
||||
"it break a run and stay where they are."),
|
||||
ParamSpec::makeDouble("sweep_speed", "Speed", 0.25, 0.0, 10.0,
|
||||
"Sweeps there and back per second, rounded so that the clip loops "
|
||||
"without a jump; 0 holds still.",
|
||||
0.05, 2),
|
||||
ParamSpec::makeChoice("sweep_key", "Sort by", "luma", {"luma", "hue", "saturation"},
|
||||
"What orders the pixels inside a run."),
|
||||
ParamSpec::makeChoice("sweep_direction", "Direction", "horizontal",
|
||||
{"horizontal", "vertical"}, "Whether rows or columns are sorted."),
|
||||
ParamSpec::makeBool("sweep_reverse", "Reverse", false,
|
||||
"Sort from high to low instead of from low to high."),
|
||||
ParamSpec::makeDouble("sweep_line_chance", "Line chance", 1.0, 0.0, 1.0,
|
||||
"Chance of each row (or column) being sorted at all."),
|
||||
ParamSpec::makeInt("sweep_max_run", "Longest run", 0, 0, 8192,
|
||||
"Each run is cut after a random length up to this many pixels, which "
|
||||
"frays the edges of the sorted streaks (0 = no limit)."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
double from;
|
||||
double to;
|
||||
double width;
|
||||
double speed;
|
||||
SortKey key;
|
||||
bool vertical;
|
||||
bool reverse;
|
||||
double lineChance;
|
||||
int maxRun;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: from(std::clamp(paramDouble(values, "sweep_from", 0.1), 0.0, 1.0)),
|
||||
to(std::clamp(paramDouble(values, "sweep_to", 0.9), 0.0, 1.0)),
|
||||
width(std::clamp(paramDouble(values, "sweep_width", 0.35), 0.0, 1.0)),
|
||||
speed(std::max(0.0, paramDouble(values, "sweep_speed", 0.25))),
|
||||
key(sortKeyNamed(paramChoice(values, "sweep_key", "luma"))),
|
||||
vertical(paramChoice(values, "sweep_direction", "horizontal") == "vertical"),
|
||||
reverse(paramBool(values, "sweep_reverse", false)),
|
||||
lineChance(std::clamp(paramDouble(values, "sweep_line_chance", 1.0), 0.0, 1.0)),
|
||||
maxRun(static_cast<int>(std::max(0L, paramInt(values, "sweep_max_run", 0)))) {}
|
||||
};
|
||||
|
||||
struct LinePlan {
|
||||
bool sorted = false;
|
||||
std::uint64_t seed = 0; // for the run lengths, the same on every frame
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image SweepEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
Image canvas = settings.vertical ? transposed(input) : std::move(input);
|
||||
const int lines = canvas.height();
|
||||
const int span = canvas.width();
|
||||
|
||||
// What stays put for the whole clip: which lines are sorted, and where
|
||||
// their runs are cut.
|
||||
Rng steady = at.steady();
|
||||
std::vector<LinePlan> plan(static_cast<std::size_t>(lines));
|
||||
for (auto& line : plan) {
|
||||
line.sorted = chance(steady.next(), settings.lineChance);
|
||||
line.seed = steady.next();
|
||||
}
|
||||
|
||||
// Where this frame is in time: the middle of the window eases from
|
||||
// `from` to `to` and back once a cycle.
|
||||
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||
const double ease = (1.0 - std::cos(2.0 * std::numbers::pi * t)) * 0.5;
|
||||
const double middle = settings.from + (settings.to - settings.from) * ease;
|
||||
SortRule rule;
|
||||
rule.low = static_cast<int>(std::lround((middle - settings.width * 0.5) * 255.0));
|
||||
rule.high = static_cast<int>(std::lround((middle + settings.width * 0.5) * 255.0));
|
||||
rule.key = settings.key;
|
||||
rule.reverse = settings.reverse;
|
||||
rule.maxRun = settings.maxRun;
|
||||
|
||||
parallelFor(static_cast<std::size_t>(lines), [&](std::size_t begin, std::size_t end) {
|
||||
std::vector<std::pair<int, std::array<std::uint8_t, 3>>> run;
|
||||
for (std::size_t line = begin; line < end; ++line) {
|
||||
if (!plan[line].sorted) continue;
|
||||
Rng runRng(plan[line].seed);
|
||||
sortLine(canvas.row(static_cast<int>(line)), span, rule, runRng, run);
|
||||
}
|
||||
});
|
||||
|
||||
return settings.vertical ? transposed(canvas) : canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The pixel sort in motion: only pixels inside a window of brightness are
|
||||
// sorted, and the window sweeps from the dark end to the bright end and back,
|
||||
// so the sorted streaks spread over one part of the picture after another,
|
||||
// melt away and return.
|
||||
//
|
||||
// Which lines are sorted, and how their runs are cut, is drawn from
|
||||
// at.steady() and stays put; the window alone moves, there and back once a
|
||||
// cycle, so the clip loops without a jump.
|
||||
class SweepEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "sweep"; }
|
||||
std::string title() const override { return "Sort sweep"; }
|
||||
std::string description() const override {
|
||||
return "Sorts runs of pixels as Pixel sort does, only within a window of brightness that "
|
||||
"sweeps from dark to bright and back.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -16,7 +16,7 @@ const std::vector<ParamSpec>& TextEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeText("text", "Text", "Effector",
|
||||
"What to write; a line break starts a new line."),
|
||||
ParamSpec::makeText("font_family", "Font", "Sans",
|
||||
ParamSpec::makeFont("font_family", "Font", "Sans",
|
||||
"Font family, as it is named on this machine: Sans, Serif, Monospace, "
|
||||
"\"DejaVu Serif\", ...")
|
||||
.styled(),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
#include "core/rng.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// One cut-out: `w` by `h` pixels taken from source `sourceIndex` at (sx, sy)
|
||||
// and laid down at (dstX, dstY). Shared by the tiles and the tile swap effects.
|
||||
struct Tile {
|
||||
int dstX;
|
||||
int dstY;
|
||||
int w;
|
||||
int h;
|
||||
int sourceIndex;
|
||||
int sx;
|
||||
int sy;
|
||||
};
|
||||
|
||||
// Splits `span` into consecutive pieces of `base` pixels give or take `jitter`,
|
||||
// the last one clipped so that the pieces cover the span exactly.
|
||||
inline std::vector<int> planSpans(Rng& rng, int base, int jitter, int span) {
|
||||
std::vector<int> spans;
|
||||
spans.reserve(static_cast<std::size_t>(span / base) + 8);
|
||||
int used = 0;
|
||||
while (used < span) {
|
||||
int size = base;
|
||||
if (jitter > 0) size += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||
size = std::max(1, std::min(size, span - used));
|
||||
spans.push_back(size);
|
||||
used += size;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
inline void paintTile(Image& canvas, const Image& src, const Tile& tile) {
|
||||
const int copyWidth = std::min(tile.w, src.width() - tile.sx);
|
||||
const int copyHeight = std::min(tile.h, src.height() - tile.sy);
|
||||
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
|
||||
const std::size_t total = static_cast<std::size_t>(tile.w) * 3;
|
||||
const std::size_t offset = static_cast<std::size_t>(tile.dstX) * 3;
|
||||
|
||||
for (int line = 0; line < copyHeight; ++line) {
|
||||
const std::uint8_t* in = src.row(tile.sy + line) + static_cast<std::size_t>(tile.sx) * 3;
|
||||
std::uint8_t* out = canvas.row(tile.dstY + line) + offset;
|
||||
|
||||
std::memcpy(out, in, copyBytes);
|
||||
// Repeat the cut sideways when the source is narrower than the tile,
|
||||
// doubling the filled span on each pass.
|
||||
std::size_t filled = copyBytes;
|
||||
while (filled < total) {
|
||||
const std::size_t chunk = std::min(filled, total - filled);
|
||||
std::memcpy(out + filled, out, chunk);
|
||||
filled += chunk;
|
||||
}
|
||||
}
|
||||
// Same again downwards, out of the lines already written.
|
||||
for (int line = copyHeight; line < tile.h; ++line) {
|
||||
std::memcpy(canvas.row(tile.dstY + line) + offset,
|
||||
canvas.row(tile.dstY + (line % copyHeight)) + offset, total);
|
||||
}
|
||||
}
|
||||
|
||||
// Paints the whole plan. Overlapping tiles (the scattered layout) are painted
|
||||
// in plan order on one thread: letting threads race for the same pixels would
|
||||
// make the output depend on their scheduling.
|
||||
inline void paintTiles(Image& canvas, const std::vector<Image>& sources,
|
||||
const std::vector<Tile>& plan, bool overlapping) {
|
||||
if (overlapping) {
|
||||
for (const Tile& tile : plan) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(tile.sourceIndex)], tile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(plan[i].sourceIndex)], plan[i]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
+2
-76
@@ -1,11 +1,10 @@
|
||||
#include "effects/tiles.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
#include "effects/tile_plan.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -29,66 +28,6 @@ const std::vector<ParamSpec>& TilesEffect::params() const {
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One cut-out: `w` by `h` pixels taken from source `sourceIndex` at (sx, sy)
|
||||
// and laid down at (dstX, dstY).
|
||||
struct Tile {
|
||||
int dstX;
|
||||
int dstY;
|
||||
int w;
|
||||
int h;
|
||||
int sourceIndex;
|
||||
int sx;
|
||||
int sy;
|
||||
};
|
||||
|
||||
// Splits `span` into consecutive pieces of `base` pixels give or take `jitter`,
|
||||
// the last one clipped so that the pieces cover the span exactly.
|
||||
std::vector<int> planSpans(Rng& rng, int base, int jitter, int span) {
|
||||
std::vector<int> spans;
|
||||
spans.reserve(static_cast<std::size_t>(span / base) + 8);
|
||||
int used = 0;
|
||||
while (used < span) {
|
||||
int size = base;
|
||||
if (jitter > 0) size += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||
size = std::max(1, std::min(size, span - used));
|
||||
spans.push_back(size);
|
||||
used += size;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
void paintTile(Image& canvas, const Image& src, const Tile& tile) {
|
||||
const int copyWidth = std::min(tile.w, src.width() - tile.sx);
|
||||
const int copyHeight = std::min(tile.h, src.height() - tile.sy);
|
||||
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
|
||||
const std::size_t total = static_cast<std::size_t>(tile.w) * 3;
|
||||
const std::size_t offset = static_cast<std::size_t>(tile.dstX) * 3;
|
||||
|
||||
for (int line = 0; line < copyHeight; ++line) {
|
||||
const std::uint8_t* in = src.row(tile.sy + line) + static_cast<std::size_t>(tile.sx) * 3;
|
||||
std::uint8_t* out = canvas.row(tile.dstY + line) + offset;
|
||||
|
||||
std::memcpy(out, in, copyBytes);
|
||||
// Repeat the cut sideways when the source is narrower than the tile,
|
||||
// doubling the filled span on each pass.
|
||||
std::size_t filled = copyBytes;
|
||||
while (filled < total) {
|
||||
const std::size_t chunk = std::min(filled, total - filled);
|
||||
std::memcpy(out + filled, out, chunk);
|
||||
filled += chunk;
|
||||
}
|
||||
}
|
||||
// Same again downwards, out of the lines already written.
|
||||
for (int line = copyHeight; line < tile.h; ++line) {
|
||||
std::memcpy(canvas.row(tile.dstY + line) + offset,
|
||||
canvas.row(tile.dstY + (line % copyHeight)) + offset, total);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image TilesEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (sources.empty()) throw EffectError("No source images.");
|
||||
@@ -159,20 +98,7 @@ Image TilesEffect::render(const std::vector<Image>& sources, int width, int heig
|
||||
}
|
||||
}
|
||||
|
||||
if (layout == "scatter") {
|
||||
// Scattered tiles overlap, so they are painted in plan order on one
|
||||
// thread: letting threads race for the same pixels would make the
|
||||
// output depend on their scheduling.
|
||||
for (const Tile& tile : plan) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(tile.sourceIndex)], tile);
|
||||
}
|
||||
} else {
|
||||
parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(plan[i].sourceIndex)], plan[i]);
|
||||
}
|
||||
});
|
||||
}
|
||||
paintTiles(canvas, sources, plan, layout == "scatter");
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#include "effects/tileswap.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
#include "effects/source_fill.hpp"
|
||||
#include "effects/tile_plan.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& TileSwapEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeChoice("tileswap_layout", "Layout", "grid", {"grid", "brick", "scatter"},
|
||||
"grid lines the tiles up in rows and columns; brick packs every row "
|
||||
"on its own so the seams do not meet; scatter drops loose tiles over "
|
||||
"a filled background, overlapping."),
|
||||
ParamSpec::makeInt("tileswap_width", "Tile width", 96, 1, 2048,
|
||||
"Width in pixels of each cut-out tile."),
|
||||
ParamSpec::makeInt("tileswap_height", "Tile height", 96, 1, 2048,
|
||||
"Height in pixels of each cut-out tile."),
|
||||
ParamSpec::makeInt("tileswap_jitter", "Size jitter", 0, 0, 512,
|
||||
"Random +/- variation added to both tile sizes (0 = uniform)."),
|
||||
ParamSpec::makeInt("tileswap_count", "Tile count", 64, 1, 4096,
|
||||
"How many tiles are dropped on the canvas; scattered layout only."),
|
||||
ParamSpec::makeBool("tileswap_keep_order", "Cycle images in order", false,
|
||||
"Take tiles from the images in order instead of picking randomly."),
|
||||
ParamSpec::makeDouble("tileswap_rate", "Swap rate", 1.0, 0.0, 5.0,
|
||||
"Swaps per second of each tile, on average; 0 for none. Below two "
|
||||
"swaps in the clip only some of the tiles swap, fewer the lower it "
|
||||
"goes, so that the clip still loops without a jump.",
|
||||
0.01, 2)
|
||||
.slider(3.0),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
std::string layout;
|
||||
int tileWidth;
|
||||
int tileHeight;
|
||||
int jitter;
|
||||
long count;
|
||||
bool keepOrder;
|
||||
double rate;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: layout(paramChoice(values, "tileswap_layout", "grid")),
|
||||
tileWidth(static_cast<int>(std::max(1L, paramInt(values, "tileswap_width", 96)))),
|
||||
tileHeight(static_cast<int>(std::max(1L, paramInt(values, "tileswap_height", 96)))),
|
||||
jitter(static_cast<int>(std::max(0L, paramInt(values, "tileswap_jitter", 0)))),
|
||||
count(std::max(1L, paramInt(values, "tileswap_count", 64))),
|
||||
keepOrder(paramBool(values, "tileswap_keep_order", false)),
|
||||
rate(std::max(0.0, paramDouble(values, "tileswap_rate", 1.0))) {}
|
||||
};
|
||||
|
||||
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
|
||||
|
||||
// Where a tile sits for the whole clip, when in its swaps it turns over, the
|
||||
// seed its cuts come from, and the draw that settles how often it swaps.
|
||||
struct Cell {
|
||||
int x;
|
||||
int y;
|
||||
int w;
|
||||
int h;
|
||||
double phase;
|
||||
std::uint64_t seed;
|
||||
double share;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image TileSwapEffect::compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const bool scatter = settings.layout == "scatter";
|
||||
Image canvas(width, height);
|
||||
|
||||
// What stays put for the whole clip: the cells, and for loose tiles the
|
||||
// backdrop they lie on.
|
||||
Rng steady = at.steady();
|
||||
std::vector<Cell> cells;
|
||||
auto addCell = [&](int x, int y, int w, int h) {
|
||||
cells.push_back(Cell{x, y, w, h, 0.0, 0, 0.0});
|
||||
};
|
||||
if (scatter) {
|
||||
const Image& src = sources[steady.below(sources.size() - 1)];
|
||||
const int sx = static_cast<int>(steady.inRange(0, std::max(0, src.width() - width)));
|
||||
const int sy = static_cast<int>(steady.inRange(0, std::max(0, src.height() - height)));
|
||||
fillFromSource(canvas, src, sx, sy);
|
||||
|
||||
cells.reserve(static_cast<std::size_t>(settings.count));
|
||||
for (long i = 0; i < settings.count; ++i) {
|
||||
int w = settings.tileWidth;
|
||||
int h = settings.tileHeight;
|
||||
if (settings.jitter > 0) {
|
||||
w += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
||||
h += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
||||
}
|
||||
const int x = static_cast<int>(steady.inRange(0, width - 1));
|
||||
const int y = static_cast<int>(steady.inRange(0, height - 1));
|
||||
addCell(x, y, std::max(1, std::min(w, width - x)), std::max(1, std::min(h, height - y)));
|
||||
}
|
||||
} else {
|
||||
const bool brick = settings.layout == "brick";
|
||||
const std::vector<int> rows = planSpans(steady, settings.tileHeight, settings.jitter, height);
|
||||
std::vector<int> columns;
|
||||
if (!brick) columns = planSpans(steady, settings.tileWidth, settings.jitter, width);
|
||||
int y = 0;
|
||||
for (const int rowHeight : rows) {
|
||||
if (brick) columns = planSpans(steady, settings.tileWidth, settings.jitter, width);
|
||||
int x = 0;
|
||||
for (const int cellWidth : columns) {
|
||||
addCell(x, y, cellWidth, rowHeight);
|
||||
x += cellWidth;
|
||||
}
|
||||
y += rowHeight;
|
||||
}
|
||||
}
|
||||
for (Cell& cell : cells) {
|
||||
cell.phase = unit(steady);
|
||||
cell.seed = steady.next();
|
||||
cell.share = unit(steady);
|
||||
}
|
||||
|
||||
// How often each tile swaps in one pass of the clip, `each` on average. A
|
||||
// tile has to come back to the cut it started with, so one that swaps at
|
||||
// all swaps at least twice; below that a share of the tiles swap twice and
|
||||
// the rest hold still, so a low rate thins out the tiles that move instead
|
||||
// of moving every one of them. Above it, each tile rounds `each` up or
|
||||
// down by its own draw.
|
||||
const double length = at.length();
|
||||
const double each = settings.rate * length;
|
||||
auto swapsOf = [&](const Cell& cell) -> long {
|
||||
if (each <= 0.0) return 0;
|
||||
if (each < 2.0) return cell.share < each / 2.0 ? 2 : 0;
|
||||
const double whole = std::floor(each);
|
||||
return static_cast<long>(whole) + (cell.share < each - whole ? 1 : 0);
|
||||
};
|
||||
|
||||
// What a tile shows depends only on the tile and how many times it has
|
||||
// swapped, counted round the clip so that the frame after the last shows
|
||||
// what the first does; so it holds still between swaps and any frame can
|
||||
// be drawn on its own.
|
||||
std::vector<Tile> plan;
|
||||
plan.reserve(cells.size());
|
||||
for (std::size_t i = 0; i < cells.size(); ++i) {
|
||||
const Cell& cell = cells[i];
|
||||
const long swaps = swapsOf(cell);
|
||||
std::uint64_t turn = 0;
|
||||
if (swaps > 0) {
|
||||
const auto passed =
|
||||
static_cast<long>(std::floor(at.seconds() * swaps / length + cell.phase));
|
||||
turn = static_cast<std::uint64_t>(passed % swaps);
|
||||
}
|
||||
Rng cut(cell.seed + turn * 0x9e3779b97f4a7c15ULL);
|
||||
const std::size_t sourceIndex =
|
||||
settings.keepOrder ? (i + turn) % sources.size() : cut.below(sources.size() - 1);
|
||||
const Image& src = sources[sourceIndex];
|
||||
const int sx = static_cast<int>(cut.inRange(0, std::max(0, src.width() - cell.w)));
|
||||
const int sy = static_cast<int>(cut.inRange(0, std::max(0, src.height() - cell.h)));
|
||||
plan.push_back(Tile{cell.x, cell.y, cell.w, cell.h, static_cast<int>(sourceIndex), sx, sy});
|
||||
}
|
||||
|
||||
paintTiles(canvas, sources, plan, scatter);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The tiles effect in motion: the canvas is laid with tiles cut from the
|
||||
// source images, and each tile is swapped for another cut, at a moment of its
|
||||
// own, so the mosaic keeps changing piece by piece.
|
||||
//
|
||||
// The video effect built from the sources rather than transforming its input:
|
||||
// it answers needsSources() and writes compose(). The layout is drawn from
|
||||
// at.steady() and stays put; what a tile shows depends only on the tile and
|
||||
// how many times it has swapped, so it holds still between its swaps.
|
||||
class TileSwapEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "tileswap"; }
|
||||
std::string title() const override { return "Tile swap"; }
|
||||
std::string description() const override {
|
||||
return "Lays tiles cut from the source images over the canvas, as Tiles does, and swaps "
|
||||
"each one for another cut at a moment of its own.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return true; }
|
||||
|
||||
protected:
|
||||
Image compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,147 @@
|
||||
#include "effects/tracking.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& TrackingEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("tracking_wobble", "Wobble", 4, 0, 256,
|
||||
"How far in pixels the colour planes sway apart and back."),
|
||||
ParamSpec::makeDouble("tracking_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),
|
||||
ParamSpec::makeInt("tracking_jitter", "Jitter", 2, 0, 256,
|
||||
"Furthest in pixels a band shakes, anew on every frame."),
|
||||
ParamSpec::makeInt("tracking_band_height", "Band height", 24, 1, 2048,
|
||||
"Thickness in pixels of the bands that shake on their own."),
|
||||
ParamSpec::makeInt("tracking_bar", "Tracking bar", 16, 0, 512,
|
||||
"How far in pixels the rolling bar tears the rows sideways and the "
|
||||
"colour planes apart; 0 for none."),
|
||||
ParamSpec::makeDouble("tracking_bar_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),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
int wobble;
|
||||
double wobbleSpeed;
|
||||
int jitter;
|
||||
int band;
|
||||
int bar;
|
||||
double barTime;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: wobble(static_cast<int>(std::max(0L, paramInt(values, "tracking_wobble", 4)))),
|
||||
wobbleSpeed(std::max(0.0, paramDouble(values, "tracking_wobble_speed", 0.5))),
|
||||
jitter(static_cast<int>(std::max(0L, paramInt(values, "tracking_jitter", 2)))),
|
||||
band(static_cast<int>(std::max(1L, paramInt(values, "tracking_band_height", 24)))),
|
||||
bar(static_cast<int>(std::max(0L, paramInt(values, "tracking_bar", 16)))),
|
||||
barTime(std::max(0.5, paramDouble(values, "tracking_bar_time", 4.0))) {}
|
||||
};
|
||||
|
||||
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
|
||||
|
||||
// How far each of the red, green and blue planes is moved on one row.
|
||||
struct RowShift {
|
||||
std::array<int, 3> dx{};
|
||||
std::array<int, 3> dy{};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image TrackingEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const int width = input.width();
|
||||
const int height = input.height();
|
||||
constexpr double tau = 2.0 * std::numbers::pi;
|
||||
|
||||
// What stays put for the whole clip: where each plane's sway starts, and
|
||||
// the direction it sways in.
|
||||
Rng steady = at.steady();
|
||||
std::array<double, 3> phase{};
|
||||
std::array<double, 3> angle{};
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
phase[c] = unit(steady) * tau;
|
||||
angle[c] = unit(steady) * tau;
|
||||
}
|
||||
|
||||
// Where this frame is in time: the sway of each plane, and the bar -- an
|
||||
// eighth of the picture, soft at both edges, entering above the top and
|
||||
// leaving below the bottom.
|
||||
std::array<double, 3> swayX{};
|
||||
std::array<double, 3> swayY{};
|
||||
if (settings.wobble > 0 && settings.wobbleSpeed > 0.0) {
|
||||
const double turn = at.cycle(settings.wobbleSpeed) * tau;
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
const double sway = settings.wobble * std::sin(turn + phase[c]);
|
||||
swayX[c] = sway * std::cos(angle[c]);
|
||||
swayY[c] = sway * std::sin(angle[c]);
|
||||
}
|
||||
}
|
||||
const double barHeight = std::max(8.0, height / 8.0);
|
||||
const double barTop = -barHeight + at.cycle(1.0 / settings.barTime) * (height + barHeight);
|
||||
|
||||
// What changes on every frame: each band's shake, mostly sideways, as a
|
||||
// picture losing its horizontal hold. All of it drawn, band by band,
|
||||
// before a pixel is touched.
|
||||
Rng fresh = at.fresh();
|
||||
std::vector<RowShift> rows(static_cast<std::size_t>(height));
|
||||
std::vector<int> tear(static_cast<std::size_t>(height), 0);
|
||||
for (int top = 0; top < height; top += settings.band) {
|
||||
std::array<double, 3> jitterX{};
|
||||
std::array<double, 3> jitterY{};
|
||||
if (settings.jitter > 0) {
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
jitterX[c] = static_cast<double>(fresh.inRange(-settings.jitter, settings.jitter));
|
||||
jitterY[c] = static_cast<double>(fresh.inRange(-settings.jitter, settings.jitter)) / 3.0;
|
||||
}
|
||||
}
|
||||
const int end = std::min(height, top + settings.band);
|
||||
for (int y = top; y < end; ++y) {
|
||||
// The bar pulls red one way and blue the other, and drags the
|
||||
// whole row half as far.
|
||||
double pull = 0.0;
|
||||
if (settings.bar > 0 && y >= barTop && y < barTop + barHeight) {
|
||||
pull = settings.bar * std::sin(std::numbers::pi * (y - barTop) / barHeight);
|
||||
}
|
||||
constexpr std::array<double, 3> apart = {1.0, 0.0, -1.0};
|
||||
auto& row = rows[static_cast<std::size_t>(y)];
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
row.dx[c] = static_cast<int>(std::lround(swayX[c] + jitterX[c] + pull * apart[c]));
|
||||
row.dy[c] = static_cast<int>(std::lround(swayY[c] + jitterY[c]));
|
||||
}
|
||||
tear[static_cast<std::size_t>(y)] = static_cast<int>(std::lround(pull / 2.0));
|
||||
}
|
||||
}
|
||||
|
||||
Image frame(width, height);
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
std::uint8_t* out = frame.row(static_cast<int>(y));
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
const int dx = rows[y].dx[c] + tear[y];
|
||||
const int sy = std::clamp(static_cast<int>(y) - rows[y].dy[c], 0, height - 1);
|
||||
const std::uint8_t* in = input.row(sy);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// A worn tape losing its hold: the red, green and blue planes sway apart and
|
||||
// back, bands of the picture shake sideways from frame to frame, and a
|
||||
// tracking bar rolls down the picture tearing the rows it passes.
|
||||
//
|
||||
// The first video effect, and the one to copy for the next: its params are
|
||||
// declared like any effect's, and animate() reads them, takes what stays put
|
||||
// from at.steady(), what changes every frame from at.fresh(), and time from
|
||||
// at.cycle() -- then draws the frame in one pass over the pixels.
|
||||
class TrackingEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "tracking"; }
|
||||
std::string title() const override { return "VHS tracking"; }
|
||||
std::string description() const override {
|
||||
return "A worn tape losing its hold: the colour planes sway and shake, and a tracking bar "
|
||||
"rolls down the picture.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,136 @@
|
||||
#include "effects/tunnel.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& TunnelEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("tunnel_count", "Echoes", 8, 2, 32,
|
||||
"How many echoes are nested inside the picture at once."),
|
||||
ParamSpec::makeDouble("tunnel_scale", "Scale", 0.8, 0.3, 0.95,
|
||||
"Size of each echo against the one around it.", 0.01),
|
||||
ParamSpec::makeDouble("tunnel_rotate", "Rotation", 6.0, -180.0, 180.0,
|
||||
"Turn in degrees of each echo against the one around it.", 0.5, 1),
|
||||
ParamSpec::makeInt("tunnel_drift", "Drift", 0, 0, 4096,
|
||||
"How far in pixels each echo sits off the centre of the one around "
|
||||
"it, all in one direction of the seed's own."),
|
||||
ParamSpec::makeDouble("tunnel_fade", "Opacity", 0.8, 0.0, 1.0,
|
||||
"How strongly each echo covers what lies beneath it."),
|
||||
ParamSpec::makeDouble("tunnel_speed", "Speed", 0.5, 0.0, 10.0,
|
||||
"Echoes passed per second, rounded so that the clip loops without "
|
||||
"a jump; 0 holds still.",
|
||||
0.1, 1),
|
||||
ParamSpec::makeChoice("tunnel_direction", "Direction", "in", {"in", "out"},
|
||||
"in flies into the tunnel, the echoes growing towards you; out "
|
||||
"backs away from it, the echoes shrinking into the distance."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
int count;
|
||||
double scale;
|
||||
double rotate;
|
||||
int drift;
|
||||
double fade;
|
||||
double speed;
|
||||
bool out;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: count(static_cast<int>(std::clamp(paramInt(values, "tunnel_count", 8), 2L, 32L))),
|
||||
scale(std::clamp(paramDouble(values, "tunnel_scale", 0.8), 0.3, 0.95)),
|
||||
rotate(std::clamp(paramDouble(values, "tunnel_rotate", 6.0), -180.0, 180.0)),
|
||||
drift(static_cast<int>(std::max(0L, paramInt(values, "tunnel_drift", 0)))),
|
||||
fade(std::clamp(paramDouble(values, "tunnel_fade", 0.8), 0.0, 1.0)),
|
||||
speed(std::max(0.0, paramDouble(values, "tunnel_speed", 0.5))),
|
||||
out(paramChoice(values, "tunnel_direction", "in") == "out") {}
|
||||
};
|
||||
|
||||
// One echo as it lies in this frame: how a pixel of the canvas finds the
|
||||
// pixel of the input it shows, and how strongly it covers what is beneath.
|
||||
struct Echo {
|
||||
double sine;
|
||||
double cosine;
|
||||
double shrink; // 1 / size
|
||||
double dx;
|
||||
double dy;
|
||||
int mix; // out of 256
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image TunnelEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const int width = input.width();
|
||||
const int height = input.height();
|
||||
|
||||
// What stays put for the whole clip: the direction the echoes drift in.
|
||||
Rng steady = at.steady();
|
||||
double driftSine = 0.0;
|
||||
double driftCosine = 1.0;
|
||||
sinCosDegrees(unitDouble(steady.next()) * 360.0, driftSine, driftCosine);
|
||||
|
||||
// Where this frame is in time: how far each echo has moved on from its
|
||||
// place at the start of the step. Echo k sits at depth k + 1 - t flying
|
||||
// in, k + t backing out; at depth 0 it is the picture itself, so it can
|
||||
// melt into it, and a new one fades in at the far end as the old leaves.
|
||||
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||
std::vector<Echo> echoes;
|
||||
echoes.reserve(static_cast<std::size_t>(settings.count));
|
||||
for (int k = 0; k < settings.count; ++k) {
|
||||
const double depth = settings.out ? k + t : k + 1.0 - t;
|
||||
const double weight = settings.fade * std::clamp(depth, 0.0, 1.0) *
|
||||
std::clamp(settings.count - depth, 0.0, 1.0);
|
||||
const int mix = static_cast<int>(weight * 256.0 + 0.5);
|
||||
if (mix <= 0) continue;
|
||||
Echo echo{};
|
||||
sinCosDegrees(settings.rotate * depth, echo.sine, echo.cosine);
|
||||
echo.shrink = 1.0 / std::pow(settings.scale, depth);
|
||||
echo.dx = settings.drift * depth * driftCosine;
|
||||
echo.dy = settings.drift * depth * driftSine;
|
||||
echo.mix = mix;
|
||||
echoes.push_back(echo);
|
||||
}
|
||||
if (echoes.empty()) return input;
|
||||
|
||||
// The echoes are laid largest first, so the deeper ones nest inside.
|
||||
const double cx = (width - 1) * 0.5;
|
||||
const double cy = (height - 1) * 0.5;
|
||||
Image frame(width, height);
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t row = begin; row < end; ++row) {
|
||||
const std::uint8_t* base = input.row(static_cast<int>(row));
|
||||
std::uint8_t* out = frame.row(static_cast<int>(row));
|
||||
for (int col = 0; col < width; ++col) {
|
||||
const std::size_t px = static_cast<std::size_t>(col) * 3;
|
||||
int colour[3] = {base[px], base[px + 1], base[px + 2]};
|
||||
for (const Echo& echo : echoes) {
|
||||
const double rx = col - cx - echo.dx;
|
||||
const double ry = static_cast<double>(row) - cy - echo.dy;
|
||||
const double sx = cx + (rx * echo.cosine + ry * echo.sine) * echo.shrink;
|
||||
const double sy = cy + (-rx * echo.sine + ry * echo.cosine) * echo.shrink;
|
||||
if (sx < 0.0 || sy < 0.0 || sx > width - 1 || sy > height - 1) continue;
|
||||
std::uint8_t echoed[3];
|
||||
sampleBilinear(input, sx, sy, echoed);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
colour[c] += (echoed[c] - colour[c]) * echo.mix / 256;
|
||||
}
|
||||
}
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
out[px + static_cast<std::size_t>(c)] = static_cast<std::uint8_t>(colour[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The feedback effect in motion: the picture nested inside itself, each copy
|
||||
// smaller, turned and drifted further than the one around it, and the whole
|
||||
// stack flying inwards (or outwards) without end.
|
||||
//
|
||||
// A copy sits at a depth, and its size, turn and drift follow from that depth
|
||||
// alone. Every frame moves each copy on by part of one step, so after a whole
|
||||
// cycle each copy lies exactly where its neighbour started: a new one fades
|
||||
// in at the far end while the nearest fades out into the picture itself, and
|
||||
// the clip loops without a jump.
|
||||
class TunnelEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "tunnel"; }
|
||||
std::string title() const override { return "Echo tunnel"; }
|
||||
std::string description() const override {
|
||||
return "Nests the picture inside itself, as Feedback does, and flies through the echoes "
|
||||
"without end.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
protected:
|
||||
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,66 @@
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "core/video.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
double Moment::length() const noexcept {
|
||||
return static_cast<double>(std::max(1, frame_.count)) / std::max(1, frame_.fps);
|
||||
}
|
||||
|
||||
double Moment::loopRate(double perSecond) const noexcept {
|
||||
const double clip = length();
|
||||
return std::max(1.0, std::round(perSecond * clip)) / clip;
|
||||
}
|
||||
|
||||
double Moment::cycle(double perSecond) const noexcept {
|
||||
return std::fmod(seconds() * loopRate(perSecond), 1.0);
|
||||
}
|
||||
|
||||
Rng Moment::fresh() const noexcept {
|
||||
return Rng(seed_ ^ (0x9e3779b97f4a7c15ULL * static_cast<std::uint64_t>(frame_.index + 1)));
|
||||
}
|
||||
|
||||
Image VideoEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
return renderFrame(sources, width, height, rng, values, FrameTime{0, kDefaultFps, kDefaultFps});
|
||||
}
|
||||
|
||||
Image VideoEffect::renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values, const FrameTime& frame) const {
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
const FrameTime at = frame.video() ? frame : FrameTime{0, kDefaultFps, kDefaultFps};
|
||||
|
||||
// An effect built from the sources takes no crop, only the one seed
|
||||
// everything it draws comes from -- the same value on every frame.
|
||||
if (needsSources()) {
|
||||
if (sources.empty()) throw EffectError("No source images.");
|
||||
const std::uint64_t seed = rng.next();
|
||||
return compose(sources, width, height, values, Moment(at, seed));
|
||||
}
|
||||
|
||||
// Both of these take the same values from the shared rng on every frame:
|
||||
// the crop a leading stage starts from, and the one seed everything the
|
||||
// effect draws comes from.
|
||||
Image input = canvasFromSources(sources, width, height, rng);
|
||||
const std::uint64_t seed = rng.next();
|
||||
|
||||
return animate(std::move(input), values, Moment(at, seed));
|
||||
}
|
||||
|
||||
// A video effect writes one of these two; the other is never called.
|
||||
Image VideoEffect::animate(Image, const ParamValues&, const Moment&) const {
|
||||
throw std::logic_error(name() + " is a transform but writes no animate()");
|
||||
}
|
||||
|
||||
Image VideoEffect::compose(const std::vector<Image>&, int, int, const ParamValues&,
|
||||
const Moment&) const {
|
||||
throw std::logic_error(name() + " needs sources but writes no compose()");
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Where one frame sits in its clip, and the randomness it may draw on, as a
|
||||
// video effect is handed it. Time is always read through here: the rates it
|
||||
// hands out are rounded to fit the clip, so whatever an effect does with
|
||||
// them, the last frame leads straight back into the first and a clip loops
|
||||
// without a jump.
|
||||
class Moment {
|
||||
public:
|
||||
Moment(const FrameTime& frame, std::uint64_t seed) noexcept : frame_(frame), seed_(seed) {}
|
||||
|
||||
int index() const noexcept { return frame_.index; }
|
||||
int frames() const noexcept { return frame_.count; }
|
||||
int fps() const noexcept { return frame_.fps; }
|
||||
double seconds() const noexcept { return frame_.seconds(); } // into the clip
|
||||
double length() const noexcept; // of the whole clip
|
||||
|
||||
// The rate nearest `perSecond` at which a whole number of cycles fits the
|
||||
// clip, and never fewer than one.
|
||||
double loopRate(double perSecond) const noexcept;
|
||||
|
||||
// How far through a cycle that happens `perSecond` times a second this
|
||||
// frame is, from 0 at its start towards 1 at its end: the one number most
|
||||
// motion needs -- sin(2π · cycle) sways, cycle · height rolls down.
|
||||
double cycle(double perSecond) const noexcept;
|
||||
|
||||
// Draws that come out the same on every frame of the clip: where a sway
|
||||
// starts, which way it goes -- what makes the motion this seed's own.
|
||||
Rng steady() const noexcept { return Rng(seed_); }
|
||||
|
||||
// Draws that are new on every frame: shake, noise, flicker. The same
|
||||
// frame of the same seed still draws the same values, so frames can be
|
||||
// rendered alone and in any order.
|
||||
Rng fresh() const noexcept;
|
||||
|
||||
private:
|
||||
FrameTime frame_;
|
||||
std::uint64_t seed_;
|
||||
};
|
||||
|
||||
// The base of every video effect, and the one place that keeps the rules a
|
||||
// moving stage has to follow, so an effect built on it is only its look:
|
||||
//
|
||||
// - It takes one value from the shared rng -- the same on every frame -- and
|
||||
// hands the effect generators of its own through the Moment. The stages
|
||||
// after it therefore see the rng in one state throughout and hold still
|
||||
// while it moves.
|
||||
// - It is one of two kinds. A transform (the default) writes animate(): it
|
||||
// needs no source images, and leading a chain it starts from a crop of
|
||||
// one, as the other transforms do. One that builds its frames out of the
|
||||
// sources answers needsSources() and writes compose() instead: it is
|
||||
// handed the loaded photographs when it leads a chain and the stage before
|
||||
// it otherwise, as the image effects are, and takes no crop.
|
||||
// - It never runs in a still project (the chain skips it). Asked for a still
|
||||
// directly, it gives the first frame of a one-second clip.
|
||||
//
|
||||
// A new video effect subclasses this, declares its params like any effect,
|
||||
// and writes animate() or compose(); see TrackingEffect for a transform and
|
||||
// TileSwapEffect for one built from the sources.
|
||||
class VideoEffect : public Effect {
|
||||
public:
|
||||
bool video() const final { return true; }
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const final;
|
||||
Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values, const FrameTime& frame) const final;
|
||||
|
||||
protected:
|
||||
// One frame of the clip: `input` is the picture this stage is handed,
|
||||
// already at the canvas size, and what comes back is the same size.
|
||||
// Every random value comes from `at`, never from anywhere else. A
|
||||
// transform writes this.
|
||||
virtual Image animate(Image input, const ParamValues& values, const Moment& at) const;
|
||||
|
||||
// One frame built out of `sources` (never empty) at the canvas size, for
|
||||
// an effect that answers needsSources(); the same rule on randomness.
|
||||
virtual Image compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
+4
-101
@@ -5,6 +5,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/noise_field.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
@@ -26,88 +27,6 @@ const std::vector<ParamSpec>& WarpEffect::params() const {
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One layer of value noise: random heights on a lattice `spacing` pixels
|
||||
// apart, blended smoothly between the lattice points.
|
||||
class NoiseLayer {
|
||||
public:
|
||||
NoiseLayer(Rng& rng, int width, int height, double spacing)
|
||||
: spacing_(spacing),
|
||||
cols_(static_cast<int>(width / spacing) + 2),
|
||||
rows_(static_cast<int>(height / spacing) + 2),
|
||||
lattice_(static_cast<std::size_t>(cols_) * rows_) {
|
||||
for (auto& value : lattice_) value = unitDouble(rng.next()) * 2.0 - 1.0;
|
||||
}
|
||||
|
||||
// The height at (x, y) in [-1, 1], and its slope per pixel along x and y.
|
||||
void sample(double x, double y, double& value, double& slopeX, double& slopeY) const {
|
||||
const double gx = x / spacing_;
|
||||
const double gy = y / spacing_;
|
||||
const int ix = std::min(static_cast<int>(gx), cols_ - 2);
|
||||
const int iy = std::min(static_cast<int>(gy), rows_ - 2);
|
||||
const double fx = gx - ix;
|
||||
const double fy = gy - iy;
|
||||
const double sx = fx * fx * (3.0 - 2.0 * fx);
|
||||
const double sy = fy * fy * (3.0 - 2.0 * fy);
|
||||
|
||||
const double a = at(ix, iy);
|
||||
const double b = at(ix + 1, iy);
|
||||
const double c = at(ix, iy + 1);
|
||||
const double d = at(ix + 1, iy + 1);
|
||||
const double cross = a - b - c + d;
|
||||
value = a + (b - a) * sx + (c - a) * sy + cross * sx * sy;
|
||||
slopeX = 6.0 * fx * (1.0 - fx) * ((b - a) + cross * sy) / spacing_;
|
||||
slopeY = 6.0 * fy * (1.0 - fy) * ((c - a) + cross * sx) / spacing_;
|
||||
}
|
||||
|
||||
private:
|
||||
double at(int x, int y) const { return lattice_[static_cast<std::size_t>(y) * cols_ + x]; }
|
||||
|
||||
double spacing_;
|
||||
int cols_;
|
||||
int rows_;
|
||||
std::vector<double> lattice_;
|
||||
};
|
||||
|
||||
// Octaves of noise summed, each twice as fine and half as strong as the one
|
||||
// before, and scaled back so the total stays in [-1, 1].
|
||||
class NoiseField {
|
||||
public:
|
||||
NoiseField(Rng& rng, int width, int height, double scale, int octaves) {
|
||||
double spacing = scale;
|
||||
double weight = 1.0;
|
||||
for (int i = 0; i < octaves; ++i) {
|
||||
layers_.emplace_back(rng, width, height, std::max(1.0, spacing));
|
||||
weights_.push_back(weight);
|
||||
total_ += weight;
|
||||
spacing *= 0.5;
|
||||
weight *= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
void sample(double x, double y, double& value, double& slopeX, double& slopeY) const {
|
||||
value = slopeX = slopeY = 0.0;
|
||||
for (std::size_t i = 0; i < layers_.size(); ++i) {
|
||||
double v = 0.0, sx = 0.0, sy = 0.0;
|
||||
layers_[i].sample(x, y, v, sx, sy);
|
||||
value += v * weights_[i];
|
||||
slopeX += sx * weights_[i];
|
||||
slopeY += sy * weights_[i];
|
||||
}
|
||||
value /= total_;
|
||||
slopeX /= total_;
|
||||
slopeY /= total_;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<NoiseLayer> layers_;
|
||||
std::vector<double> weights_;
|
||||
double total_ = 0.0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image WarpEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
@@ -119,11 +38,8 @@ Image WarpEffect::render(const std::vector<Image>& sources, int width, int heigh
|
||||
|
||||
const Image input = canvasFromSources(sources, width, height, rng);
|
||||
|
||||
// Lattices are drawn in full before any pixel moves: one field for the
|
||||
// swirl, which flows along its contours, two for a push along x and y.
|
||||
const NoiseField first(rng, width, height, scale, octaves);
|
||||
std::vector<NoiseField> extra;
|
||||
if (!swirl) extra.emplace_back(rng, width, height, scale, octaves);
|
||||
// The lattices are drawn in full before any pixel moves.
|
||||
const WarpField field(rng, width, height, scale, octaves, swirl);
|
||||
|
||||
Image canvas(width, height);
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
@@ -132,22 +48,9 @@ Image WarpEffect::render(const std::vector<Image>& sources, int width, int heigh
|
||||
const double y = static_cast<double>(row);
|
||||
for (int col = 0; col < width; ++col) {
|
||||
const double x = col;
|
||||
double value = 0.0, slopeX = 0.0, slopeY = 0.0;
|
||||
first.sample(x, y, value, slopeX, slopeY);
|
||||
double dx = 0.0;
|
||||
double dy = 0.0;
|
||||
if (swirl) {
|
||||
// Along the contour: the slope turned a quarter, which
|
||||
// never piles pixels up or tears them apart. The slope of
|
||||
// a field `scale` wide is about 1/scale, hence the factor.
|
||||
dx = slopeY * scale * strength * 0.5;
|
||||
dy = -slopeX * scale * strength * 0.5;
|
||||
} else {
|
||||
double other = 0.0;
|
||||
extra.front().sample(x, y, other, slopeX, slopeY);
|
||||
dx = value * strength;
|
||||
dy = other * strength;
|
||||
}
|
||||
field.push(x, y, strength, dx, dy);
|
||||
sampleBilinear(input, x + dx, y + dy, out + static_cast<std::size_t>(col) * 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@ int runGui(const Options& initial, int argc, char** argv) {
|
||||
|
||||
auto app = Gtk::Application::create("org.effector.Effector",
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+980
-67
File diff suppressed because it is too large
Load Diff
+123
-1
@@ -10,6 +10,7 @@
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "cli/options.hpp"
|
||||
@@ -18,6 +19,7 @@
|
||||
#include "core/image.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/style.hpp"
|
||||
#include "core/video.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -68,7 +70,7 @@ private:
|
||||
void onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||
const Glib::RefPtr<Gtk::FileDialog>& dialog);
|
||||
void addPaths(const std::vector<std::string>& paths); // decodes in the background
|
||||
void onLoadProgress(); // dispatcher: one file finished decoding
|
||||
void onLoadProgress(); // dispatcher: one file sized or decoded
|
||||
void onLoadFinished(); // dispatcher: the batch is done or was cancelled
|
||||
void showLoadWindow();
|
||||
void closeLoadWindow();
|
||||
@@ -87,8 +89,38 @@ private:
|
||||
void syncCanvasWidgets();
|
||||
void removeSource(Gtk::ListBoxRow* row);
|
||||
|
||||
// Video: a switch that makes the project a clip, and its length, rate and
|
||||
// preset. A preset owns the canvas format as well as the timing; changing
|
||||
// any of them by hand drops it back to "Custom".
|
||||
Gtk::Widget& buildVideoSection();
|
||||
VideoSettings videoSettings() const;
|
||||
void setVideoSettings(const VideoSettings& video); // quietly, for a project
|
||||
void onVideoToggled();
|
||||
void onVideoPresetSelected();
|
||||
void onVideoTimingChanged();
|
||||
void matchVideoPreset(); // "Custom" once the settings are no longer the preset's
|
||||
bool isVideo() const { return videoSwitch_.get_active(); }
|
||||
|
||||
// The preview of a video is the whole clip, rendered first and then looped.
|
||||
void onClipProgress(); // dispatcher: frames arrived from the worker
|
||||
void showIncomingClip(); // the clip in flight takes the screen, gaps held
|
||||
void showClipFrame(int index);
|
||||
void startPlayback();
|
||||
void stopPlayback();
|
||||
void setPlaying(bool playing);
|
||||
void clearClip();
|
||||
|
||||
// Writing a video file: rendered again at full size, on a thread of its
|
||||
// own, into ffmpeg, with a progress window that can cancel it.
|
||||
void startExport(const std::string& path);
|
||||
void onExportProgress();
|
||||
void onExportFinished();
|
||||
void cancelExport();
|
||||
void closeExportWindow();
|
||||
|
||||
Gtk::Widget& buildChainSection();
|
||||
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
||||
void updateVideoEffectItems(); // offers the video effects in a video only
|
||||
void appendStage(const EffectPtr& effect);
|
||||
void removeStage(int index);
|
||||
void moveStage(int from, int to); // `to` is the index it ends up at
|
||||
@@ -129,6 +161,26 @@ private:
|
||||
void writeProject(const std::string& path);
|
||||
void updateTitle();
|
||||
|
||||
// Undo and redo. The history is a list of whole snapshots of the project,
|
||||
// taken a moment after the edits stop, so a spin held down or a word typed
|
||||
// is one step rather than one per tick. The sources ride along as the
|
||||
// immutable list they already are, so stepping back never decodes again.
|
||||
struct HistoryState {
|
||||
std::string json; // what the step is compared by
|
||||
Project project;
|
||||
std::shared_ptr<const std::vector<Image>> sources;
|
||||
std::vector<std::string> refusedSources;
|
||||
int selectedStage = 0;
|
||||
};
|
||||
HistoryState captureState() const;
|
||||
void restoreState(const HistoryState& state);
|
||||
void noteChange(); // coalesced: restarts the short history timer
|
||||
void commitHistory(); // the current project becomes a step, if it changed
|
||||
void resetHistory(); // a new or opened project starts with none
|
||||
void undo();
|
||||
void redo();
|
||||
void updateHistoryActions();
|
||||
|
||||
void onParamChanged();
|
||||
// The chooser behind a Path param. The chosen file goes straight into the
|
||||
// stage's values rather than into the widget, which may well have been
|
||||
@@ -174,22 +226,43 @@ private:
|
||||
Image result_;
|
||||
std::uint64_t seed_ = 0;
|
||||
std::string projectPath_; // empty until the project has been saved once
|
||||
// The last image or video saved from this project, which the next Save
|
||||
// offers again with its number counted on; empty until one is saved.
|
||||
std::string lastExport_;
|
||||
Glib::RefPtr<Gio::Menu> recentMenu_;
|
||||
sigc::connection recentChanged_; // the recent list outlives the window
|
||||
bool suspendRender_ = true;
|
||||
|
||||
std::vector<HistoryState> undo_;
|
||||
std::vector<HistoryState> redo_;
|
||||
HistoryState committed_; // the project as the last step left it
|
||||
sigc::connection historyCommit_;
|
||||
bool suspendHistory_ = false; // set while a step is being put back
|
||||
// The sources of a project being opened are not a step of their own: the
|
||||
// history starts over once they are in.
|
||||
bool historyAfterLoad_ = false;
|
||||
Glib::RefPtr<Gio::SimpleAction> undoAction_;
|
||||
Glib::RefPtr<Gio::SimpleAction> redoAction_;
|
||||
|
||||
// Source loading. Decoding a batch of large photographs takes seconds, so
|
||||
// it runs off the main thread and reports into a small progress window.
|
||||
std::thread loader_;
|
||||
Glib::Dispatcher loadProgress_;
|
||||
Glib::Dispatcher loadFinished_;
|
||||
std::atomic<bool> loadCancel_{false};
|
||||
std::atomic<std::size_t> loadSized_{0}; // headers read (sizing the batch)
|
||||
std::atomic<std::size_t> loadDone_{0};
|
||||
std::size_t loadTotal_ = 0;
|
||||
std::mutex loadMutex_;
|
||||
std::string loadCurrent_;
|
||||
std::vector<Image> loadedImages_;
|
||||
std::vector<LoadFailure> loadFailures_;
|
||||
std::string loadRefused_; // why the batch would not fit in memory, if it would not
|
||||
std::vector<std::string> loadRefusedPaths_;
|
||||
// Sources refused as too large to hold. They are not shown or rendered,
|
||||
// but a project saved now keeps them, so a refusal never costs the
|
||||
// project its images.
|
||||
std::vector<std::string> refusedSources_;
|
||||
std::vector<std::string> loadQueue_; // chosen while another batch was loading
|
||||
bool loading_ = false;
|
||||
bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project
|
||||
@@ -205,6 +278,40 @@ private:
|
||||
Image pendingImage_;
|
||||
std::string pendingError_;
|
||||
double pendingMillis_ = 0.0;
|
||||
// Set to stop a clip render early once its settings are out of date.
|
||||
std::atomic<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, by index, which take its place once its
|
||||
// draft frames are in (showIncomingClip).
|
||||
Glib::Dispatcher clipProgress_;
|
||||
std::vector<std::pair<int, Image>> pendingFrames_; // by index, under resultMutex_
|
||||
std::vector<Glib::RefPtr<Gdk::Texture>> clip_;
|
||||
std::vector<Glib::RefPtr<Gdk::Texture>> incomingClip_;
|
||||
int incomingTotal_ = 0;
|
||||
int incomingReceived_ = 0;
|
||||
int incomingFps_ = kDefaultFps;
|
||||
// The clip on screen is the one being rendered, its draft frames all in.
|
||||
bool draftShown_ = false;
|
||||
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
|
||||
Gtk::Label banner_;
|
||||
@@ -224,6 +331,13 @@ private:
|
||||
Gtk::SpinButton heightSpin_;
|
||||
Gtk::SpinButton dpiSpin_;
|
||||
Gtk::Label* dpiLabel_ = nullptr; // hidden along with the dpi spin
|
||||
Gtk::Switch videoSwitch_;
|
||||
Gtk::Box videoDetails_; // hidden while the project is a still
|
||||
Gtk::DropDown videoPresetDrop_;
|
||||
Gtk::SpinButton durationSpin_;
|
||||
Gtk::SpinButton fpsSpin_;
|
||||
Gtk::DropDown videoQualityDrop_; // videoQualities()[index]; only the export hears of it
|
||||
bool suspendVideoSync_ = false; // set while code, not the user, fills them
|
||||
// Set while a format fills the spins, so their own handler does not queue
|
||||
// a second render on top of the one the format change asks for.
|
||||
bool suspendCanvasSync_ = false;
|
||||
@@ -237,6 +351,10 @@ private:
|
||||
Gtk::Entry seedEntry_;
|
||||
Gtk::ListBox chainList_;
|
||||
Gtk::MenuButton addEffectButton_;
|
||||
// The entries of the video effects in its menu, and the line saying why
|
||||
// they cannot be picked in a still; both owned by the menu.
|
||||
std::vector<Gtk::Button*> videoEffectItems_;
|
||||
Gtk::Label* videoEffectsHint_ = nullptr;
|
||||
Gtk::MenuButton menuButton_;
|
||||
Gtk::Button recreateButton_{"Recreate"};
|
||||
Gtk::Button saveButton_{"Save As…"};
|
||||
@@ -245,6 +363,10 @@ private:
|
||||
Gtk::Label placeholder_;
|
||||
Gtk::Spinner spinner_;
|
||||
Gtk::Label status_;
|
||||
Gtk::Box playbackBar_; // under the preview of a video only
|
||||
Gtk::Button playButton_;
|
||||
Gtk::Scale playbackScale_;
|
||||
Gtk::Label playbackTime_;
|
||||
|
||||
struct ParamWidget {
|
||||
ParamSpec spec;
|
||||
|
||||
+65
-3
@@ -13,12 +13,56 @@
|
||||
#include "core/image.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.hpp"
|
||||
#include "core/video.hpp"
|
||||
#include "core/version.hpp"
|
||||
#include "gui/app.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
int renderVideo(const effector::Options& options, const std::vector<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,
|
||||
options.video.quality);
|
||||
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, true) << " seed=" << seed << " render="
|
||||
<< std::fixed << std::setprecision(1) << seconds << " s\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
int renderHeadless(const effector::Options& options) {
|
||||
// Checked before anything is decoded: a mismatch between what was asked
|
||||
// for and the file named is a typo, not something to find out later.
|
||||
const bool videoFile = effector::isVideoPath(options.output);
|
||||
if (options.video.enabled && !videoFile) {
|
||||
std::cerr << "effector: a video is written as .mp4, .mov, .mkv, .webm or .gif, not "
|
||||
<< options.output << "\n";
|
||||
return 2;
|
||||
}
|
||||
if (!options.video.enabled && videoFile) {
|
||||
std::cerr << "effector: " << options.output
|
||||
<< " is a video; give its length with --duration or --video-preset\n";
|
||||
return 2;
|
||||
}
|
||||
|
||||
auto stages = effector::buildChain(options);
|
||||
// A headless run always renders something, so with no -e the default
|
||||
// effect stands in. The interface has no such need: it opens on an empty
|
||||
@@ -28,6 +72,17 @@ int renderHeadless(const effector::Options& options) {
|
||||
stages.push_back({effect, effect->defaults()});
|
||||
}
|
||||
|
||||
// A video effect has nothing to do in a still; the render skips it, and
|
||||
// says so here rather than leave it looking forgotten.
|
||||
if (!options.video.enabled) {
|
||||
for (const auto& stage : stages) {
|
||||
if (!stage.enabled || !stage.effect->video()) continue;
|
||||
std::cerr << "effector: skipping " << stage.effect->name()
|
||||
<< ": a video effect, and this is a still (give --duration or "
|
||||
"--video-preset for a clip)\n";
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<effector::LoadFailure> failures;
|
||||
|
||||
// Decoding a batch of photographs takes seconds; on a terminal keep a
|
||||
@@ -37,6 +92,11 @@ int renderHeadless(const effector::Options& options) {
|
||||
std::mutex progressMutex;
|
||||
const bool showProgress = isatty(fileno(stderr)) != 0 && options.images.size() > 1;
|
||||
if (showProgress) {
|
||||
progress.onSized = [&](std::size_t done, std::size_t total, const std::string& path) {
|
||||
std::lock_guard<std::mutex> lock(progressMutex);
|
||||
std::cerr << "\r\033[Ksizing " << done << "/" << total << " "
|
||||
<< std::filesystem::path(path).filename().string() << std::flush;
|
||||
};
|
||||
progress.onFile = [&](std::size_t done, std::size_t total, const std::string& path) {
|
||||
std::lock_guard<std::mutex> lock(progressMutex);
|
||||
std::cerr << "\r\033[Kreading " << done << "/" << total << " "
|
||||
@@ -48,12 +108,13 @@ int renderHeadless(const effector::Options& options) {
|
||||
for (const auto& failure : failures) {
|
||||
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
|
||||
}
|
||||
if (sources.empty() && effector::chainNeedsSources(stages)) {
|
||||
if (sources.empty() && effector::chainNeedsSources(stages, options.video.enabled)) {
|
||||
std::cerr << "effector: no usable source images\n";
|
||||
return 2;
|
||||
}
|
||||
|
||||
const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed());
|
||||
if (options.video.enabled) return renderVideo(options, stages, sources, seed);
|
||||
effector::Rng rng(seed);
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
@@ -64,7 +125,7 @@ int renderHeadless(const effector::Options& options) {
|
||||
image.save(options.output);
|
||||
|
||||
std::cout << options.output << " " << options.width << "x" << options.height
|
||||
<< " effect=" << effector::chainSlug(stages) << " seed=" << seed << " render="
|
||||
<< " effect=" << effector::chainSlug(stages, false) << " seed=" << seed << " render="
|
||||
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
|
||||
return 0;
|
||||
}
|
||||
@@ -85,7 +146,8 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
if (options.listEffects) {
|
||||
for (const auto& effect : effector::effects()) {
|
||||
std::cout << effect->name() << "\t" << effect->title() << " - "
|
||||
std::cout << effect->name() << "\t" << effect->title()
|
||||
<< (effect->video() ? " (video)" : "") << " - "
|
||||
<< effect->description() << "\n";
|
||||
}
|
||||
return 0;
|
||||
|
||||
+725
-9
@@ -4,8 +4,10 @@
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <clocale>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
@@ -20,18 +22,22 @@
|
||||
#include "cli/options.hpp"
|
||||
#include "core/canvas.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/memory.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.hpp"
|
||||
#include "core/style.hpp"
|
||||
#include "core/version.hpp"
|
||||
#include "core/video.hpp"
|
||||
#include "effects/blur.hpp"
|
||||
#include "effects/channelsplit.hpp"
|
||||
#include "effects/grain.hpp"
|
||||
#include "effects/monochrome.hpp"
|
||||
#include "effects/overlay.hpp"
|
||||
#include "effects/scanlines.hpp"
|
||||
#include "effects/text.hpp"
|
||||
#include "effects/tiles.hpp"
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -1065,7 +1071,8 @@ int main() {
|
||||
check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a),
|
||||
renderChain({scanlines, stage}, sources, 300, 200, b)),
|
||||
name + " cascades reproducibly");
|
||||
check(!chainNeedsSources({stage, scanlines}), "a chain led by " + name + " needs no sources");
|
||||
check(!chainNeedsSources({stage, scanlines}, false),
|
||||
"a chain led by " + name + " needs no sources");
|
||||
|
||||
// A canvas one pixel wide or high must not trip any of them up.
|
||||
check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 &&
|
||||
@@ -1192,8 +1199,8 @@ int main() {
|
||||
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
|
||||
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
|
||||
|
||||
check(chainTitle({scanlines, blur}) == "Scanlines \u2192 Blur", "the chain has a readable title");
|
||||
check(chainSlug({scanlines, blur}) == "scanlines-blur", "the chain has a file name slug");
|
||||
check(chainTitle({scanlines, blur}, false) == "Scanlines \u2192 Blur", "the chain has a readable title");
|
||||
check(chainSlug({scanlines, blur}, false) == "scanlines-blur", "the chain has a file name slug");
|
||||
|
||||
bool threw = false;
|
||||
try {
|
||||
@@ -1208,10 +1215,10 @@ int main() {
|
||||
// them, blur only softens what it is handed and runs without any.
|
||||
check(effectByName("scanlines")->needsSources(), "scanlines needs source images");
|
||||
check(!effectByName("blur")->needsSources(), "blur needs no source images");
|
||||
check(chainNeedsSources({scanlines, blur}), "a chain led by scanlines needs sources");
|
||||
check(!chainNeedsSources({blur, scanlines}),
|
||||
check(chainNeedsSources({scanlines, blur}, false), "a chain led by scanlines needs sources");
|
||||
check(!chainNeedsSources({blur, scanlines}, false),
|
||||
"a chain led by blur needs none, later stages see the stage before them");
|
||||
check(!chainNeedsSources({}), "an empty chain needs no sources");
|
||||
check(!chainNeedsSources({}, false), "an empty chain needs no sources");
|
||||
|
||||
Rng bare(5);
|
||||
const auto sourceless = renderChain({blur}, {}, 200, 150, bare);
|
||||
@@ -1238,12 +1245,12 @@ int main() {
|
||||
Rng skipping(5);
|
||||
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping);
|
||||
check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it");
|
||||
check(chainTitle({scanlines, blurOff}) == "Scanlines", "the title names only what ran");
|
||||
check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug");
|
||||
check(chainTitle({scanlines, blurOff}, false) == "Scanlines", "the title names only what ran");
|
||||
check(chainSlug({scanlines, blurOff}, false) == "scanlines", "and so does the slug");
|
||||
|
||||
// Whoever leads the chain is handed the sources, and that is the first
|
||||
// stage still switched on.
|
||||
check(!chainNeedsSources({scanlinesOff, blur}),
|
||||
check(!chainNeedsSources({scanlinesOff, blur}, false),
|
||||
"a disabled leading stage leaves the question to the stage behind it");
|
||||
Rng leading(5);
|
||||
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
|
||||
@@ -1276,6 +1283,21 @@ int main() {
|
||||
{
|
||||
const auto dir = std::filesystem::temp_directory_path() / "effector_test_project";
|
||||
std::filesystem::create_directories(dir);
|
||||
{
|
||||
const auto base = (dir / "city.png").string();
|
||||
std::filesystem::remove(dir / "city-1.png");
|
||||
std::filesystem::remove(dir / "city-2.png");
|
||||
check(nextExportPath(base) == (dir / "city-1.png").string(),
|
||||
"the next export after a plain name counts from 1");
|
||||
check(nextExportPath((dir / "city-1.png").string()) == (dir / "city-2.png").string(),
|
||||
"the next export counts on from the last number");
|
||||
std::ofstream((dir / "city-1.png").string()) << "x";
|
||||
check(nextExportPath(base) == (dir / "city-2.png").string(),
|
||||
"the next export skips a name already on disk");
|
||||
std::filesystem::remove(dir / "city-1.png");
|
||||
check(nextExportPath((dir / "a-b.mp4").string()) == (dir / "a-b-1.mp4").string(),
|
||||
"a dash before letters is not a count");
|
||||
}
|
||||
const auto source = dir / "source.png";
|
||||
gradient(64, 48, 7).save(source.string());
|
||||
|
||||
@@ -1688,9 +1710,16 @@ int main() {
|
||||
paths.push_back((dir / "missing.png").string());
|
||||
|
||||
std::atomic<std::size_t> calls{0};
|
||||
std::atomic<std::size_t> sizedCalls{0};
|
||||
std::atomic<bool> sizedAfterDecode{false};
|
||||
std::size_t lastTotal = 0;
|
||||
std::mutex mutex;
|
||||
LoadProgress progress;
|
||||
progress.onSized = [&](std::size_t done, std::size_t total, const std::string&) {
|
||||
++sizedCalls;
|
||||
if (calls.load() > 0) sizedAfterDecode = true;
|
||||
check(done >= 1 && done <= total, "sizing step is within the batch");
|
||||
};
|
||||
progress.onFile = [&](std::size_t done, std::size_t total, const std::string&) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
++calls;
|
||||
@@ -1703,6 +1732,8 @@ int main() {
|
||||
check(images.size() == 6, "every readable image is returned");
|
||||
check(failuresOut.size() == 1, "the unreadable one is reported");
|
||||
check(calls.load() == paths.size(), "progress fires once per file");
|
||||
check(sizedCalls.load() == paths.size() && !sizedAfterDecode.load(),
|
||||
"every header is sized, all before the first decode");
|
||||
check(lastTotal == paths.size(), "progress reports the batch size");
|
||||
check(images.front().path() == paths.front(), "input order is preserved");
|
||||
|
||||
@@ -1716,6 +1747,691 @@ int main() {
|
||||
std::filesystem::remove_all(dir);
|
||||
}
|
||||
|
||||
std::cout << "memory\n";
|
||||
{
|
||||
check(availableMemory() > 0, "the free memory can be read");
|
||||
check(formatBytes(std::size_t{5} << 29) == "2.5 GB" && formatBytes(std::size_t{380} << 20) == "380 MB",
|
||||
"sizes are written in GB and MB");
|
||||
|
||||
const auto dir = std::filesystem::temp_directory_path() / "effector_test_memory";
|
||||
std::filesystem::create_directories(dir);
|
||||
std::vector<std::string> paths;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const auto path = dir / ("src" + std::to_string(i) + ".png");
|
||||
gradient(400, 300, i + 1).save(path.string());
|
||||
paths.push_back(path.string());
|
||||
}
|
||||
check(imageFilePixels(paths[0]) == 400u * 300 && imageFilePixels((dir / "missing.png").string()) == 0,
|
||||
"an image is sized from its header");
|
||||
|
||||
// Three 400 x 300 images hold 1 MB decoded; with 1 MB free they do not fit.
|
||||
limitAvailableMemory(std::size_t{1} << 20);
|
||||
std::atomic<int> decoded{0};
|
||||
LoadProgress counting;
|
||||
counting.onFile = [&](std::size_t, std::size_t, const std::string&) { ++decoded; };
|
||||
std::vector<LoadFailure> failures;
|
||||
bool refused = false;
|
||||
std::string message;
|
||||
try {
|
||||
loadImages(paths, failures, &counting);
|
||||
} catch (const OutOfMemoryError& error) {
|
||||
refused = true;
|
||||
message = error.what();
|
||||
}
|
||||
check(refused, "a batch too large for the free memory is refused");
|
||||
check(decoded.load() == 0, "before a single file is decoded");
|
||||
check(message.find("Loading these 3 images needs about") == 0 && message.find("with 1 MB free") != std::string::npos,
|
||||
"and the message says how much is needed and how much is free");
|
||||
|
||||
const std::vector<ChainStage> scanlines = {{effectByName("scanlines"), {}}};
|
||||
const std::vector<Image> small = {gradient(64, 64, 7)};
|
||||
bool renderRefused = false;
|
||||
try {
|
||||
Rng rng(1);
|
||||
renderChain(scanlines, small, 4000, 4000, rng);
|
||||
} catch (const OutOfMemoryError&) {
|
||||
renderRefused = true;
|
||||
}
|
||||
check(renderRefused, "a render too large for the free memory is refused");
|
||||
bool videoRefused = false;
|
||||
try {
|
||||
VideoSettings clip;
|
||||
clip.enabled = true;
|
||||
renderFrames(scanlines, small, 4000, 4000, 1, {}, clip, [](int, Image&&) { return true; });
|
||||
} catch (const OutOfMemoryError&) {
|
||||
videoRefused = true;
|
||||
}
|
||||
check(videoRefused, "and so is a clip");
|
||||
Rng smallRng(1);
|
||||
check(renderChain(scanlines, small, 100, 100, smallRng).width() == 100,
|
||||
"a render that fits still runs");
|
||||
|
||||
limitAvailableMemory(0);
|
||||
failures.clear();
|
||||
check(loadImages(paths, failures).size() == 3, "with the memory there the batch loads");
|
||||
|
||||
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 auto tracking = effector::effectByName("tracking");
|
||||
const std::vector<effector::Image> sources = {gradient(96, 64, 40)};
|
||||
|
||||
check(tracking->video() && !split->video(),
|
||||
"VHS tracking is a video effect, channel split an image one");
|
||||
bool imagesFirst = true;
|
||||
bool seenVideo = false;
|
||||
for (const auto& effect : effects()) {
|
||||
imagesFirst = imagesFirst && (effect->video() || !seenVideo);
|
||||
seenVideo = seenVideo || effect->video();
|
||||
}
|
||||
check(imagesFirst, "the registry lists the image effects before the video effects");
|
||||
|
||||
auto wild = tracking->defaults();
|
||||
wild["tracking_wobble"] = 40L;
|
||||
wild["tracking_jitter"] = 30L;
|
||||
wild["tracking_bar"] = 60L;
|
||||
auto frozen = tracking->defaults();
|
||||
frozen["tracking_wobble"] = 0L;
|
||||
frozen["tracking_jitter"] = 0L;
|
||||
frozen["tracking_bar"] = 0L;
|
||||
|
||||
// A still skips a video effect whole, as it skips a stage switched
|
||||
// off: the picture is the one the chain made without it.
|
||||
ChainStage scanlinesStage{effectByName("scanlines"), {}};
|
||||
ChainStage trackingStage{tracking, wild};
|
||||
effector::Rng withRng(5);
|
||||
effector::Rng withoutRng(5);
|
||||
check(sameBytes(renderChain({scanlinesStage, trackingStage}, sources, 96, 64, withRng),
|
||||
renderChain({scanlinesStage}, sources, 96, 64, withoutRng)),
|
||||
"a still skips a video effect as though it were not there");
|
||||
check(chainTitle({scanlinesStage, trackingStage}, false) == "Scanlines" &&
|
||||
chainTitle({scanlinesStage, trackingStage}, true) == "Scanlines \u2192 VHS tracking",
|
||||
"and names it only in a video");
|
||||
check(chainNeedsSources({trackingStage, scanlinesStage}, false) &&
|
||||
!chainNeedsSources({trackingStage, scanlinesStage}, true),
|
||||
"led by a video effect, a still starts from the stage after it");
|
||||
bool onlyVideo = false;
|
||||
try {
|
||||
effector::Rng rng(1);
|
||||
renderChain({trackingStage}, sources, 96, 64, rng);
|
||||
} catch (const EffectError&) {
|
||||
onlyVideo = true;
|
||||
}
|
||||
check(onlyVideo, "a still of video effects alone is refused");
|
||||
|
||||
// A video effect 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);
|
||||
tracking->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
|
||||
tracking->renderFrame(sources, 96, 64, frameB, wild, FrameTime{17, 30, 30});
|
||||
check(frameA.next() == frameB.next(), "every frame leaves the shared rng in one state");
|
||||
|
||||
// Time is rounded to fit the clip, so the frame after the last is the
|
||||
// first again, and a clip loops without a jump.
|
||||
const effector::Moment start(FrameTime{0, 30, 30}, 1);
|
||||
check(start.length() == 1.0 && start.loopRate(0.4) == 1.0 && start.loopRate(2.2) == 2.0,
|
||||
"a rate is rounded to a whole number of cycles in the clip");
|
||||
check(effector::Moment(FrameTime{15, 30, 30}, 1).cycle(1.0) == 0.5,
|
||||
"half-way through the clip is half-way through its one cycle");
|
||||
auto smooth = wild;
|
||||
smooth["tracking_jitter"] = 0L;
|
||||
const std::vector<effector::Image> input = {gradient(96, 64, 70)};
|
||||
effector::Rng loopA(4);
|
||||
effector::Rng loopB(4);
|
||||
check(sameBytes(tracking->renderFrame(input, 96, 64, loopA, smooth, FrameTime{0, 30, 30}),
|
||||
tracking->renderFrame(input, 96, 64, loopB, smooth, FrameTime{30, 30, 30})),
|
||||
"without the shake, the clip loops without a jump");
|
||||
effector::Rng stillFrame(4);
|
||||
check(sameBytes(tracking->renderFrame(input, 96, 64, stillFrame, frozen, FrameTime{7, 30, 30}),
|
||||
input[0]),
|
||||
"with every motion at 0 it leaves its input alone");
|
||||
|
||||
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("tracking", 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"), 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 of image effects 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");
|
||||
|
||||
// A preview renders a draft of every third frame first; the frames
|
||||
// are the same whichever order they come in.
|
||||
const auto order = effector::draftFirstOrder(8, 3);
|
||||
check(order == std::vector<int>({0, 3, 6, 1, 2, 4, 5, 7}),
|
||||
"a draft order takes every third frame first, then the rest");
|
||||
std::vector<effector::Image> drafted(8);
|
||||
const int draftHanded = effector::renderFrames(
|
||||
chain, sources, 80, 48, 21, {}, video,
|
||||
[&](int index, effector::Image&& frame) {
|
||||
drafted[static_cast<std::size_t>(index)] = std::move(frame);
|
||||
return true;
|
||||
},
|
||||
order);
|
||||
bool sameClip = draftHanded == 8;
|
||||
for (std::size_t i = 0; sameClip && i < first.size(); ++i) {
|
||||
sameClip = sameBytes(first[i], drafted[i]);
|
||||
}
|
||||
check(sameClip, "a clip rendered draft first holds exactly the same frames");
|
||||
|
||||
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");
|
||||
|
||||
// Tile swap builds its frames out of the sources rather than
|
||||
// transforming a crop of one.
|
||||
{
|
||||
const auto swap = effectByName("tileswap");
|
||||
check(swap->video() && swap->needsSources(),
|
||||
"tile swap is a video effect built from the sources");
|
||||
ChainStage swapStage = stage("tileswap", {{"tileswap_width", 16L},
|
||||
{"tileswap_height", 12L},
|
||||
{"tileswap_rate", 2.0}});
|
||||
ChainStage scanStage = stage("scanlines");
|
||||
check(chainNeedsSources({swapStage}, true), "and a clip led by it needs sources");
|
||||
effector::Rng withSwap(3);
|
||||
effector::Rng withoutSwap(3);
|
||||
check(sameBytes(renderChain({swapStage, scanStage}, sources, 80, 48, withSwap),
|
||||
renderChain({scanStage}, sources, 80, 48, withoutSwap)),
|
||||
"a still skips tile swap as though it were not there");
|
||||
bool refused = false;
|
||||
try {
|
||||
effector::Rng rng(1);
|
||||
renderChain({swapStage}, {}, 80, 48, rng, {}, FrameTime{0, 8, 8});
|
||||
} catch (const EffectError&) {
|
||||
refused = true;
|
||||
}
|
||||
check(refused, "tile swap with no sources is refused");
|
||||
|
||||
const std::vector<effector::Image> photos = {solid(60, 40, 255, 0, 0),
|
||||
solid(50, 30, 0, 0, 255)};
|
||||
auto frameOf = [&](const ChainStage& at, int index, int count = 8) {
|
||||
effector::Rng rng(11);
|
||||
return renderChain({at}, photos, 80, 48, rng, {}, FrameTime{index, count, 8});
|
||||
};
|
||||
effector::Rng swapA(9);
|
||||
effector::Rng swapB(9);
|
||||
swap->renderFrame(photos, 80, 48, swapA, swapStage.values, FrameTime{3, 8, 8});
|
||||
swap->renderFrame(photos, 80, 48, swapB, swapStage.values, FrameTime{5, 8, 8});
|
||||
check(swapA.next() == swapB.next(), "tile swap leaves the shared rng in one state");
|
||||
|
||||
auto onlyPhotos = [](const effector::Image& image) {
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
const std::uint8_t* row = image.row(y);
|
||||
for (int x = 0; x < image.width(); ++x) {
|
||||
const std::uint8_t* p = row + static_cast<std::size_t>(x) * 3;
|
||||
const bool red = p[0] == 255 && p[1] == 0 && p[2] == 0;
|
||||
const bool blue = p[0] == 0 && p[1] == 0 && p[2] == 255;
|
||||
if (!red && !blue) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
check(onlyPhotos(frameOf(swapStage, 0)) && onlyPhotos(frameOf(swapStage, 5)),
|
||||
"its tiles are cut from the photographs");
|
||||
bool swapped = false;
|
||||
for (int index = 1; index < 8; ++index) {
|
||||
swapped = swapped || !sameBytes(frameOf(swapStage, 0), frameOf(swapStage, index));
|
||||
}
|
||||
check(swapped, "its tiles change over the clip");
|
||||
check(sameBytes(frameOf(swapStage, 0), frameOf(swapStage, 8)),
|
||||
"and the frame after the last is the first again");
|
||||
ChainStage frozenSwap = swapStage;
|
||||
frozenSwap.values["tileswap_rate"] = 0.0;
|
||||
check(sameBytes(frameOf(frozenSwap, 0), frameOf(frozenSwap, 5)),
|
||||
"at a rate of 0 it holds still");
|
||||
// How many pixels ever show something other than frame 0.
|
||||
auto moving = [&](const ChainStage& at) {
|
||||
const auto first = frameOf(at, 0);
|
||||
std::vector<bool> moved(static_cast<std::size_t>(80 * 48), false);
|
||||
for (int index = 1; index < 8; ++index) {
|
||||
const auto frame = frameOf(at, index);
|
||||
for (int y = 0; y < 48; ++y) {
|
||||
for (int x = 0; x < 80; ++x) {
|
||||
if (std::memcmp(frame.row(y) + x * 3, first.row(y) + x * 3, 3) != 0) {
|
||||
moved[static_cast<std::size_t>(y * 80 + x)] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return std::count(moved.begin(), moved.end(), true);
|
||||
};
|
||||
ChainStage slowSwap = swapStage;
|
||||
slowSwap.values["tileswap_rate"] = 0.3;
|
||||
const auto slowMoving = moving(slowSwap);
|
||||
check(slowMoving > 0 && slowMoving < moving(swapStage),
|
||||
"a rate below two swaps a clip moves only some of the tiles");
|
||||
|
||||
const auto& rate = swap->params().back();
|
||||
check(rate.onSlider && rate.curve > 1.0, "its swap rate is a curved slider");
|
||||
check(rate.sliderValue(0.0) == 0.0 && rate.sliderValue(1.0) == 5.0 &&
|
||||
rate.sliderValue(0.5) < 2.5,
|
||||
"which spans the whole range and gives the low end more travel");
|
||||
check(std::abs(rate.sliderValue(rate.sliderPosition(0.37)) - 0.37) < 1e-9 &&
|
||||
rate.sliderValue(0.123456) * 100.0 ==
|
||||
std::round(rate.sliderValue(0.123456) * 100.0),
|
||||
"a value finds its place on the slider, and a place rounds to the step");
|
||||
ChainStage scattered = swapStage;
|
||||
scattered.values["tileswap_layout"] = std::string("scatter");
|
||||
ChainStage bricks = swapStage;
|
||||
bricks.values["tileswap_layout"] = std::string("brick");
|
||||
bricks.values["tileswap_keep_order"] = true;
|
||||
check(onlyPhotos(frameOf(scattered, 2)) && onlyPhotos(frameOf(bricks, 6)),
|
||||
"scattered and brick layouts are cut from the photographs too");
|
||||
|
||||
const std::vector<ChainStage> swapChain = {swapStage, stage("grain")};
|
||||
std::vector<effector::Image> clip;
|
||||
effector::renderFrames(swapChain, sources, 80, 48, 21, {}, video,
|
||||
[&](int, effector::Image&& frame) {
|
||||
clip.push_back(std::move(frame));
|
||||
return true;
|
||||
});
|
||||
bool alone = clip.size() == 8;
|
||||
for (const int index : {0, 4, 7}) {
|
||||
effector::Rng rng(21);
|
||||
alone = alone && sameBytes(effector::renderChain(swapChain, sources, 80, 48, rng,
|
||||
{}, FrameTime{index, 8, 8}),
|
||||
clip[static_cast<std::size_t>(index)]);
|
||||
}
|
||||
check(alone, "a tile swap clip's frame is exactly that frame rendered on its own");
|
||||
}
|
||||
|
||||
// Strip roll builds its frames out of the sources too, and rolls the
|
||||
// picture in each strip through its image.
|
||||
{
|
||||
const auto roll = effectByName("striproll");
|
||||
check(roll->video() && roll->needsSources(),
|
||||
"strip roll is a video effect built from the sources");
|
||||
ChainStage rollStage = stage("striproll", {{"roll_height", 6L}, {"roll_speed", 40L}});
|
||||
ChainStage scanStage = stage("scanlines");
|
||||
effector::Rng withRoll(3);
|
||||
effector::Rng withoutRoll(3);
|
||||
check(sameBytes(renderChain({rollStage, scanStage}, sources, 80, 48, withRoll),
|
||||
renderChain({scanStage}, sources, 80, 48, withoutRoll)),
|
||||
"a still skips strip roll as though it were not there");
|
||||
bool refused = false;
|
||||
try {
|
||||
effector::Rng rng(1);
|
||||
renderChain({rollStage}, {}, 80, 48, rng, {}, FrameTime{0, 8, 8});
|
||||
} catch (const EffectError&) {
|
||||
refused = true;
|
||||
}
|
||||
check(refused, "strip roll with no sources is refused");
|
||||
|
||||
const std::vector<effector::Image> photos = {gradient(60, 40, 20), gradient(50, 30, 150)};
|
||||
auto frameOf = [&](const ChainStage& at, int index) {
|
||||
effector::Rng rng(11);
|
||||
return renderChain({at}, photos, 80, 48, rng, {}, FrameTime{index, 8, 8});
|
||||
};
|
||||
effector::Rng rollA(9);
|
||||
effector::Rng rollB(9);
|
||||
roll->renderFrame(photos, 80, 48, rollA, rollStage.values, FrameTime{3, 8, 8});
|
||||
roll->renderFrame(photos, 80, 48, rollB, rollStage.values, FrameTime{5, 8, 8});
|
||||
check(rollA.next() == rollB.next(), "strip roll leaves the shared rng in one state");
|
||||
|
||||
// Every row of a strip is a row of one photograph, turned round.
|
||||
auto rowsOfPhotos = [&](const effector::Image& image) {
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
bool found = false;
|
||||
for (const auto& photo : photos) {
|
||||
for (int py = 0; !found && py < photo.height(); ++py) {
|
||||
for (int shift = 0; !found && shift < photo.width(); ++shift) {
|
||||
bool same = true;
|
||||
for (int x = 0; same && x < image.width(); ++x) {
|
||||
const int px = (x + shift) % photo.width();
|
||||
same = std::equal(image.row(y) + x * 3, image.row(y) + x * 3 + 3,
|
||||
photo.row(py) + px * 3);
|
||||
}
|
||||
found = same;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
check(rowsOfPhotos(frameOf(rollStage, 0)) && rowsOfPhotos(frameOf(rollStage, 5)),
|
||||
"its strips are rows of the photographs");
|
||||
bool rolled = false;
|
||||
for (int index = 1; index < 8; ++index) {
|
||||
rolled = rolled || !sameBytes(frameOf(rollStage, 0), frameOf(rollStage, index));
|
||||
}
|
||||
check(rolled, "its strips roll over the clip");
|
||||
check(sameBytes(frameOf(rollStage, 0), frameOf(rollStage, 8)),
|
||||
"and the frame after the last is the first again");
|
||||
ChainStage sideways = rollStage;
|
||||
sideways.values["roll_axis"] = std::string("horizontal");
|
||||
sideways.values["roll_direction"] = std::string("random");
|
||||
sideways.values["roll_keep_order"] = true;
|
||||
check(rowsOfPhotos(frameOf(sideways, 3)) &&
|
||||
!sameBytes(frameOf(sideways, 0), frameOf(sideways, 3)) &&
|
||||
sameBytes(frameOf(sideways, 0), frameOf(sideways, 8)),
|
||||
"sideways, its strips slide along their rows and loop");
|
||||
ChainStage frozenRoll = rollStage;
|
||||
frozenRoll.values["roll_speed"] = 0L;
|
||||
check(sameBytes(frameOf(frozenRoll, 0), frameOf(frozenRoll, 5)),
|
||||
"at a speed of 0 it holds still");
|
||||
|
||||
const std::vector<ChainStage> rollChain = {rollStage, stage("grain")};
|
||||
std::vector<effector::Image> clip;
|
||||
effector::renderFrames(rollChain, sources, 80, 48, 21, {}, video,
|
||||
[&](int, effector::Image&& frame) {
|
||||
clip.push_back(std::move(frame));
|
||||
return true;
|
||||
});
|
||||
bool alone = clip.size() == 8;
|
||||
for (const int index : {0, 4, 7}) {
|
||||
effector::Rng rng(21);
|
||||
alone = alone && sameBytes(effector::renderChain(rollChain, sources, 80, 48, rng,
|
||||
{}, FrameTime{index, 8, 8}),
|
||||
clip[static_cast<std::size_t>(index)]);
|
||||
}
|
||||
check(alone, "a strip roll clip's frame is exactly that frame rendered on its own");
|
||||
}
|
||||
|
||||
// The video transforms made from image effects. Each gets a setting
|
||||
// that moves plenty, one without its per-frame noise (which, drawn
|
||||
// anew on every frame, is the one thing that does not loop), and one
|
||||
// that switches it off.
|
||||
struct Moving {
|
||||
std::string name;
|
||||
effector::ParamValues lively;
|
||||
effector::ParamValues smooth;
|
||||
effector::ParamValues off;
|
||||
};
|
||||
const std::vector<Moving> moving = {
|
||||
{"tunnel", {{"tunnel_drift", 6L}, {"tunnel_fade", 1.0}}, {}, {{"tunnel_fade", 0.0}}},
|
||||
{"flow", {{"flow_mode", std::string("swirl")}}, {}, {{"flow_strength", 0L}}},
|
||||
{"sweep", {{"sweep_reverse", true}, {"sweep_max_run", 20L}}, {},
|
||||
{{"sweep_line_chance", 0.0}}},
|
||||
{"filmgate", {{"gate_weave", 6L}, {"gate_dust", 20L}, {"gate_scratches", 3L}},
|
||||
{{"gate_weave", 6L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L},
|
||||
{"gate_scratches", 0L}},
|
||||
{{"gate_weave", 0L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L},
|
||||
{"gate_scratches", 0L}}},
|
||||
{"datamosh", {{"mosh_rate", 0.4}, {"mosh_block", 8L}, {"mosh_bleed", true}}, {},
|
||||
{{"mosh_rate", 0.0}}},
|
||||
{"focuspull", {{"focus_radius", 5L}}, {}, {{"focus_radius", 0L}}},
|
||||
{"focuspull", {{"focus_mode", std::string("band")}, {"focus_speed", 1.0}}, {},
|
||||
{{"focus_mode", std::string("band")}, {"focus_radius", 0L}}},
|
||||
};
|
||||
for (const auto& [name, lively, smooth, off] : moving) {
|
||||
const auto effect = effectByName(name);
|
||||
auto with = [&](const effector::ParamValues& changes) {
|
||||
ChainStage built = stage(name);
|
||||
for (const auto& [key, value] : changes) built.values[key] = value;
|
||||
return built;
|
||||
};
|
||||
auto frameOf = [&](const ChainStage& at, int index) {
|
||||
effector::Rng rng(6);
|
||||
return renderChain({at}, input, 96, 64, rng, {}, FrameTime{index, 8, 8});
|
||||
};
|
||||
const ChainStage wildStage = with(lively);
|
||||
check(effect->video() && !effect->needsSources(),
|
||||
name + " is a video effect that needs no sources");
|
||||
|
||||
ChainStage scanStage = stage("scanlines");
|
||||
effector::Rng withIt(3);
|
||||
effector::Rng withoutIt(3);
|
||||
check(sameBytes(renderChain({scanStage, wildStage}, sources, 80, 48, withIt),
|
||||
renderChain({scanStage}, sources, 80, 48, withoutIt)),
|
||||
"a still skips " + name + " as though it were not there");
|
||||
|
||||
effector::Rng stateA(9);
|
||||
effector::Rng stateB(9);
|
||||
effect->renderFrame(sources, 80, 48, stateA, wildStage.values, FrameTime{3, 8, 8});
|
||||
effect->renderFrame(sources, 80, 48, stateB, wildStage.values, FrameTime{5, 8, 8});
|
||||
check(stateA.next() == stateB.next(), name + " leaves the shared rng in one state");
|
||||
|
||||
bool changes = false;
|
||||
for (int index = 1; index < 8; ++index) {
|
||||
changes = changes || !sameBytes(frameOf(wildStage, 0), frameOf(wildStage, index));
|
||||
}
|
||||
check(changes, name + " moves over the clip");
|
||||
const ChainStage smoothStage = with(smooth);
|
||||
check(sameBytes(frameOf(smoothStage, 0), frameOf(smoothStage, 8)),
|
||||
name + " loops: the frame after the last is the first again");
|
||||
check(sameBytes(frameOf(with(off), 5), input[0]),
|
||||
name + " switched off leaves its input alone");
|
||||
effector::Rng sliverA(2);
|
||||
effector::Rng sliverB(2);
|
||||
check(renderChain({wildStage}, input, 1, 57, sliverA, {}, FrameTime{3, 8, 8}).height() == 57 &&
|
||||
renderChain({wildStage}, input, 57, 1, sliverB, {}, FrameTime{3, 8, 8}).width() == 57,
|
||||
name + " copes with a sliver of a canvas");
|
||||
|
||||
const std::vector<ChainStage> movingChain = {stage("tiles"), wildStage, stage("grain")};
|
||||
std::vector<effector::Image> clip;
|
||||
effector::renderFrames(movingChain, sources, 80, 48, 21, {}, video,
|
||||
[&](int, effector::Image&& frame) {
|
||||
clip.push_back(std::move(frame));
|
||||
return true;
|
||||
});
|
||||
bool exactFrames = clip.size() == 8;
|
||||
for (const int index : {0, 5, 7}) {
|
||||
effector::Rng rng(21);
|
||||
exactFrames = exactFrames &&
|
||||
sameBytes(effector::renderChain(movingChain, sources, 80, 48, rng, {},
|
||||
FrameTime{index, 8, 8}),
|
||||
clip[static_cast<std::size_t>(index)]);
|
||||
}
|
||||
check(exactFrames, "a " + name + " clip's frame is exactly that frame rendered on its own");
|
||||
}
|
||||
|
||||
// The project file: a video says so, a still says nothing about time.
|
||||
effector::Project project;
|
||||
project.stages = {stage("tracking", 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, "tracking_jitter", 0) == 30 &&
|
||||
effector::paramInt(motion, "tracking_bar", 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");
|
||||
check(effector::projectToJson(project).find("\"quality\"") == std::string::npos,
|
||||
"the standard quality is not written");
|
||||
project.video.quality = "small";
|
||||
check(effector::projectFromJson(effector::projectToJson(project)).video.quality == "small",
|
||||
"another quality is kept");
|
||||
check(effector::projectFromJson(base + "2, \"quality\": \"lossless\"}}").video.quality.empty(),
|
||||
"an unknown quality reads as the standard one");
|
||||
check(effector::videoQualityOrDefault("").key == effector::kDefaultVideoQuality &&
|
||||
effector::videoQualityByKey(effector::kDefaultVideoQuality) != nullptr,
|
||||
"an empty quality is the standard one, which the table holds");
|
||||
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");
|
||||
const auto quality = parsedOptions({"--duration", "2", "--video-quality", "high"});
|
||||
check(quality && quality->video.quality == "high", "--video-quality picks the encoding");
|
||||
check(!parsedOptions({"--video-quality", "best"}), "and refuses one it does not know");
|
||||
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");
|
||||
// Every quality, through every codec.
|
||||
bool everyQuality = true;
|
||||
for (const auto& quality : effector::videoQualities()) {
|
||||
for (const char* extension : {".mp4", ".webm", ".gif"}) {
|
||||
const auto encoded = (dir / (quality.key + extension)).string();
|
||||
effector::VideoWriter writer(encoded, 16, 16, 12, quality.key);
|
||||
for (int i = 0; i < 2; ++i) writer.write(gradient(16, 16, 60 * i));
|
||||
writer.finish();
|
||||
everyQuality = everyQuality && std::filesystem::file_size(encoded) > 0;
|
||||
}
|
||||
}
|
||||
check(everyQuality, "every quality writes .mp4, .webm and .gif");
|
||||
const auto dropped = (dir / "dropped.webm").string();
|
||||
{
|
||||
effector::VideoWriter writer(dropped, 16, 16, 12);
|
||||
writer.write(gradient(16, 16, 10));
|
||||
writer.cancel();
|
||||
}
|
||||
check(!std::filesystem::exists(dropped), "a cancelled video leaves no file behind");
|
||||
bool wrongSize = false;
|
||||
try {
|
||||
effector::VideoWriter writer((dir / "sized.mp4").string(), 16, 16, 12);
|
||||
writer.write(gradient(8, 8, 10));
|
||||
} catch (const effector::EffectError&) {
|
||||
wrongSize = true;
|
||||
}
|
||||
check(wrongSize && !std::filesystem::exists(dir / "sized.mp4"),
|
||||
"a frame of the wrong size is refused and the file dropped");
|
||||
std::filesystem::remove_all(dir);
|
||||
} else {
|
||||
std::cout << " skip writing a video (ffmpeg is not installed)\n";
|
||||
}
|
||||
}
|
||||
|
||||
std::cout << "version\n";
|
||||
{
|
||||
// A release is the version in CMakeLists.txt and its entry at the top
|
||||
|
||||
+412
-8
@@ -7,6 +7,7 @@
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -14,6 +15,8 @@
|
||||
#include <vector>
|
||||
|
||||
#include "cli/options.hpp"
|
||||
#include "core/image.hpp"
|
||||
#include "core/memory.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/version.hpp"
|
||||
@@ -107,6 +110,14 @@ Gtk::Button* menuEntry(Gtk::Popover& menu, const std::string& title) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Whether any label under `root` says `text`, shown or not.
|
||||
bool showsText(Gtk::Widget& root, const std::string& text) {
|
||||
for (auto* label : all<Gtk::Label>(root)) {
|
||||
if (label->get_text() == text) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Presses a button the way the keyboard does. GTK shows the press for a
|
||||
// quarter of a second before the button reports the click, so wait it out.
|
||||
void press(Gtk::Button& button) {
|
||||
@@ -151,6 +162,11 @@ void dropStage(GtkDropTarget* drop, int index, double y) {
|
||||
|
||||
void runTests(Gtk::Window& window) {
|
||||
const auto& effects = effector::effects();
|
||||
// What can be added to the still project the window opens on.
|
||||
std::vector<effector::EffectPtr> imageEffects;
|
||||
for (const auto& effect : effects) {
|
||||
if (!effect->video()) imageEffects.push_back(effect);
|
||||
}
|
||||
|
||||
std::cout << "sidebar\n";
|
||||
auto* stack = all<Gtk::Stack>(window).empty() ? nullptr : all<Gtk::Stack>(window).front();
|
||||
@@ -193,6 +209,19 @@ void runTests(Gtk::Window& window) {
|
||||
for (const auto& effect : effects) listed = listed && menuEntry(*menu, effect->title()) != nullptr;
|
||||
check(listed, "the menu has an entry for every registered effect");
|
||||
check(all<Gtk::Button>(*menu).size() == effects.size(), "and nothing else");
|
||||
bool offered = true;
|
||||
bool videoLast = true;
|
||||
bool seenVideo = false;
|
||||
for (const auto& effect : effects) {
|
||||
auto* entry = menuEntry(*menu, effect->title());
|
||||
offered = offered && entry->get_sensitive() != effect->video();
|
||||
videoLast = videoLast && (effect->video() || !seenVideo);
|
||||
seenVideo = seenVideo || effect->video();
|
||||
}
|
||||
check(seenVideo, "there is a video effect");
|
||||
check(videoLast && showsText(*menu, "Image effects") && showsText(*menu, "Video effects"),
|
||||
"the menu lists image effects and video effects under headings of their own");
|
||||
check(offered, "in a still project only the image effects can be picked");
|
||||
|
||||
add->popup();
|
||||
pump();
|
||||
@@ -202,12 +231,21 @@ void runTests(Gtk::Window& window) {
|
||||
// there were enough effects: the entries have to scroll instead, in a
|
||||
// menu short enough to open below a button near the top of a 768-pixel
|
||||
// screen.
|
||||
// Image and video effects scroll apart, so the video effects never
|
||||
// scroll out of sight below a long list of image effects.
|
||||
const auto scrollers = all<Gtk::ScrolledWindow>(*menu);
|
||||
const int cap = scrollers.empty() ? -1 : scrollers.front()->get_max_content_height();
|
||||
check(!scrollers.empty() && isAncestor(*scrollers.front(), *menuEntry(*menu, effects.front()->title())),
|
||||
"the entries scroll");
|
||||
int cap = 0;
|
||||
for (auto* scroller : scrollers) cap += scroller->get_max_content_height();
|
||||
bool scrolled = scrollers.size() == 2;
|
||||
for (const auto& effect : effects) {
|
||||
scrolled = scrolled &&
|
||||
isAncestor(*scrollers[effect->video() ? 1 : 0], *menuEntry(*menu, effect->title()));
|
||||
}
|
||||
check(scrolled, "the image and the video entries scroll, each on their own");
|
||||
check(cap > 0 && cap <= 480, "past a height that fits a small screen");
|
||||
check(menu->get_height() > 0 && menu->get_height() <= cap + 40,
|
||||
// The rest is the two headings, the separator and, in a still, the hint
|
||||
// under the video heading.
|
||||
check(menu->get_height() > 0 && menu->get_height() <= cap + 170,
|
||||
"and the menu stays within it however many effects there are");
|
||||
add->popdown();
|
||||
pump();
|
||||
@@ -304,15 +342,19 @@ void runTests(Gtk::Window& window) {
|
||||
}
|
||||
|
||||
std::cout << "many stages\n";
|
||||
for (int i = 0; i < 4; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
addFromMenu(*add, imageEffects[static_cast<std::size_t>(i) % imageEffects.size()]->title());
|
||||
}
|
||||
pump();
|
||||
const int fewSplit = split != nullptr ? split->get_position() : -1;
|
||||
check(split != nullptr && fewSplit > emptySplit,
|
||||
"the split moves down as the chain grows");
|
||||
for (int i = 4; i < 12; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
|
||||
for (int i = 4; i < 12; ++i) {
|
||||
addFromMenu(*add, imageEffects[static_cast<std::size_t>(i) % imageEffects.size()]->title());
|
||||
}
|
||||
check(rowCount(*chainList) == 13, "the menu still works with a long chain");
|
||||
check(addFromMenu(*add, effects.back()->title()) && rowCount(*chainList) == 14,
|
||||
"including for the last effect in the menu");
|
||||
check(addFromMenu(*add, imageEffects.back()->title()) && rowCount(*chainList) == 14,
|
||||
"including for the last image effect in the menu");
|
||||
pump();
|
||||
const int longSplit = split != nullptr ? split->get_position() : -1;
|
||||
check(longSplit > fewSplit && listScroller != nullptr &&
|
||||
@@ -440,6 +482,363 @@ void testRecentProjects(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
"opened projects are listed newest first, a shared name with its folder");
|
||||
}
|
||||
|
||||
// Visible labels in the params grid saying `text`.
|
||||
bool showsLabel(Gtk::Widget& root, const std::string& text) {
|
||||
for (auto* label : all<Gtk::Label>(root)) {
|
||||
if (label->get_text() == text && label->is_drawable()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
std::cout << "video\n";
|
||||
std::filesystem::create_directories(dir);
|
||||
const auto path = dir / "moving.json";
|
||||
effector::Project project;
|
||||
// Neither needs sources: an image effect, then the video effect.
|
||||
project.stages.push_back({effector::effectByName("channelsplit"), {}});
|
||||
project.stages.push_back({effector::effectByName("tracking"), {}});
|
||||
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");
|
||||
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
|
||||
auto* add = named<Gtk::MenuButton>(window, "add-effect");
|
||||
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 || chainList == nullptr || add == nullptr) {
|
||||
return;
|
||||
}
|
||||
const std::string tracking = effector::effectByName("tracking")->title();
|
||||
auto* entry = menuEntry(*add->get_popover(), tracking);
|
||||
// The label of a chain row, which says whether the stage runs.
|
||||
auto rowLabel = [&](int index) -> Gtk::Label* {
|
||||
auto* row = chainList->get_row_at_index(index);
|
||||
if (row == nullptr) return nullptr;
|
||||
for (auto* label : all<Gtk::Label>(*row)) {
|
||||
if (label->get_text().substr(0, 3) == std::to_string(index + 1) + ". ") return label;
|
||||
}
|
||||
return nullptr;
|
||||
};
|
||||
auto* videoRow = chainList->get_row_at_index(1);
|
||||
auto* videoLabel = rowLabel(1);
|
||||
|
||||
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(videoRow != nullptr && !all<Gtk::Image>(*videoRow).empty() && videoLabel != nullptr &&
|
||||
videoLabel->has_css_class("dim-label") &&
|
||||
videoLabel->get_tooltip_text() == "A video effect: skipped in a still picture",
|
||||
"a video stage is marked as one, and dimmed as skipped in a still");
|
||||
check(!rowLabel(0)->has_css_class("dim-label"), "while the image stage before it runs");
|
||||
check(entry != nullptr && !entry->get_sensitive(), "the video effect cannot be added to a still");
|
||||
|
||||
toggle->set_active(true);
|
||||
pump(200);
|
||||
videoLabel = rowLabel(1);
|
||||
check(videoLabel != nullptr && !videoLabel->has_css_class("dim-label"),
|
||||
"in a video the video stage runs");
|
||||
check(entry != nullptr && entry->get_sensitive(), "and more can be added");
|
||||
chainList->select_row(*chainList->get_row_at_index(1));
|
||||
pump(100);
|
||||
check(showsLabel(window, "Wobble") && showsLabel(window, "Tracking bar"),
|
||||
"its settings are its motion");
|
||||
stack->set_visible_child("setup");
|
||||
pump(100);
|
||||
check(preset->is_drawable() && duration->is_drawable(), "and its preset and length");
|
||||
auto* quality = named<Gtk::DropDown>(window, "video-quality");
|
||||
check(quality != nullptr && quality->is_drawable() &&
|
||||
effector::videoQualities().at(quality->get_selected()).key ==
|
||||
effector::kDefaultVideoQuality,
|
||||
"and its file quality, standard to begin with");
|
||||
|
||||
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);
|
||||
videoLabel = rowLabel(1);
|
||||
check(!playback->get_visible() && videoLabel != nullptr &&
|
||||
videoLabel->has_css_class("dim-label") && entry != nullptr && !entry->get_sensitive(),
|
||||
"switched back to a still, the playback goes and the video stage is skipped again");
|
||||
stack->set_visible_child("chain");
|
||||
}
|
||||
|
||||
// A font param is a font chooser showing the family, and the family picked
|
||||
// there is what the stage renders with and what the project saves.
|
||||
void testFont(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
std::cout << "font\n";
|
||||
std::filesystem::create_directories(dir);
|
||||
const auto path = dir / "lettering.json";
|
||||
effector::Project project;
|
||||
effector::ChainStage stage{effector::effectByName("text"), {}};
|
||||
stage.values["font_family"] = std::string("Serif");
|
||||
project.stages.push_back(stage);
|
||||
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* chainList = named<Gtk::ListBox>(window, "chain-list");
|
||||
if (chainList != nullptr && chainList->get_row_at_index(0) != nullptr) {
|
||||
chainList->select_row(*chainList->get_row_at_index(0));
|
||||
}
|
||||
pump(100);
|
||||
std::vector<Gtk::FontDialogButton*> buttons;
|
||||
collect<Gtk::FontDialogButton>(window, [](Gtk::FontDialogButton& b) { return b.is_drawable(); },
|
||||
buttons);
|
||||
check(buttons.size() == 1, "the text stage picks its font with a font chooser");
|
||||
if (buttons.size() != 1) return;
|
||||
auto* button = buttons.front();
|
||||
check(button->get_level() == Gtk::FontLevel::FAMILY,
|
||||
"which chooses only the family, the weight and size being params of their own");
|
||||
check(button->get_font_desc().get_family() == "Serif", "and shows the family saved");
|
||||
|
||||
Pango::FontDescription mono;
|
||||
mono.set_family("Monospace");
|
||||
button->set_font_desc(mono);
|
||||
pump(100);
|
||||
window.activate_action("win.save-project");
|
||||
pump(100);
|
||||
const auto saved = effector::loadProject(path.string());
|
||||
check(saved.stages.size() == 1 &&
|
||||
effector::paramText(saved.stages[0].values, "font_family", "") == "Monospace",
|
||||
"a family chosen there is saved with the project");
|
||||
}
|
||||
|
||||
void testSlider(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
std::cout << "slider\n";
|
||||
std::filesystem::create_directories(dir);
|
||||
const auto path = dir / "swapping.json";
|
||||
effector::Project project;
|
||||
project.video.enabled = true;
|
||||
effector::ChainStage stage{effector::effectByName("tileswap"), {}};
|
||||
stage.values["tileswap_rate"] = 0.5;
|
||||
project.stages.push_back(stage);
|
||||
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* chainList = named<Gtk::ListBox>(window, "chain-list");
|
||||
if (chainList != nullptr && chainList->get_row_at_index(0) != nullptr) {
|
||||
chainList->select_row(*chainList->get_row_at_index(0));
|
||||
}
|
||||
pump(100);
|
||||
std::vector<Gtk::Scale*> scales;
|
||||
collect<Gtk::Scale>(window, [](Gtk::Scale& s) { return s.is_drawable(); }, scales);
|
||||
check(scales.size() == 1, "a param marked as a slider is shown as one");
|
||||
if (scales.size() != 1) return;
|
||||
auto* scale = scales.front();
|
||||
const auto& spec = effector::effectByName("tileswap")->params().back();
|
||||
// Written the way the locale writes numbers, as the interface does.
|
||||
const auto mark = [](double at) {
|
||||
return std::string(Glib::ustring::format(std::fixed, std::setprecision(2), at));
|
||||
};
|
||||
check(showsText(*scale, mark(0.0)) && showsText(*scale, mark(5.0)),
|
||||
"with its range marked at both ends");
|
||||
check(spec.sliderValue(scale->get_value()) == 0.5, "and shows the value saved");
|
||||
check(scale->get_value() > 0.5 / 5.0 + 0.3,
|
||||
"a curved slider gives the low end of its range more of its travel");
|
||||
|
||||
scale->set_value(spec.sliderPosition(0.05));
|
||||
pump(100);
|
||||
window.activate_action("win.save-project");
|
||||
pump(100);
|
||||
const auto saved = effector::loadProject(path.string());
|
||||
check(saved.stages.size() == 1 &&
|
||||
std::abs(effector::paramDouble(saved.stages[0].values, "tileswap_rate", 0.0) - 0.05) < 1e-9,
|
||||
"a value slid to is saved with the project");
|
||||
}
|
||||
|
||||
bool actionEnabled(Gtk::Window& window, const std::string& name) {
|
||||
auto action = dynamic_cast<Gtk::ApplicationWindow&>(window).lookup_action(name);
|
||||
return action && action->get_enabled();
|
||||
}
|
||||
|
||||
// Whether the window catches `keys` for `action` before the focused widget.
|
||||
bool capturesShortcut(Gtk::Window& window, const std::string& keys, const std::string& action) {
|
||||
auto controllers = window.observe_controllers();
|
||||
for (guint i = 0; i < controllers->get_n_items(); ++i) {
|
||||
auto* controller = GTK_EVENT_CONTROLLER(g_list_model_get_item(controllers->gobj(), i));
|
||||
const bool capture = gtk_event_controller_get_propagation_phase(controller) == GTK_PHASE_CAPTURE;
|
||||
if (GTK_IS_SHORTCUT_CONTROLLER(controller) && capture) {
|
||||
auto* list = G_LIST_MODEL(controller);
|
||||
for (guint j = 0; j < g_list_model_get_n_items(list); ++j) {
|
||||
auto* shortcut = GTK_SHORTCUT(g_list_model_get_item(list, j));
|
||||
char* trigger = gtk_shortcut_trigger_to_string(gtk_shortcut_get_trigger(shortcut));
|
||||
auto* named = gtk_shortcut_get_action(shortcut);
|
||||
const bool match = trigger == keys && GTK_IS_NAMED_ACTION(named) &&
|
||||
action == gtk_named_action_get_action_name(GTK_NAMED_ACTION(named));
|
||||
g_free(trigger);
|
||||
g_object_unref(shortcut);
|
||||
if (match) {
|
||||
g_object_unref(controller);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
g_object_unref(controller);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void testHistory(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
std::cout << "undo and redo\n";
|
||||
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
|
||||
auto* add = named<Gtk::MenuButton>(window, "add-effect");
|
||||
if (chainList == nullptr || add == nullptr) return;
|
||||
|
||||
check(capturesShortcut(window, "<Control>z", "win.undo") &&
|
||||
capturesShortcut(window, "<Control>y", "win.redo") &&
|
||||
capturesShortcut(window, "<Shift><Control>z", "win.redo"),
|
||||
"Ctrl+Z undoes, Ctrl+Y and Ctrl+Shift+Z redo, ahead of the focused widget");
|
||||
|
||||
window.activate_action("win.new-project");
|
||||
pump(700);
|
||||
check(!actionEnabled(window, "undo") && !actionEnabled(window, "redo"),
|
||||
"a new project has nothing to undo or redo");
|
||||
|
||||
addFromMenu(*add, "Blur");
|
||||
pump(700);
|
||||
addFromMenu(*add, "Scanlines");
|
||||
pump(700);
|
||||
check(rowCount(*chainList) == 2 && actionEnabled(window, "undo"), "two stages added");
|
||||
|
||||
window.activate_action("win.undo");
|
||||
pump();
|
||||
check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur",
|
||||
"undo takes the last stage off");
|
||||
check(actionEnabled(window, "redo"), "and it can be redone");
|
||||
window.activate_action("win.undo");
|
||||
pump();
|
||||
check(rowCount(*chainList) == 0 && !actionEnabled(window, "undo"), "undo again empties the chain");
|
||||
|
||||
window.activate_action("win.redo");
|
||||
pump();
|
||||
window.activate_action("win.redo");
|
||||
pump();
|
||||
check(rowCount(*chainList) == 2 && rowTitle(*chainList, 1) == "2. Scanlines" &&
|
||||
!actionEnabled(window, "redo"),
|
||||
"redo puts both back");
|
||||
|
||||
// Undone straight away, before the edit has settled into a step.
|
||||
addFromMenu(*add, "Blur");
|
||||
check(actionEnabled(window, "undo"), "an edit can be undone at once");
|
||||
window.activate_action("win.undo");
|
||||
pump();
|
||||
check(rowCount(*chainList) == 2, "and undoing it takes that edit back");
|
||||
|
||||
window.activate_action("win.undo");
|
||||
pump();
|
||||
addFromMenu(*add, "Blur");
|
||||
pump(700);
|
||||
check(!actionEnabled(window, "redo"), "a new edit after an undo drops what could be redone");
|
||||
|
||||
// The images of an opened project arrive after it, from the decoder, and
|
||||
// are part of it rather than a step to undo.
|
||||
std::filesystem::create_directories(dir);
|
||||
const auto source = dir / "source.png";
|
||||
effector::Image(64, 64).save(source.string());
|
||||
effector::Project project;
|
||||
project.sources.push_back(source.string());
|
||||
project.stages.push_back({effector::effectByName("scanlines"), {}});
|
||||
const auto path = dir / "history.json";
|
||||
effector::saveProject(path.string(), project);
|
||||
window.activate_action("win.open-recent",
|
||||
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||
pump(1000);
|
||||
auto* sources = named<Gtk::Box>(window, "sources");
|
||||
auto lists = sources != nullptr ? all<Gtk::ListBox>(*sources) : std::vector<Gtk::ListBox*>{};
|
||||
auto* sourceList = lists.empty() ? nullptr : lists.front();
|
||||
check(sourceList != nullptr && rowCount(*sourceList) == 1 && !actionEnabled(window, "undo"),
|
||||
"opening a project with images leaves nothing to undo");
|
||||
if (sourceList == nullptr || rowCount(*sourceList) != 1) return;
|
||||
|
||||
// Removing an image is undone with the image still in hand.
|
||||
auto* stack = all<Gtk::Stack>(window).front();
|
||||
stack->set_visible_child("setup");
|
||||
pump();
|
||||
const auto removes = all<Gtk::Button>(*sourceList->get_row_at_index(0));
|
||||
if (!removes.empty()) press(*removes.front());
|
||||
pump(700);
|
||||
check(rowCount(*sourceList) == 0, "an image removed");
|
||||
window.activate_action("win.undo");
|
||||
pump();
|
||||
check(rowCount(*sourceList) == 1, "comes back with undo");
|
||||
stack->set_visible_child("chain");
|
||||
|
||||
window.activate_action("win.new-project");
|
||||
pump(700);
|
||||
}
|
||||
|
||||
// A project whose images will not fit is refused before any is decoded, and
|
||||
// saving it then must not quietly drop them from the file.
|
||||
void testMemoryGuard(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
std::cout << "memory guard\n";
|
||||
std::filesystem::create_directories(dir);
|
||||
effector::Project project;
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
const auto source = dir / ("big" + std::to_string(i) + ".png");
|
||||
effector::Image(600, 600).save(source.string());
|
||||
project.sources.push_back(source.string());
|
||||
}
|
||||
project.stages.push_back({effector::effectByName("scanlines"), {}});
|
||||
const auto path = dir / "big.json";
|
||||
effector::saveProject(path.string(), project);
|
||||
|
||||
effector::limitAvailableMemory(std::size_t{1} << 20); // 2.1 MB of images, 1 MB free
|
||||
window.activate_action("win.open-recent",
|
||||
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||
pump(500);
|
||||
auto* sources = named<Gtk::Widget>(window, "sources");
|
||||
auto lists = sources != nullptr ? all<Gtk::ListBox>(*sources) : std::vector<Gtk::ListBox*>{};
|
||||
check(!lists.empty() && rowCount(*lists.front()) == 0, "images too large for the memory are not loaded");
|
||||
bool told = false;
|
||||
for (auto* label : all<Gtk::Label>(window)) {
|
||||
const std::string text = label->get_text();
|
||||
if (label->is_drawable() && text.find("Loading these 2 images needs about") != std::string::npos &&
|
||||
text.find("stay in the project") != std::string::npos) {
|
||||
told = true;
|
||||
}
|
||||
}
|
||||
check(told, "and the window says why");
|
||||
|
||||
window.activate_action("win.save-project");
|
||||
pump(100);
|
||||
check(effector::loadProject(path.string()).sources.size() == 2,
|
||||
"saving the project keeps the images it could not load");
|
||||
effector::limitAvailableMemory(0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
@@ -467,6 +866,11 @@ int main(int argc, char** argv) {
|
||||
runTests(*window);
|
||||
testHelpWindows(*window);
|
||||
testRecentProjects(*window, dataDir / "projects");
|
||||
testVideo(*window, dataDir / "video");
|
||||
testFont(*window, dataDir / "font");
|
||||
testSlider(*window, dataDir / "slider");
|
||||
testHistory(*window, dataDir / "history");
|
||||
testMemoryGuard(*window, dataDir / "memory");
|
||||
app->remove_window(*window);
|
||||
});
|
||||
const int status = app->run(argc > 0 ? 1 : 0, argv);
|
||||
|
||||
Reference in New Issue
Block a user