21 Commits
Author SHA1 Message Date
SiandClaude Opus 5.5 5402a50742 Add undo and redo to the interface
Ctrl+Z undoes, Ctrl+Y and Ctrl+Shift+Z redo, caught in the capture
phase so a focused entry or spin never undoes only its own text; both
are in the header-bar menu too. The history keeps whole snapshots of
the project (currentProject() plus the immutable source list itself,
so stepping back never decodes again), compared by their JSON.
requestRender() notes every edit, and a step is committed once the
controls have been still for half a second. New and Open start the
history over once the project's images are in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:15:03 +02:00
SiandClaude Opus 5.5 f9fe6e0fa5 Pick the text stage's font with a font chooser
A new Font param type (ParamSpec::makeFont) is a font family as the
machine names it. The interface shows it as a family-only
FontDialogButton; the command line and the project file treat it as a
plain string, as they did the Text param it replaces, so existing
projects and --font-family keep working. A family the machine lacks is
kept as written. Bumps the version to 0.8.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:58:44 +02:00
SiandClaude Opus 5.5 8acf85ddb8 Show progress while loading reads the image sizes
Before decoding, loadImages reads every header to size the batch against
the free memory. That pass was serial, silent and could not be cancelled,
so the loading window sat at "0 of N". LoadProgress gains onSized, the
headers are read in parallel on the same cursor-driven workers as the
decodes, and a cancel stops the pass. The window's bar runs twice,
"Checking sizes" then "Decoding", and the CLI prints a sizing line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:42:30 +02:00
SiandClaude Opus 5.5 cfaa773cf7 Give the video effects more room in the Add effect menu
With eight video effects their list filled its 110-pixel cap, and the menu's
headings take more than the 120 pixels the height test allowed for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:49:10 +02:00
SiandClaude Opus 5.5 53dca6b4b8 Add Focus pull, a video effect that pulls Blur in and out of focus
Blur's box blur moves to effects/box_blur.hpp so both share it; Blur renders
exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:45:46 +02:00
SiandClaude Opus 5.5 4b407281e2 Add Strip roll, a video effect that rolls the picture in Scanlines' strips
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:43:47 +02:00
SiandClaude Opus 5.5 a7cb2505d7 Add Datamosh, a video effect whose Corrupt blocks break and heal over the clip
Corrupt's block damage moves to effects/corrupt_blocks.hpp so both share it;
Corrupt renders exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:41:52 +02:00
SiandClaude Opus 5.5 ce0207c41f Add Film gate, a video effect of film running through a projector
Film grain's field moves to effects/grain_field.hpp so both share it; Film
grain renders exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:39:57 +02:00
SiandClaude Opus 5.5 37c02a8a7b Add Sort sweep, a video effect that sweeps Pixel sort's window over the brightness
The run sorting moves to effects/sort_runs.hpp so both share it; Pixel sort
renders exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:37:30 +02:00
SiandClaude Opus 5.5 0377cd4abb Add Warp flow, a video effect that sets Warp's noise field drifting
Warp's noise field moves to effects/noise_field.hpp so both share it; Warp
renders exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:34:55 +02:00
SiandClaude Opus 5.5 370d3e550e Add Echo tunnel, a video effect that flies through Feedback's nested echoes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:32:49 +02:00
SiandClaude Opus 5.5 8537a3cf0a Open Save in the project folder and offer the last name with a number counted on
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:24:10 +02:00
SiandClaude Opus 5.5 ac8b62208c Render the video preview draft first: every third frame, then the rest
The preview plays a draft of every third frame, each held for three, as
soon as those are in, and fills in the frames between while it plays.
renderFrames takes an optional frame order (draftFirstOrder builds this
one); a frame is the same whatever order it is rendered in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:49:59 +02:00
SiandClaude Opus 5.5 6df4bcb079 Add a video export quality choice: Small file, Standard or High
A Quality dropdown under Video on Setup, --video-quality on the command
line, and "quality" in a project's video settings (written only when it is
not Standard). One table, videoQualities(), holds the ffmpeg settings per
codec; Standard is what every video was written with before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:41:00 +02:00
SiandClaude Opus 5.5 1b30a079e6 Add Tile swap, a video effect built from the source images
Tile swap lays tiles cut from the photographs over the canvas as Tiles
does, in a grid, in bricks or scattered, and swaps each one for another
cut at a moment of its own, so the mosaic keeps changing piece by piece.
The layout stays put for the whole clip; what a tile shows depends only
on the tile and how many times it has swapped, counted round the clip, so
it holds still between swaps and the clip loops without a jump. On the
command line it is -e tileswap with --tileswap-rate and the rest.

VideoEffect learns a second kind: an effect that answers needsSources()
writes compose() instead of animate(), and the base then takes no crop,
only the one seed, so it sees the photographs when it leads the chain and
the stage before it otherwise. Transforms render exactly as before. The
tile cutting and painting move out of tiles.cpp into tile_plan.hpp for
both effects to share; Tiles renders byte for byte as before. Version
0.8.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:22:26 +02:00
SiandClaude Opus 5.5 cdaadffc89 Split effects into image and video effects, with VHS tracking as the first video effect
Effect::video() says which kind an effect is. The Add effect menu lists the
two kinds in sections of their own, each scrolling on its own, and offers
video effects only in a video project; a video stage carries a film icon in
the chain and, in a still, is dimmed and skipped like a stage switched off.
enabledStages takes whether the render is a video and is the one place that
leaves video stages out of a still. --help groups the video effects under a
heading of their own, --list-effects marks them, and a video effect given
for a still is skipped with a warning.

VideoEffect is the base every video effect builds on, and keeps the rules a
moving stage has to follow: it takes one value from the shared rng, the same
on every frame, and hands the effect steady and per-frame generators and
loop-rounded time through a Moment, so a subclass writes only animate().
VHS tracking is the first, and the one to copy: the colour planes sway and
shake and a tracking bar rolls down the picture.

That motion moves out of channel split, which is an image effect again and
renders stills exactly as before; forVideo() goes with it. Version 0.7.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:03:39 +02:00
SiandClaude Opus 5.5 ea59db5acd Refuse loads and renders that would not fit in memory
Opening a project of ninety 17 MP photos got Effector killed by the
kernel's OOM killer: 4.6 GB of decoded RGB on a machine with 3.6 GB free,
and no core dump to show for it. Bulk allocations are now sized first and
refused with an OutOfMemoryError past three quarters of the free memory
(MemAvailable, or less under a cgroup memory.max). loadImages sizes the
batch from the file headers and refuses it before decoding anything, and
holds the decodes in flight to what is left; renderChain and renderFrames
refuse a canvas at 24 bytes a pixel, plus any picture a Path param loads.
The interface keeps a refused batch's paths, so saving the project never
drops them. Version 0.6.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:31:53 +02:00
SiandClaude Opus 5.5 385617781e Make video projects, with a moving channel split as the first video effect
A project can now be a clip: Setup has a Video switch with a length, a
frame rate and presets that set the canvas too (Instagram story, reel and
feed video, TikTok / YouTube Short, Square loop, Full HD clip). The preview
renders the whole clip, shrunk to a memory budget, and loops it with
play/pause and a scale; Save As... renders it again at full size and pipes
the frames into ffmpeg as .mp4, .mov, .mkv, .webm or .gif, with a progress
window that can cancel. --duration, --fps and --video-preset do the same
from the command line.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:22:01 +02:00
SiandClaude Opus 5.5 c069de3566 Give Effector its own icon
An E split into its colour channels, with two slices slipped sideways,
replaces the generic applications-graphics icon in the desktop entry and
About Effector, with a one-colour symbolic version for panels. Both are
installed into the hicolor theme; a build run straight from the build tree
falls back to the copy in the sources. The version moves to 0.5.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:38:49 +02:00
SiandClaude Opus 5.5 708b6bdb1b License Effector under the GPL, version 3 or later
LICENSE holds the text, the README and About Effector state it, and the
Arch package declares it. The version moves to 0.5.2 for the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:26:28 +02:00
SiandClaude Opus 5.5 520c7c45fb Package Effector for Arch and give it a desktop entry
packaging/arch/PKGBUILD builds the tagged release from this repository
and installs it with pacman; the version test keeps its pkgver in step
with CMakeLists.txt. A desktop entry puts the interface in the app menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:22:18 +02:00
67 changed files with 6266 additions and 579 deletions
+4
View File
@@ -5,3 +5,7 @@ compile_commands.json
__pycache__/ __pycache__/
*.o *.o
*.a *.a
packaging/arch/effector/
packaging/arch/src/
packaging/arch/pkg/
packaging/arch/*.pkg.tar*
+142
View File
@@ -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 `project()` line of `CMakeLists.txt`, and each release is a `vX.Y.Z` tag on the
commit that made it. The interface shows this file under Changelog in its menu. commit that made it. The interface shows this file under Changelog in its menu.
## [0.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
- Effector is free software under the GNU General Public License, version 3
or later; the `LICENSE` file holds the text, and About Effector says so.
## [0.5.1] - 2026-09-26
### Added
- An Arch Linux `PKGBUILD` in `packaging/arch`: `makepkg -si` there builds and
installs the tagged release with pacman.
- A desktop entry, so an installed Effector shows up in the app menu.
## [0.5.0] - 2026-09-26 ## [0.5.0] - 2026-09-26
### Added ### Added
+109 -7
View File
@@ -15,7 +15,9 @@ Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way.
The core test suite is a single hand-rolled binary (no framework), so there is no The core test suite is a single hand-rolled binary (no framework), so there is no
per-test filter: run `./build/tests/effector_tests` directly to see the section per-test filter: run `./build/tests/effector_tests` directly to see the section
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`, headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
`image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment `image io`, `batch loading`, `video`, `version`) and the `ok`/`FAIL` lines. The
`video` section writes real files through `ffmpeg` when it is on the PATH and
prints a `skip` line otherwise. To iterate on one area, comment
out the other blocks in `tests/core_tests.cpp` or add a new out the other blocks in `tests/core_tests.cpp` or add a new
`std::cout << "section\n"` block — checks are plain `std::cout << "section\n"` block — checks are plain
`check(condition, "description")` calls and `main()` returns the failure count. `check(condition, "description")` calls and `main()` returns the failure count.
@@ -41,7 +43,9 @@ CMake writes it, together with the text of `CHANGELOG.md`, into a generated
in the build tree. `--version` prints it, and the header-bar menu shows it in the build tree. `--version` prints it, and the header-bar menu shows it
under About Effector and the changelog under Changelog. The `version` core test under About Effector and the changelog under Changelog. The `version` core test
fails unless the newest `## [X.Y.Z]` entry in `CHANGELOG.md` is the current fails unless the newest `## [X.Y.Z]` entry in `CHANGELOG.md` is the current
version, so a bump and its changelog entry go in together. Each release is an version, so a bump and its changelog entry go in together, and it also fails unless
`pkgver` in `packaging/arch/PKGBUILD` matches (that PKGBUILD builds the
`vX.Y.Z` tag, so reset `pkgrel=1` on a bump). Each release is an
annotated `vX.Y.Z` tag on the commit that made it. When you add a user-visible annotated `vX.Y.Z` tag on the commit that made it. When you add a user-visible
change, add a line to the newest changelog entry, or start a new entry if that change, add a line to the newest changelog entry, or start a new entry if that
one is already tagged. The Changelog window reads only headings, `- ` bullets one is already tagged. The Changelog window reads only headings, `- ` bullets
@@ -69,7 +73,9 @@ 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` 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 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`), `#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 travel as `ParamValues` (a
`map<string, variant<long,double,bool,string>>`), read back with `map<string, variant<long,double,bool,string>>`), read back with
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`. `paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
@@ -91,7 +97,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 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". 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 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 `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. CLI, in the chain menu and as a cascade stage on its own.
@@ -168,11 +175,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 `XDG_DATA_HOME` at a temporary folder so they never touch the user's list. Opening one replaces the sources, so a decode still in flight
is discarded rather than mixed into the new project (`dropLoadedImages_`). is discarded rather than mixed into the new project (`dropLoadedImages_`).
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 ### Two link targets, deliberately
`effector_core` (static) holds image IO, params, effects, rng and links only `effector_core` (static) holds image IO, params, effects, rng, video writing
`gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need and links only `gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango
no display) + Threads — **no gtkmm, no display**. `effector_gui` (static) adds and Cairo need no display) + Threads — **no gtkmm, no display**; `ffmpeg` is a
program it runs, not a library it links. `effector_gui` (static) adds
`src/cli/` and `src/gui/` and links gtkmm, and the `effector` executable is `src/cli/` and `src/gui/` and links gtkmm, and the `effector` executable is
`src/main.cpp` on top of it; the GUI tests link `effector_gui` too. Keep GTK out of `src/main.cpp` on top of it; the GUI tests link `effector_gui` too. Keep GTK out of
`src/core/` and `src/effects/`; the tests link the core alone and rely on this. `src/core/` and `src/effects/`; the tests link the core alone and rely on this.
@@ -212,6 +299,21 @@ then copies pixels in parallel.
copy-on-write via `mutateSources()`, so a render in flight can never race with copy-on-write via `mutateSources()`, so a render in flight can never race with
an add/remove. 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 buffer
`Image` is tightly packed 8-bit RGB with stride exactly `width * 3` — effects `Image` is tightly packed 8-bit RGB with stride exactly `width * 3` — effects
+20 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(effector VERSION 0.5.0 LANGUAGES CXX) project(effector VERSION 0.8.1 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -35,16 +35,22 @@ add_library(effector_core STATIC
src/core/chain.cpp src/core/chain.cpp
src/core/effect.cpp src/core/effect.cpp
src/core/image.cpp src/core/image.cpp
src/core/memory.cpp
src/core/params.cpp src/core/params.cpp
src/core/project.cpp src/core/project.cpp
src/core/registry.cpp src/core/registry.cpp
src/core/rng.cpp src/core/rng.cpp
src/core/style.cpp src/core/style.cpp
src/core/video.cpp
src/effects/bitcrush.cpp src/effects/bitcrush.cpp
src/effects/blur.cpp src/effects/blur.cpp
src/effects/channelsplit.cpp src/effects/channelsplit.cpp
src/effects/corrupt.cpp src/effects/corrupt.cpp
src/effects/datamosh.cpp
src/effects/feedback.cpp src/effects/feedback.cpp
src/effects/filmgate.cpp
src/effects/focuspull.cpp
src/effects/flow.cpp
src/effects/grain.cpp src/effects/grain.cpp
src/effects/monochrome.cpp src/effects/monochrome.cpp
src/effects/overlay.cpp src/effects/overlay.cpp
@@ -52,8 +58,14 @@ add_library(effector_core STATIC
src/effects/scanlines.cpp src/effects/scanlines.cpp
src/effects/shatter.cpp src/effects/shatter.cpp
src/effects/slitshift.cpp src/effects/slitshift.cpp
src/effects/striproll.cpp
src/effects/sweep.cpp
src/effects/text.cpp src/effects/text.cpp
src/effects/tiles.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 src/effects/warp.cpp
) )
target_include_directories(effector_core PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated) 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_link_libraries(effector_gui PUBLIC effector_core PkgConfig::GTKMM)
target_compile_options(effector_gui PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(effector_gui PRIVATE -Wall -Wextra -Wpedantic)
# Where a build that was never installed finds its own icon (see app.cpp).
target_compile_definitions(effector_gui PRIVATE
EFFECTOR_SOURCE_ICON_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data/icons")
# --- application -------------------------------------------------------------- # --- application --------------------------------------------------------------
add_executable(effector src/main.cpp) add_executable(effector src/main.cpp)
@@ -77,6 +92,10 @@ target_link_libraries(effector PRIVATE effector_gui)
target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic)
install(TARGETS effector RUNTIME DESTINATION bin) install(TARGETS effector RUNTIME DESTINATION bin)
# The launcher entry, so the interface shows up in the desktop's app menu.
install(FILES data/org.effector.Effector.desktop DESTINATION share/applications)
# The icon it names, in full colour and as a one-colour symbolic for panels.
install(DIRECTORY data/icons/hicolor DESTINATION share/icons)
# --- tests -------------------------------------------------------------------- # --- tests --------------------------------------------------------------------
include(CTest) include(CTest)
+232
View File
@@ -0,0 +1,232 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
+51
View File
@@ -9,6 +9,8 @@ those declarations, so a new effect needs no UI or CLI code.
Requirements: a C++20 compiler (GCC 13+), CMake 3.20+, `gtkmm-4.0` and Requirements: a C++20 compiler (GCC 13+), CMake 3.20+, `gtkmm-4.0` and
`gdk-pixbuf-2.0` development packages. On Arch: `gcc cmake ninja gtkmm-4.0`. `gdk-pixbuf-2.0` development packages. On Arch: `gcc cmake ninja gtkmm-4.0`.
Writing videos needs the `ffmpeg` program at run time (on Arch: `ffmpeg`);
without it everything else works and the video tests are skipped.
```bash ```bash
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
@@ -19,6 +21,13 @@ ctest --test-dir build --output-on-failure
The binary lands at `build/effector`; `cmake --install build --prefix ~/.local` The binary lands at `build/effector`; `cmake --install build --prefix ~/.local`
installs it to `~/.local/bin`. installs it to `~/.local/bin`.
On Arch, `packaging/arch/PKGBUILD` builds the release tagged `v<version>` from
this repository and installs it with pacman, desktop entry included:
```bash
cd packaging/arch && makepkg -si
```
## Run ## Run
```bash ```bash
@@ -51,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 format is saved with the project, so an A4 project reopens as A4 rather than as
the pixels it happened to be. the pixels it happened to be.
### Video
A project can be a video clip instead of a still: switch **Video** on under
Setup and give it a length and a frame rate, or pick a preset that sets the
canvas too — Instagram story (1080×1920, 15 s), Instagram reel (30 s),
Instagram feed video, TikTok / YouTube Short, Square loop (6 s) and Full HD
clip. The preview renders the whole clip first, shrunk to fit in memory, and
then loops it, with play/pause and a scale to look at any one frame. **Save
As…** then writes the video at full size through `ffmpeg`, which has to be
installed: `.mp4`, `.mov` and `.mkv` as H.264, `.webm` as VP9, `.gif` as a
palette GIF.
```bash
build/effector -o story.mp4 --video-preset instagram-story -e channelsplit -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 Any option can also be passed when launching the interface; it becomes the
starting state (images, canvas size, effect settings, seed). starting state (images, canvas size, effect settings, seed).
@@ -152,3 +192,14 @@ render time of the current preview in its status line.
Decoding threads take the next file from a shared cursor rather than a fixed Decoding threads take the next file from a shared cursor rather than a fixed
slice of the list, so one very large file cannot stall the files queued behind slice of the list, so one very large file cannot stall the files queued behind
it and progress advances at a steady rate. it and progress advances at a steady rate.
## License
Copyright © 2026 Si.
Effector is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version. It is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; see `LICENSE` for the full text. Images you render with it are yours
and are not covered by the license.
@@ -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

+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=Effector
GenericName=Image Effects
Comment=Cascade glitch and collage effects over your photographs
Exec=effector %F
Icon=org.effector.Effector
Terminal=false
Categories=Graphics;2DGraphics;RasterGraphics;GTK;
MimeType=image/jpeg;image/png;image/webp;image/tiff;image/bmp;
Keywords=glitch;collage;scanlines;photo;effect;
StartupNotify=true
+31
View File
@@ -0,0 +1,31 @@
# Builds the release tagged v$pkgver from the repository this file sits in,
# 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.8.1
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")
sha256sums=('SKIP')
build() {
cmake -S "$pkgname" -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr
cmake --build build
}
check() {
ctest --test-dir build --output-on-failure
}
package() {
DESTDIR="$pkgdir" cmake --install build
}
+91 -1
View File
@@ -55,6 +55,11 @@ std::string requireValue(int argc, char** argv, int& i, const std::string& flag)
Options parseOptions(int argc, char** argv) { Options parseOptions(int argc, char** argv) {
Options options; Options options;
// What was given explicitly, so that a --video-preset fills in only the
// rest, whichever order the flags come in.
bool sizeGiven = false;
bool durationGiven = false;
bool fpsGiven = false;
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i]; const std::string arg = argv[i];
@@ -73,6 +78,7 @@ Options parseOptions(int argc, char** argv) {
options.output = requireValue(argc, argv, i, arg); options.output = requireValue(argc, argv, i, arg);
} else if (arg == "-s" || arg == "--size") { } else if (arg == "-s" || arg == "--size") {
const std::string value = requireValue(argc, argv, i, arg); const std::string value = requireValue(argc, argv, i, arg);
sizeGiven = true;
if (!parseSize(value, options.width, options.height)) { if (!parseSize(value, options.width, options.height)) {
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with " throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with "
"each side between " + std::to_string(kMinCanvasSide) + " and " + "each side between " + std::to_string(kMinCanvasSide) + " and " +
@@ -92,6 +98,47 @@ Options parseOptions(int argc, char** argv) {
std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi)); std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi));
} }
options.dpi = static_cast<int>(dpi); options.dpi = static_cast<int>(dpi);
} else if (arg == "--duration") {
const std::string value = requireValue(argc, argv, i, arg);
double seconds = 0.0;
try {
std::size_t used = 0;
seconds = std::stod(value, &used);
if (used != value.size()) seconds = 0.0;
} catch (const std::exception&) {
seconds = 0.0;
}
if (!(seconds >= kMinDuration) || !(seconds <= kMaxDuration)) {
throw EffectError("invalid duration '" + value + "', expected seconds between " +
formatSeconds(kMinDuration) + " and " +
formatSeconds(kMaxDuration));
}
options.video.enabled = true;
options.video.duration = seconds;
durationGiven = true;
} else if (arg == "--fps") {
const std::string value = requireValue(argc, argv, i, arg);
long fps = 0;
if (!parseLong(value, fps) || fps < kMinFps || fps > kMaxFps) {
throw EffectError("invalid frame rate '" + value + "', expected " +
std::to_string(kMinFps) + "-" + std::to_string(kMaxFps));
}
options.video.enabled = true;
options.video.fps = static_cast<int>(fps);
fpsGiven = true;
} else if (arg == "--video-preset") {
const std::string value = requireValue(argc, argv, i, arg);
if (videoPresetByKey(value) == nullptr) {
throw EffectError("unknown video preset '" + value + "', see --help");
}
options.video.enabled = true;
options.video.preset = value;
} else if (arg == "--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") { } else if (arg == "--disable") {
const std::string name = requireValue(argc, argv, i, arg); const std::string name = requireValue(argc, argv, i, arg);
effectByName(name); // validate now, exactly as -e does effectByName(name); // validate now, exactly as -e does
@@ -135,6 +182,7 @@ Options parseOptions(int argc, char** argv) {
} }
case ParamSpec::Type::Path: case ParamSpec::Type::Path:
case ParamSpec::Type::Text: case ParamSpec::Type::Text:
case ParamSpec::Type::Font:
options.params[spec->key] = requireValue(argc, argv, i, arg); options.params[spec->key] = requireValue(argc, argv, i, arg);
break; break;
case ParamSpec::Type::Color: { case ParamSpec::Type::Color: {
@@ -162,6 +210,18 @@ Options parseOptions(int argc, char** argv) {
options.images.push_back(arg); options.images.push_back(arg);
} }
} }
// A preset fills in whatever was not given on its own: the length, the
// rate and -- unless -s or --format named one -- the canvas. It keeps its
// name only while the settings still are the preset's.
if (const auto* preset = videoPresetByKey(options.video.preset)) {
if (!durationGiven) options.video.duration = preset->seconds;
if (!fpsGiven) options.video.fps = preset->fps;
if (options.format.empty() && !sizeGiven) options.format = preset->canvasFormat;
if (options.video.duration != preset->seconds || options.video.fps != preset->fps ||
options.format != preset->canvasFormat) {
options.video.preset.clear();
}
}
// Applied once everything is parsed, so --dpi may come before --format and // Applied once everything is parsed, so --dpi may come before --format and
// so a format always wins over an -s given alongside it. // so a format always wins over an -s given alongside it.
if (const auto* format = canvasFormatByKey(options.format)) { if (const auto* format = canvasFormatByKey(options.format)) {
@@ -183,6 +243,13 @@ std::string usage(const char* programName) {
<< " --format NAME canvas format, overrides --size (see below)\n" << " --format NAME canvas format, overrides --size (see below)\n"
<< " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi << " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi
<< " (default: " << kDefaultDpi << ")\n" << " (default: " << kDefaultDpi << ")\n"
<< " --duration SECS render a video this long, " << formatSeconds(kMinDuration)
<< " to " << formatSeconds(kMaxDuration) << "\n"
<< " --fps N frames per second of a video, " << kMinFps << "-" << kMaxFps
<< " (default: " << kDefaultFps << ")\n"
<< " --video-preset NAME a video's canvas and length in one (see below)\n"
<< " --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" << " --disable NAME keep this effect in the chain but skip it\n"
<< " --seed N random seed (default: random)\n" << " --seed N random seed (default: random)\n"
<< " --list-effects list available effects and exit\n" << " --list-effects list available effects and exit\n"
@@ -197,8 +264,30 @@ std::string usage(const char* programName) {
<< (format.print ? " at --dpi" : "") << "\n"; << (format.print ? " at --dpi" : "") << "\n";
} }
out << "\nA video is written through ffmpeg, as .mp4, .mov, .mkv, .webm or .gif by the "
"name given to\n--output.\n";
out << "\nVideo presets (--video-preset):\n";
for (const auto& preset : videoPresets()) {
out << " " << preset.key;
if (preset.key.size() < 20) out << std::string(20 - preset.key.size(), ' ');
out << " " << preset.label << ", " << videoPresetDetail(preset) << "\n";
}
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()) { for (const auto& effect : effects()) {
out << "\n" << effect->title() << " options:\n"; if (effect->video() != video) continue;
out << "\n" << effect->title() << " options (-e " << effect->name() << "):\n";
for (const auto& spec : effect->params()) { for (const auto& spec : effect->params()) {
std::string flag = spec.flag(); std::string flag = spec.flag();
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE"; if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
@@ -207,6 +296,7 @@ std::string usage(const char* programName) {
out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n"; out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n";
} }
} }
}
return out.str(); return out.str();
} }
+4
View File
@@ -8,6 +8,7 @@
#include "core/canvas.hpp" #include "core/canvas.hpp"
#include "core/chain.hpp" #include "core/chain.hpp"
#include "core/params.hpp" #include "core/params.hpp"
#include "core/video.hpp"
namespace effector { namespace effector {
@@ -23,6 +24,9 @@ struct Options {
int height = 1080; int height = 1080;
std::string format; // a named canvas format, empty for plain pixels std::string format; // a named canvas format, empty for plain pixels
int dpi = kDefaultDpi; // only a print format is rendered at a resolution int dpi = kDefaultDpi; // only a print format is rendered at a resolution
// Enabled by --duration, --fps or --video-preset: the output is then a
// video, and --output has to name one.
VideoSettings video;
std::optional<std::uint64_t> seed; std::optional<std::uint64_t> seed;
ParamValues params; // only the keys the user actually passed ParamValues params; // only the keys the user actually passed
bool listEffects = false; bool listEffects = false;
+1
View File
@@ -33,6 +33,7 @@ const std::vector<CanvasFormat>& canvasFormats() {
{"instagram-post", "Instagram post", false, 1080, 1080, 0.0, 0.0}, {"instagram-post", "Instagram post", false, 1080, 1080, 0.0, 0.0},
{"instagram-portrait", "Instagram portrait", false, 1080, 1350, 0.0, 0.0}, {"instagram-portrait", "Instagram portrait", false, 1080, 1350, 0.0, 0.0},
{"instagram-story", "Instagram story", false, 1080, 1920, 0.0, 0.0}, {"instagram-story", "Instagram story", false, 1080, 1920, 0.0, 0.0},
{"vertical-hd", "Vertical Full HD", false, 1080, 1920, 0.0, 0.0},
{"hd", "Full HD", false, 1920, 1080, 0.0, 0.0}, {"hd", "Full HD", false, 1920, 1080, 0.0, 0.0},
{"uhd", "4K UHD", false, 3840, 2160, 0.0, 0.0}, {"uhd", "4K UHD", false, 3840, 2160, 0.0, 0.0},
{"a5", "A5", true, 0, 0, 148.0, 210.0}, {"a5", "A5", true, 0, 0, 148.0, 210.0},
+120 -17
View File
@@ -1,12 +1,18 @@
#include "core/chain.hpp" #include "core/chain.hpp"
#include <algorithm>
#include "core/memory.hpp"
namespace effector { 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; std::vector<const ChainStage*> running;
running.reserve(stages.size()); running.reserve(stages.size());
for (const auto& stage : stages) { 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; return running;
} }
@@ -29,8 +35,10 @@ ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
return values; return values;
} }
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources, namespace {
int width, int height, Rng& rng, const StyleSet& styles) {
// The stages that run, checked: throws for a chain with nothing to render.
std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stages, bool video) {
if (stages.empty()) throw EffectError("The effect chain is empty."); if (stages.empty()) throw EffectError("The effect chain is empty.");
for (const auto& stage : stages) { for (const auto& stage : stages) {
if (!stage.effect) throw EffectError("The effect chain has an empty stage."); if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
@@ -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 // A disabled stage never reaches the render at all: it is not handed an
// image and draws nothing from the shared rng, so switching one off gives // image and draws nothing from the shared rng, so switching one off gives
// back exactly the picture the chain made before it was added. // back exactly the picture the chain made before it was added. A video
const auto running = enabledStages(stages); // effect in a still is left out the same way.
if (running.empty()) throw EffectError("Every effect in the chain is disabled."); 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; // Throws OutOfMemoryError for a render that would not fit. A render holds a
for (std::size_t i = 0; i < running.size(); ++i) { // 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& stage = *running[i];
const auto values = stageValues(stage, styles); const auto values = stageValues(stage, styles);
if (i == 0) { if (i == 0) {
image = stage.effect->render(sources, width, height, rng, values); image = stage.effect->renderFrame(sources, width, height, rng, values, frame);
} else { } else {
// Feeding the previous result back in as the only source is what // Feeding the previous result back in as the only source is what
// cascading means here; effects need to know nothing about it. // cascading means here; effects need to know nothing about it.
const std::vector<Image> previous = {std::move(image)}; const std::vector<Image> previous = {std::move(image)};
image = stage.effect->render(previous, width, height, rng, values); image = stage.effect->renderFrame(previous, width, height, rng, values, frame);
} }
} }
return image; return image;
} }
bool chainNeedsSources(const std::vector<ChainStage>& stages) { } // namespace
const auto running = enabledStages(stages);
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; if (running.empty()) return false;
return running.front()->effect->needsSources(); 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; std::string text;
for (const auto* stage : enabledStages(stages)) { for (const auto* stage : enabledStages(stages, video)) {
if (!text.empty()) text += " → "; if (!text.empty()) text += " → ";
text += stage->effect->title(); text += stage->effect->title();
} }
return text; return text;
} }
std::string chainSlug(const std::vector<ChainStage>& stages) { std::string chainSlug(const std::vector<ChainStage>& stages, bool video) {
std::string text; std::string text;
for (const auto* stage : enabledStages(stages)) { for (const auto* stage : enabledStages(stages, video)) {
if (!text.empty()) text += "-"; if (!text.empty()) text += "-";
text += stage->effect->name(); text += stage->effect->name();
} }
+41 -10
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <functional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -8,6 +9,7 @@
#include "core/params.hpp" #include "core/params.hpp"
#include "core/rng.hpp" #include "core/rng.hpp"
#include "core/style.hpp" #include "core/style.hpp"
#include "core/video.hpp"
namespace effector { namespace effector {
@@ -38,11 +40,12 @@ struct ChainStage {
// that style carries. The one place a style is resolved. // that style carries. The one place a style is resolved.
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles); ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
// The stages that actually run, in order, as pointers into `stages`. A // The stages that actually run, in order, as pointers into `stages`: those
// disabled stage draws nothing from the rng and is handed no image, so a chain // switched on, and of those the video effects only in a video. A stage left
// renders exactly as it would if that stage had never been added -- this is // out draws nothing from the rng and is handed no image, so a chain renders
// the one place the rest of the code asks what "disabled" means. // exactly as it would if that stage had never been added -- this is the one
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages); // 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; // 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 // 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. // reproduces the cascade byte for byte. Throws when no stage is left to run.
// `styles` is the project's pool; a chain built without one -- the command // `styles` is the project's pool; a chain built without one -- the command
// line's -- passes none and renders from the stages alone. // line's -- passes none and renders from the stages alone.
//
// `frame` says where in a video the render sits, a still by default, 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, 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 // Whether the chain has to be given source images. Only the first stage that
// runs ever sees them — every later one works on the stage before it — so this // runs ever sees them — every later one works on the stage before it — so this
// is simply whether that effect declares that it needs them. // 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 // "Scanlines → Blur", for window titles and status lines. Both of these name
// what was rendered, so a disabled stage is left out of them. // what was rendered, so a stage that did not run is left out of them.
std::string chainTitle(const std::vector<ChainStage>& stages); std::string chainTitle(const std::vector<ChainStage>& stages, bool video);
// "scanlines-blur", for file names. // "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 } // namespace effector
+32
View File
@@ -16,6 +16,19 @@ public:
using std::runtime_error::runtime_error; using std::runtime_error::runtime_error;
}; };
// Where a render sits in time. The default -- no frame rate -- is a still
// picture; a video hands every frame its index, the clip's length in frames
// and its rate, so an effect that moves works out where it is from these
// alone and a frame can be rendered on its own, in any order.
struct FrameTime {
int index = 0;
int count = 1;
int fps = 0;
bool video() const noexcept { return fps > 0; }
double seconds() const noexcept { return video() ? static_cast<double>(index) / fps : 0.0; }
};
// Base class for every effect. Canvas size and the random source are common to // Base class for every effect. Canvas size and the random source are common to
// all of them; params() covers only the effect-specific knobs. // all of them; params() covers only the effect-specific knobs.
class Effect { class Effect {
@@ -45,9 +58,28 @@ public:
return title(); 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, virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const = 0; const ParamValues& values) const = 0;
// One frame of a video, or a still when `frame` says so. An 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; ParamValues defaults() const;
}; };
+102 -24
View File
@@ -11,6 +11,8 @@
#include <stdexcept> #include <stdexcept>
#include <thread> #include <thread>
#include "core/memory.hpp"
namespace effector { namespace effector {
namespace { namespace {
@@ -91,6 +93,39 @@ Image Image::load(const std::string& path, std::vector<std::uint8_t>* alphaOut)
return image; return image;
} }
Image Image::scaledToFit(int maxSide) const {
const int longest = std::max(width_, height_);
if (empty() || maxSide < 1 || longest <= maxSide) return *this;
const double scale = static_cast<double>(maxSide) / longest;
const int outWidth = std::max(1, static_cast<int>(width_ * scale));
const int outHeight = std::max(1, static_cast<int>(height_ * scale));
Image out(outWidth, outHeight);
for (int y = 0; y < outHeight; ++y) {
const int y0 = static_cast<int>(static_cast<long>(y) * height_ / outHeight);
const int y1 = std::max(y0 + 1, static_cast<int>(static_cast<long>(y + 1) * height_ / outHeight));
std::uint8_t* target = out.row(y);
for (int x = 0; x < outWidth; ++x) {
const int x0 = static_cast<int>(static_cast<long>(x) * width_ / outWidth);
const int x1 = std::max(x0 + 1, static_cast<int>(static_cast<long>(x + 1) * width_ / outWidth));
unsigned sum[3] = {0, 0, 0};
for (int sy = y0; sy < y1; ++sy) {
const std::uint8_t* in = row(sy) + static_cast<std::size_t>(x0) * 3;
for (int sx = x0; sx < x1; ++sx, in += 3) {
sum[0] += in[0];
sum[1] += in[1];
sum[2] += in[2];
}
}
const unsigned area = static_cast<unsigned>((y1 - y0) * (x1 - x0));
for (int c = 0; c < 3; ++c) {
target[static_cast<std::size_t>(x) * 3 + c] = static_cast<std::uint8_t>(sum[c] / area);
}
}
}
return out;
}
void Image::save(const std::string& path) const { void Image::save(const std::string& path) const {
if (empty()) throw std::runtime_error("nothing to save"); if (empty()) throw std::runtime_error("nothing to save");
@@ -119,31 +154,81 @@ void Image::save(const std::string& path) const {
if (saved == FALSE) throw std::runtime_error(takeError(error)); 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<Image> loadImages(const std::vector<std::string>& paths,
std::vector<LoadFailure>& failures, std::vector<LoadFailure>& failures,
const LoadProgress* progress) { const LoadProgress* progress) {
// Decoding is the dominant cost of a headless run, and the files are const auto cancelled = [progress] {
// independent, so they are decoded in parallel. Threads take the next path return progress != nullptr && progress->cancel != nullptr &&
// from a shared cursor rather than a fixed slice, so one huge file cannot progress->cancel->load(std::memory_order_relaxed);
// stall the files behind it and progress arrives at a steady rate. Slots };
// keep the result in the order the paths were given. // Runs `work(i)` for every path on `workers` threads. Threads take the next
std::vector<Image> slots(paths.size()); // path from a shared cursor rather than a fixed slice, so one huge or slow
std::vector<std::string> errors(paths.size()); // 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}; std::atomic<std::size_t> cursor{0};
std::atomic<std::size_t> done{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(); unsigned hardware = std::thread::hardware_concurrency();
if (hardware == 0) hardware = 1; 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 = [&] { // What the batch holds once decoded, and what one decode needs on top
for (;;) { // while it runs: the pixbuf (with alpha, at worst) and its rotated copy.
if (progress != nullptr && progress->cancel != nullptr && // Reading a header can mean a round trip to a slow disk or a network
progress->cancel->load(std::memory_order_relaxed)) { // share, so the headers are read in parallel too, and reported.
return; 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]);
} }
const std::size_t i = cursor.fetch_add(1, std::memory_order_relaxed); });
if (i >= paths.size()) return; if (cancelled()) return {};
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 { try {
slots[i] = Image::load(paths[i]); slots[i] = Image::load(paths[i]);
} catch (const std::exception& error) { } catch (const std::exception& error) {
@@ -153,14 +238,7 @@ std::vector<Image> loadImages(const std::vector<std::string>& paths,
progress->onFile(done.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(), progress->onFile(done.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(),
paths[i]); paths[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(decode);
decode();
for (auto& thread : threads) thread.join();
std::vector<Image> images; std::vector<Image> images;
images.reserve(paths.size()); images.reserve(paths.size());
+19 -2
View File
@@ -40,6 +40,11 @@ public:
// which is what an effect compositing the image needs. // which is what an effect compositing the image needs.
static Image load(const std::string& path, std::vector<std::uint8_t>* alphaOut = nullptr); static Image load(const std::string& path, std::vector<std::uint8_t>* alphaOut = nullptr);
// A copy shrunk so that its longer side is at most `maxSide`, each pixel
// the average of the ones it covers; an image already that small comes
// back as it is. For previews: nothing rendered goes through it.
Image scaledToFit(int maxSide) const;
// Encodes by file extension; png is used when the extension is unknown. // Encodes by file extension; png is used when the extension is unknown.
void save(const std::string& path) const; void save(const std::string& path) const;
@@ -56,16 +61,28 @@ struct LoadFailure {
}; };
// Lets a caller follow a long batch decode and abort it. Both members are // 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 // touched from the decoding threads, so `onSized` and `onFile` must be
// not block: the GUI only bumps a counter from it and wakes the main thread. // 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 { 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::function<void(std::size_t done, std::size_t total, const std::string& path)> onFile;
std::atomic<bool>* cancel = nullptr; 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. // Loads every path, collecting rather than throwing on individual failures.
// When `progress->cancel` is set the remaining paths are skipped, so the result // When `progress->cancel` is set the remaining paths are skipped, so the result
// is short and should be discarded rather than used. // 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<Image> loadImages(const std::vector<std::string>& paths,
std::vector<LoadFailure>& failures, std::vector<LoadFailure>& failures,
const LoadProgress* progress = nullptr); const LoadProgress* progress = nullptr);
+123
View File
@@ -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
+39
View File
@@ -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
+12
View File
@@ -100,6 +100,17 @@ ParamSpec ParamSpec::makeColor(std::string key, std::string label, std::string d
return spec; 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 { namespace {
const ParamValue* find(const ParamValues& values, const std::string& key) { const ParamValue* find(const ParamValues& values, const std::string& key) {
@@ -121,6 +132,7 @@ ParamValue paramDefault(const ParamSpec& spec) {
case ParamSpec::Type::Path: case ParamSpec::Type::Path:
case ParamSpec::Type::Text: case ParamSpec::Type::Text:
case ParamSpec::Type::Color: case ParamSpec::Type::Color:
case ParamSpec::Type::Font:
break; break;
} }
return spec.defaultChoice; return spec.defaultChoice;
+6 -1
View File
@@ -11,7 +11,7 @@ namespace effector {
// Declarative description of one effect knob. The GUI turns these into widgets // 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. // and the CLI turns them into flags, so neither knows about any effect.
struct ParamSpec { 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; Type type = Type::Int;
std::string key; std::string key;
@@ -56,6 +56,11 @@ struct ParamSpec {
// spelling as a flag value. // spelling as a flag value.
static ParamSpec makeColor(std::string key, std::string label, std::string defaultColor, static ParamSpec makeColor(std::string key, std::string label, std::string defaultColor,
std::string help = {}); 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>; using ParamValue = std::variant<long, double, bool, std::string>;
+76
View File
@@ -319,6 +319,25 @@ int canvasDpi(const Json& value) {
return static_cast<int>(value.number); return static_cast<int>(value.number);
} }
// A clip's length in seconds and its frame rate, checked like the canvas.
double videoDuration(const Json& value) {
if (value.kind != Json::Kind::Number || !(value.number >= kMinDuration) ||
!(value.number <= kMaxDuration)) {
throw EffectError("the video duration must be between " + formatSeconds(kMinDuration) +
" and " + formatSeconds(kMaxDuration));
}
return value.number;
}
int videoFps(const Json& value) {
if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number) ||
!(value.number >= kMinFps) || !(value.number <= kMaxFps)) {
throw EffectError("the video frame rate must be a whole number between " +
std::to_string(kMinFps) + " and " + std::to_string(kMaxFps));
}
return static_cast<int>(value.number);
}
std::string resolve(const std::string& path, const std::string& directory) { std::string resolve(const std::string& path, const std::string& directory) {
std::filesystem::path file(path); std::filesystem::path file(path);
if (file.is_absolute() || directory.empty()) return path; if (file.is_absolute() || directory.empty()) return path;
@@ -347,6 +366,7 @@ void writeParams(std::ostream& out, const std::vector<ParamSpec>& specs, const P
break; break;
case ParamSpec::Type::Choice: case ParamSpec::Type::Choice:
case ParamSpec::Type::Text: case ParamSpec::Type::Text:
case ParamSpec::Type::Font:
out << quote(paramText(values, spec.key, spec.defaultChoice)); out << quote(paramText(values, spec.key, spec.defaultChoice));
break; break;
case ParamSpec::Type::Color: { case ParamSpec::Type::Color: {
@@ -393,6 +413,7 @@ void readParams(const std::vector<ParamSpec>& specs, const Json& params,
} }
break; break;
case ParamSpec::Type::Text: case ParamSpec::Type::Text:
case ParamSpec::Type::Font:
if (value->kind == Json::Kind::String) values[spec.key] = value->text; if (value->kind == Json::Kind::String) values[spec.key] = value->text;
break; break;
case ParamSpec::Type::Color: { 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; if (format->print) out << ", \"dpi\": " << project.dpi;
} }
out << "},\n"; out << "},\n";
// Only a video says anything about time: a still project reads exactly as
// it did before there were videos.
if (project.video.enabled) {
out << " \"video\": {\"duration\": " << number(project.video.duration, 3)
<< ", \"fps\": " << project.video.fps;
if (const auto* preset = videoPresetByKey(project.video.preset)) {
out << ", \"preset\": " << quote(preset->key);
}
// 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"; if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
out << " \"sources\": ["; 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")) { if (const auto* seed = root.member("seed")) {
// Taken from the digits rather than from the double: a 64-bit seed has // Taken from the digits rather than from the double: a 64-bit seed has
// more bits than a double has mantissa, and a seed that comes back off // more bits than a double has mantissa, and a seed that comes back off
@@ -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 } // namespace effector
+8
View File
@@ -9,6 +9,7 @@
#include "core/chain.hpp" #include "core/chain.hpp"
#include "core/params.hpp" #include "core/params.hpp"
#include "core/style.hpp" #include "core/style.hpp"
#include "core/video.hpp"
namespace effector { namespace effector {
@@ -30,6 +31,8 @@ struct Project {
// resolution for. // resolution for.
std::string format; std::string format;
int dpi = kDefaultDpi; int dpi = kDefaultDpi;
// Whether the project is a video, and how long and how fast.
VideoSettings video;
std::optional<std::uint64_t> seed; std::optional<std::uint64_t> seed;
}; };
@@ -51,4 +54,9 @@ Project projectFromJson(const std::string& text, const std::string& directory =
void saveProject(const std::string& path, const Project& project); void saveProject(const std::string& path, const Project& project);
Project loadProject(const std::string& path); 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 } // namespace effector
+19
View File
@@ -4,7 +4,11 @@
#include "effects/blur.hpp" #include "effects/blur.hpp"
#include "effects/channelsplit.hpp" #include "effects/channelsplit.hpp"
#include "effects/corrupt.hpp" #include "effects/corrupt.hpp"
#include "effects/datamosh.hpp"
#include "effects/feedback.hpp" #include "effects/feedback.hpp"
#include "effects/filmgate.hpp"
#include "effects/focuspull.hpp"
#include "effects/flow.hpp"
#include "effects/grain.hpp" #include "effects/grain.hpp"
#include "effects/monochrome.hpp" #include "effects/monochrome.hpp"
#include "effects/overlay.hpp" #include "effects/overlay.hpp"
@@ -12,8 +16,13 @@
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
#include "effects/shatter.hpp" #include "effects/shatter.hpp"
#include "effects/slitshift.hpp" #include "effects/slitshift.hpp"
#include "effects/striproll.hpp"
#include "effects/sweep.hpp"
#include "effects/text.hpp" #include "effects/text.hpp"
#include "effects/tiles.hpp" #include "effects/tiles.hpp"
#include "effects/tileswap.hpp"
#include "effects/tracking.hpp"
#include "effects/tunnel.hpp"
#include "effects/warp.hpp" #include "effects/warp.hpp"
namespace effector { namespace effector {
@@ -36,6 +45,16 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<WarpEffect>(), std::make_shared<WarpEffect>(),
std::make_shared<BitCrushEffect>(), std::make_shared<BitCrushEffect>(),
std::make_shared<FeedbackEffect>(), 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; return registry;
} }
+303
View File
@@ -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
+134
View File
@@ -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
View File
@@ -1,10 +1,9 @@
#include "effects/blur.hpp" #include "effects/blur.hpp"
#include <algorithm> #include <algorithm>
#include <cstring>
#include <vector> #include <vector>
#include "core/parallel.hpp" #include "effects/box_blur.hpp"
#include "effects/source_fill.hpp" #include "effects/source_fill.hpp"
namespace effector { namespace effector {
@@ -23,57 +22,6 @@ const std::vector<ParamSpec>& BlurEffect::params() const {
return specs; 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, Image BlurEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const { const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
@@ -94,17 +42,7 @@ Image BlurEffect::render(const std::vector<Image>& sources, int width, int heigh
fillFromSource(canvas, src, sx, sy); fillFromSource(canvas, src, sx, sy);
} }
Image scratch(width, height); boxBlur(canvas, radius, passes, direction);
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);
}
}
return canvas; return canvas;
} }
+80
View File
@@ -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
View File
@@ -1,10 +1,9 @@
#include "effects/corrupt.hpp" #include "effects/corrupt.hpp"
#include <algorithm> #include <algorithm>
#include <cstring>
#include <vector> #include <vector>
#include "core/parallel.hpp" #include "effects/corrupt_blocks.hpp"
#include "effects/sampling.hpp" #include "effects/sampling.hpp"
#include "effects/source_fill.hpp" #include "effects/source_fill.hpp"
@@ -28,19 +27,6 @@ const std::vector<ParamSpec>& CorruptEffect::params() const {
return specs; 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, Image CorruptEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const { const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
@@ -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 cols = (width + size - 1) / size;
const int rows = (height + 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. // 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); std::vector<Block> plan(static_cast<std::size_t>(cols) * rows);
for (int by = 0; by < rows; ++by) { for (int by = 0; by < rows; ++by) {
@@ -88,54 +57,13 @@ Image CorruptEffect::render(const std::vector<Image>& sources, int width, int he
continue; continue;
} }
if (!chance(rng.next(), rate)) continue; if (!chance(rng.next(), rate)) continue;
block = drawBlock(bx); block = drawDamage(rng, kinds, bx, width, height, size);
if (bleed) carried = &block; if (bleed) carried = &block;
} }
} }
Image canvas = input; Image canvas = input;
// Block rows are independent (a repeat only ever reads to its left), so paintBlocks(canvas, input, plan, cols, rows, size);
// 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;
}
}
}
}
});
return canvas; return canvas;
} }
+97
View File
@@ -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
+135
View File
@@ -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
+28
View File
@@ -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
+168
View File
@@ -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
+28
View File
@@ -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
+118
View File
@@ -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
+28
View File
@@ -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
+108
View File
@@ -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
+27
View File
@@ -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
View File
@@ -4,7 +4,7 @@
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
#include "core/parallel.hpp" #include "effects/grain_field.hpp"
#include "effects/source_fill.hpp" #include "effects/source_fill.hpp"
namespace effector { namespace effector {
@@ -23,14 +23,6 @@ const std::vector<ParamSpec>& GrainEffect::params() const {
return specs; 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, Image GrainEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const { const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
@@ -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 // The whole grain field is drawn serially from the rng, exactly like the
// scanlines plan, and only the pixel arithmetic afterwards is parallel. // scanlines plan, and only the pixel arithmetic afterwards is parallel.
const int cellsX = (width + size - 1) / size; const GrainField field = drawGrain(rng, width, height, size, colour);
const int cellsY = (height + size - 1) / size; applyGrain(canvas, field, amount, midtones);
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));
}
}
}
});
return canvas; return canvas;
} }
+73
View File
@@ -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
+141
View File
@@ -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
+8 -70
View File
@@ -7,6 +7,7 @@
#include "core/parallel.hpp" #include "core/parallel.hpp"
#include "effects/sampling.hpp" #include "effects/sampling.hpp"
#include "effects/sort_runs.hpp"
#include "effects/source_fill.hpp" #include "effects/source_fill.hpp"
namespace effector { namespace effector {
@@ -34,39 +35,6 @@ const std::vector<ParamSpec>& PixelSortEffect::params() const {
namespace { 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 { struct LinePlan {
bool sorted = false; bool sorted = false;
std::uint64_t seed = 0; // for the run lengths, when they are limited 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(); if (maxRun > 0) line.seed = rng.next();
} }
auto keyOf = [&](const std::uint8_t* px) { SortRule rule;
if (keyName == "hue") return hue(px); rule.low = low;
if (keyName == "saturation") return saturation(px); rule.high = high;
return luma(px); 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) { 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; std::vector<std::pair<int, std::array<std::uint8_t, 3>>> run;
for (std::size_t line = begin; line < end; ++line) { for (std::size_t line = begin; line < end; ++line) {
if (!plan[line].sorted) continue; if (!plan[line].sorted) continue;
Rng runRng(plan[line].seed); Rng runRng(plan[line].seed);
std::uint8_t* row = canvas.row(static_cast<int>(line)); sortLine(canvas.row(static_cast<int>(line)), span, rule, runRng, 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 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);
} }
}); });
+115
View File
@@ -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
+143
View File
@@ -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
+31
View File
@@ -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
+115
View File
@@ -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
+29
View File
@@ -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
+1 -1
View File
@@ -16,7 +16,7 @@ const std::vector<ParamSpec>& TextEffect::params() const {
static const std::vector<ParamSpec> specs = { static const std::vector<ParamSpec> specs = {
ParamSpec::makeText("text", "Text", "Effector", ParamSpec::makeText("text", "Text", "Effector",
"What to write; a line break starts a new line."), "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, " "Font family, as it is named on this machine: Sans, Serif, Monospace, "
"\"DejaVu Serif\", ...") "\"DejaVu Serif\", ...")
.styled(), .styled(),
+87
View File
@@ -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
View File
@@ -1,11 +1,10 @@
#include "effects/tiles.hpp" #include "effects/tiles.hpp"
#include <algorithm> #include <algorithm>
#include <cstring>
#include <vector> #include <vector>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp" #include "effects/source_fill.hpp"
#include "effects/tile_plan.hpp"
namespace effector { namespace effector {
@@ -29,66 +28,6 @@ const std::vector<ParamSpec>& TilesEffect::params() const {
return specs; 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, Image TilesEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const { const ParamValues& values) const {
if (sources.empty()) throw EffectError("No source images."); 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") { paintTiles(canvas, sources, plan, 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]);
}
});
}
return canvas; return canvas;
} }
+158
View File
@@ -0,0 +1,158 @@
#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, 30.0,
"Swaps per second of each tile, rounded so that the clip loops "
"without a jump; 0 for none.",
0.1, 1),
};
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, and
// the seed its cuts come from.
struct Cell {
int x;
int y;
int w;
int h;
double phase;
std::uint64_t seed;
};
} // 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});
};
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();
}
// Where this frame is in time: each tile has swapped a whole number of
// times, counted round the clip so that the frame after the last shows
// what the first does.
long swaps = 0;
double perSecond = 0.0;
if (settings.rate > 0.0) {
perSecond = at.loopRate(settings.rate);
swaps = std::max(1L, std::lround(perSecond * at.length()));
}
// What a tile shows depends only on the tile and its swap, 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];
std::uint64_t turn = 0;
if (swaps > 0) {
const auto passed = static_cast<long>(std::floor(at.seconds() * perSecond + 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
+31
View File
@@ -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
+147
View File
@@ -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
+29
View File
@@ -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
+136
View File
@@ -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
+30
View File
@@ -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
+66
View File
@@ -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
+90
View File
@@ -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
View File
@@ -5,6 +5,7 @@
#include <vector> #include <vector>
#include "core/parallel.hpp" #include "core/parallel.hpp"
#include "effects/noise_field.hpp"
#include "effects/sampling.hpp" #include "effects/sampling.hpp"
#include "effects/source_fill.hpp" #include "effects/source_fill.hpp"
@@ -26,88 +27,6 @@ const std::vector<ParamSpec>& WarpEffect::params() const {
return specs; 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, Image WarpEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const { const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
@@ -119,11 +38,8 @@ Image WarpEffect::render(const std::vector<Image>& sources, int width, int heigh
const Image input = canvasFromSources(sources, width, height, rng); const Image input = canvasFromSources(sources, width, height, rng);
// Lattices are drawn in full before any pixel moves: one field for the // The lattices are drawn in full before any pixel moves.
// swirl, which flows along its contours, two for a push along x and y. const WarpField field(rng, width, height, scale, octaves, swirl);
const NoiseField first(rng, width, height, scale, octaves);
std::vector<NoiseField> extra;
if (!swirl) extra.emplace_back(rng, width, height, scale, octaves);
Image canvas(width, height); Image canvas(width, height);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) { parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
@@ -132,22 +48,9 @@ Image WarpEffect::render(const std::vector<Image>& sources, int width, int heigh
const double y = static_cast<double>(row); const double y = static_cast<double>(row);
for (int col = 0; col < width; ++col) { for (int col = 0; col < width; ++col) {
const double x = 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 dx = 0.0;
double dy = 0.0; double dy = 0.0;
if (swirl) { field.push(x, y, strength, dx, dy);
// 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;
}
sampleBilinear(input, x + dx, y + dy, out + static_cast<std::size_t>(col) * 3); sampleBilinear(input, x + dx, y + dy, out + static_cast<std::size_t>(col) * 3);
} }
} }
+8
View File
@@ -13,6 +13,14 @@ int runGui(const Options& initial, int argc, char** argv) {
auto app = Gtk::Application::create("org.effector.Effector", auto app = Gtk::Application::create("org.effector.Effector",
Gio::Application::Flags::NON_UNIQUE); Gio::Application::Flags::NON_UNIQUE);
app->signal_startup().connect([] {
// An installed Effector finds its icon in the hicolor theme; one run
// straight from the build tree falls back to the copy in the sources.
const char* icon = "org.effector.Effector";
auto theme = Gtk::IconTheme::get_for_display(Gdk::Display::get_default());
if (!theme->has_icon(icon)) theme->add_search_path(EFFECTOR_SOURCE_ICON_DIR);
Gtk::Window::set_default_icon_name(icon);
});
return app->make_window_and_run<EffectorWindow>(0, nullptr, initial); return app->make_window_and_run<EffectorWindow>(0, nullptr, initial);
} }
+913 -33
View File
File diff suppressed because it is too large Load Diff
+123 -1
View File
@@ -10,6 +10,7 @@
#include <functional> #include <functional>
#include <string> #include <string>
#include <thread> #include <thread>
#include <utility>
#include <vector> #include <vector>
#include "cli/options.hpp" #include "cli/options.hpp"
@@ -18,6 +19,7 @@
#include "core/image.hpp" #include "core/image.hpp"
#include "core/project.hpp" #include "core/project.hpp"
#include "core/style.hpp" #include "core/style.hpp"
#include "core/video.hpp"
namespace effector { namespace effector {
@@ -68,7 +70,7 @@ private:
void onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result, void onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog); const Glib::RefPtr<Gtk::FileDialog>& dialog);
void addPaths(const std::vector<std::string>& paths); // decodes in the background 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 onLoadFinished(); // dispatcher: the batch is done or was cancelled
void showLoadWindow(); void showLoadWindow();
void closeLoadWindow(); void closeLoadWindow();
@@ -87,8 +89,38 @@ private:
void syncCanvasWidgets(); void syncCanvasWidgets();
void removeSource(Gtk::ListBoxRow* row); void removeSource(Gtk::ListBoxRow* row);
// Video: a switch that makes the project a clip, and its length, rate and
// preset. A preset owns the canvas format as well as the timing; changing
// any of them by hand drops it back to "Custom".
Gtk::Widget& buildVideoSection();
VideoSettings videoSettings() const;
void setVideoSettings(const VideoSettings& video); // quietly, for a project
void onVideoToggled();
void onVideoPresetSelected();
void onVideoTimingChanged();
void matchVideoPreset(); // "Custom" once the settings are no longer the preset's
bool isVideo() const { return videoSwitch_.get_active(); }
// The preview of a video is the whole clip, rendered first and then looped.
void onClipProgress(); // dispatcher: frames arrived from the worker
void 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& buildChainSection();
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended 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 appendStage(const EffectPtr& effect);
void removeStage(int index); void removeStage(int index);
void moveStage(int from, int to); // `to` is the index it ends up at 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 writeProject(const std::string& path);
void updateTitle(); 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(); void onParamChanged();
// The chooser behind a Path param. The chosen file goes straight into the // 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 // stage's values rather than into the widget, which may well have been
@@ -174,22 +226,43 @@ private:
Image result_; Image result_;
std::uint64_t seed_ = 0; std::uint64_t seed_ = 0;
std::string projectPath_; // empty until the project has been saved once 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_; Glib::RefPtr<Gio::Menu> recentMenu_;
sigc::connection recentChanged_; // the recent list outlives the window sigc::connection recentChanged_; // the recent list outlives the window
bool suspendRender_ = true; 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 // Source loading. Decoding a batch of large photographs takes seconds, so
// it runs off the main thread and reports into a small progress window. // it runs off the main thread and reports into a small progress window.
std::thread loader_; std::thread loader_;
Glib::Dispatcher loadProgress_; Glib::Dispatcher loadProgress_;
Glib::Dispatcher loadFinished_; Glib::Dispatcher loadFinished_;
std::atomic<bool> loadCancel_{false}; 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::atomic<std::size_t> loadDone_{0};
std::size_t loadTotal_ = 0; std::size_t loadTotal_ = 0;
std::mutex loadMutex_; std::mutex loadMutex_;
std::string loadCurrent_; std::string loadCurrent_;
std::vector<Image> loadedImages_; std::vector<Image> loadedImages_;
std::vector<LoadFailure> loadFailures_; 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 std::vector<std::string> loadQueue_; // chosen while another batch was loading
bool loading_ = false; bool loading_ = false;
bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project
@@ -205,6 +278,40 @@ private:
Image pendingImage_; Image pendingImage_;
std::string pendingError_; std::string pendingError_;
double pendingMillis_ = 0.0; double pendingMillis_ = 0.0;
// Set to stop a clip render early once its settings are out of date.
std::atomic<bool> renderCancel_{false};
bool renderingVideo_ = false; // what the render in flight is making
// The clip being previewed: small frames, ready to show, and the frames
// of the clip being rendered, 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 // Widgets
Gtk::Label banner_; Gtk::Label banner_;
@@ -224,6 +331,13 @@ private:
Gtk::SpinButton heightSpin_; Gtk::SpinButton heightSpin_;
Gtk::SpinButton dpiSpin_; Gtk::SpinButton dpiSpin_;
Gtk::Label* dpiLabel_ = nullptr; // hidden along with the dpi spin Gtk::Label* dpiLabel_ = nullptr; // hidden along with the dpi spin
Gtk::Switch videoSwitch_;
Gtk::Box videoDetails_; // hidden while the project is a still
Gtk::DropDown videoPresetDrop_;
Gtk::SpinButton durationSpin_;
Gtk::SpinButton fpsSpin_;
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 // Set while a format fills the spins, so their own handler does not queue
// a second render on top of the one the format change asks for. // a second render on top of the one the format change asks for.
bool suspendCanvasSync_ = false; bool suspendCanvasSync_ = false;
@@ -237,6 +351,10 @@ private:
Gtk::Entry seedEntry_; Gtk::Entry seedEntry_;
Gtk::ListBox chainList_; Gtk::ListBox chainList_;
Gtk::MenuButton addEffectButton_; 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::MenuButton menuButton_;
Gtk::Button recreateButton_{"Recreate"}; Gtk::Button recreateButton_{"Recreate"};
Gtk::Button saveButton_{"Save As…"}; Gtk::Button saveButton_{"Save As…"};
@@ -245,6 +363,10 @@ private:
Gtk::Label placeholder_; Gtk::Label placeholder_;
Gtk::Spinner spinner_; Gtk::Spinner spinner_;
Gtk::Label status_; Gtk::Label status_;
Gtk::Box playbackBar_; // under the preview of a video only
Gtk::Button playButton_;
Gtk::Scale playbackScale_;
Gtk::Label playbackTime_;
struct ParamWidget { struct ParamWidget {
ParamSpec spec; ParamSpec spec;
+65 -3
View File
@@ -13,12 +13,56 @@
#include "core/image.hpp" #include "core/image.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
#include "core/rng.hpp" #include "core/rng.hpp"
#include "core/video.hpp"
#include "core/version.hpp" #include "core/version.hpp"
#include "gui/app.hpp" #include "gui/app.hpp"
namespace { namespace {
int renderVideo(const effector::Options& options, const std::vector<effector::ChainStage>& stages,
const std::vector<effector::Image>& sources, std::uint64_t seed) {
const int count = options.video.frameCount();
const bool showProgress = isatty(fileno(stderr)) != 0;
const auto start = std::chrono::steady_clock::now();
effector::VideoWriter writer(options.output, options.width, options.height, options.video.fps,
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) { int renderHeadless(const effector::Options& options) {
// Checked before anything is decoded: a mismatch between what was asked
// for and the file named is a typo, not something to find out later.
const bool videoFile = effector::isVideoPath(options.output);
if (options.video.enabled && !videoFile) {
std::cerr << "effector: a video is written as .mp4, .mov, .mkv, .webm or .gif, not "
<< options.output << "\n";
return 2;
}
if (!options.video.enabled && videoFile) {
std::cerr << "effector: " << options.output
<< " is a video; give its length with --duration or --video-preset\n";
return 2;
}
auto stages = effector::buildChain(options); auto stages = effector::buildChain(options);
// A headless run always renders something, so with no -e the default // A headless run always renders something, so with no -e the default
// effect stands in. The interface has no such need: it opens on an empty // effect stands in. The interface has no such need: it opens on an empty
@@ -28,6 +72,17 @@ int renderHeadless(const effector::Options& options) {
stages.push_back({effect, effect->defaults()}); 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; std::vector<effector::LoadFailure> failures;
// Decoding a batch of photographs takes seconds; on a terminal keep a // 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; std::mutex progressMutex;
const bool showProgress = isatty(fileno(stderr)) != 0 && options.images.size() > 1; const bool showProgress = isatty(fileno(stderr)) != 0 && options.images.size() > 1;
if (showProgress) { 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) { progress.onFile = [&](std::size_t done, std::size_t total, const std::string& path) {
std::lock_guard<std::mutex> lock(progressMutex); std::lock_guard<std::mutex> lock(progressMutex);
std::cerr << "\r\033[Kreading " << done << "/" << total << " " std::cerr << "\r\033[Kreading " << done << "/" << total << " "
@@ -48,12 +108,13 @@ int renderHeadless(const effector::Options& options) {
for (const auto& failure : failures) { for (const auto& failure : failures) {
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n"; 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"; std::cerr << "effector: no usable source images\n";
return 2; return 2;
} }
const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed()); const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed());
if (options.video.enabled) return renderVideo(options, stages, sources, seed);
effector::Rng rng(seed); effector::Rng rng(seed);
const auto start = std::chrono::steady_clock::now(); const auto start = std::chrono::steady_clock::now();
@@ -64,7 +125,7 @@ int renderHeadless(const effector::Options& options) {
image.save(options.output); image.save(options.output);
std::cout << options.output << " " << options.width << "x" << options.height 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"; << std::fixed << std::setprecision(1) << renderMillis << " ms\n";
return 0; return 0;
} }
@@ -85,7 +146,8 @@ int main(int argc, char** argv) {
} }
if (options.listEffects) { if (options.listEffects) {
for (const auto& effect : effector::effects()) { 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"; << effect->description() << "\n";
} }
return 0; return 0;
+2
View File
@@ -3,6 +3,8 @@
add_executable(effector_tests core_tests.cpp ../src/cli/options.cpp) add_executable(effector_tests core_tests.cpp ../src/cli/options.cpp)
target_link_libraries(effector_tests PRIVATE effector_core) target_link_libraries(effector_tests PRIVATE effector_core)
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
# The version test reads the Arch PKGBUILD to keep its pkgver in step.
target_compile_definitions(effector_tests PRIVATE EFFECTOR_SOURCE_DIR="${PROJECT_SOURCE_DIR}")
add_test(NAME core COMMAND effector_tests) add_test(NAME core COMMAND effector_tests)
+699 -9
View File
@@ -20,18 +20,22 @@
#include "cli/options.hpp" #include "cli/options.hpp"
#include "core/canvas.hpp" #include "core/canvas.hpp"
#include "core/chain.hpp" #include "core/chain.hpp"
#include "core/memory.hpp"
#include "core/project.hpp" #include "core/project.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
#include "core/rng.hpp" #include "core/rng.hpp"
#include "core/style.hpp" #include "core/style.hpp"
#include "core/version.hpp" #include "core/version.hpp"
#include "core/video.hpp"
#include "effects/blur.hpp" #include "effects/blur.hpp"
#include "effects/channelsplit.hpp"
#include "effects/grain.hpp" #include "effects/grain.hpp"
#include "effects/monochrome.hpp" #include "effects/monochrome.hpp"
#include "effects/overlay.hpp" #include "effects/overlay.hpp"
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
#include "effects/text.hpp" #include "effects/text.hpp"
#include "effects/tiles.hpp" #include "effects/tiles.hpp"
#include "effects/video_effect.hpp"
namespace { namespace {
@@ -1065,7 +1069,8 @@ int main() {
check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a), check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a),
renderChain({scanlines, stage}, sources, 300, 200, b)), renderChain({scanlines, stage}, sources, 300, 200, b)),
name + " cascades reproducibly"); 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. // A canvas one pixel wide or high must not trip any of them up.
check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 && check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 &&
@@ -1192,8 +1197,8 @@ int main() {
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward); const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image"); check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
check(chainTitle({scanlines, blur}) == "Scanlines \u2192 Blur", "the chain has a readable title"); check(chainTitle({scanlines, blur}, false) == "Scanlines \u2192 Blur", "the chain has a readable title");
check(chainSlug({scanlines, blur}) == "scanlines-blur", "the chain has a file name slug"); check(chainSlug({scanlines, blur}, false) == "scanlines-blur", "the chain has a file name slug");
bool threw = false; bool threw = false;
try { try {
@@ -1208,10 +1213,10 @@ int main() {
// them, blur only softens what it is handed and runs without any. // them, blur only softens what it is handed and runs without any.
check(effectByName("scanlines")->needsSources(), "scanlines needs source images"); check(effectByName("scanlines")->needsSources(), "scanlines needs source images");
check(!effectByName("blur")->needsSources(), "blur needs no source images"); check(!effectByName("blur")->needsSources(), "blur needs no source images");
check(chainNeedsSources({scanlines, blur}), "a chain led by scanlines needs sources"); check(chainNeedsSources({scanlines, blur}, false), "a chain led by scanlines needs sources");
check(!chainNeedsSources({blur, scanlines}), check(!chainNeedsSources({blur, scanlines}, false),
"a chain led by blur needs none, later stages see the stage before them"); "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); Rng bare(5);
const auto sourceless = renderChain({blur}, {}, 200, 150, bare); const auto sourceless = renderChain({blur}, {}, 200, 150, bare);
@@ -1238,12 +1243,12 @@ int main() {
Rng skipping(5); Rng skipping(5);
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping); 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(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(chainTitle({scanlines, blurOff}, false) == "Scanlines", "the title names only what ran");
check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug"); check(chainSlug({scanlines, blurOff}, false) == "scanlines", "and so does the slug");
// Whoever leads the chain is handed the sources, and that is the first // Whoever leads the chain is handed the sources, and that is the first
// stage still switched on. // 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"); "a disabled leading stage leaves the question to the stage behind it");
Rng leading(5); Rng leading(5);
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading); const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
@@ -1276,6 +1281,21 @@ int main() {
{ {
const auto dir = std::filesystem::temp_directory_path() / "effector_test_project"; const auto dir = std::filesystem::temp_directory_path() / "effector_test_project";
std::filesystem::create_directories(dir); 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"; const auto source = dir / "source.png";
gradient(64, 48, 7).save(source.string()); gradient(64, 48, 7).save(source.string());
@@ -1688,9 +1708,16 @@ int main() {
paths.push_back((dir / "missing.png").string()); paths.push_back((dir / "missing.png").string());
std::atomic<std::size_t> calls{0}; 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::size_t lastTotal = 0;
std::mutex mutex; std::mutex mutex;
LoadProgress progress; 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&) { progress.onFile = [&](std::size_t done, std::size_t total, const std::string&) {
std::lock_guard<std::mutex> lock(mutex); std::lock_guard<std::mutex> lock(mutex);
++calls; ++calls;
@@ -1703,6 +1730,8 @@ int main() {
check(images.size() == 6, "every readable image is returned"); check(images.size() == 6, "every readable image is returned");
check(failuresOut.size() == 1, "the unreadable one is reported"); check(failuresOut.size() == 1, "the unreadable one is reported");
check(calls.load() == paths.size(), "progress fires once per file"); 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(lastTotal == paths.size(), "progress reports the batch size");
check(images.front().path() == paths.front(), "input order is preserved"); check(images.front().path() == paths.front(), "input order is preserved");
@@ -1716,6 +1745,660 @@ int main() {
std::filesystem::remove_all(dir); 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");
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"; std::cout << "version\n";
{ {
// A release is the version in CMakeLists.txt and its entry at the top // A release is the version in CMakeLists.txt and its entry at the top
@@ -1725,6 +2408,13 @@ int main() {
check(newest != std::string_view::npos && check(newest != std::string_view::npos &&
log.substr(newest + 5).starts_with(std::string(kVersion) + "]"), log.substr(newest + 5).starts_with(std::string(kVersion) + "]"),
"the newest changelog entry is this version"); "the newest changelog entry is this version");
std::ifstream pkgbuild(std::string(EFFECTOR_SOURCE_DIR) + "/packaging/arch/PKGBUILD");
std::string line;
std::string pkgver;
while (std::getline(pkgbuild, line)) {
if (line.starts_with("pkgver=")) pkgver = line.substr(7);
}
check(pkgver == kVersion, "the Arch PKGBUILD packages this version");
const auto options = parsedOptions({"--version"}); const auto options = parsedOptions({"--version"});
check(options && options->showVersion, "--version is understood"); check(options && options->showVersion, "--version is understood");
const auto shortOptions = parsedOptions({"-V"}); const auto shortOptions = parsedOptions({"-V"});
+365 -8
View File
@@ -14,6 +14,8 @@
#include <vector> #include <vector>
#include "cli/options.hpp" #include "cli/options.hpp"
#include "core/image.hpp"
#include "core/memory.hpp"
#include "core/project.hpp" #include "core/project.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
#include "core/version.hpp" #include "core/version.hpp"
@@ -107,6 +109,14 @@ Gtk::Button* menuEntry(Gtk::Popover& menu, const std::string& title) {
return nullptr; 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 // 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. // quarter of a second before the button reports the click, so wait it out.
void press(Gtk::Button& button) { void press(Gtk::Button& button) {
@@ -151,6 +161,11 @@ void dropStage(GtkDropTarget* drop, int index, double y) {
void runTests(Gtk::Window& window) { void runTests(Gtk::Window& window) {
const auto& effects = effector::effects(); 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"; std::cout << "sidebar\n";
auto* stack = all<Gtk::Stack>(window).empty() ? nullptr : all<Gtk::Stack>(window).front(); auto* stack = all<Gtk::Stack>(window).empty() ? nullptr : all<Gtk::Stack>(window).front();
@@ -193,6 +208,19 @@ void runTests(Gtk::Window& window) {
for (const auto& effect : effects) listed = listed && menuEntry(*menu, effect->title()) != nullptr; for (const auto& effect : effects) listed = listed && menuEntry(*menu, effect->title()) != nullptr;
check(listed, "the menu has an entry for every registered effect"); check(listed, "the menu has an entry for every registered effect");
check(all<Gtk::Button>(*menu).size() == effects.size(), "and nothing else"); 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(); add->popup();
pump(); pump();
@@ -202,12 +230,21 @@ void runTests(Gtk::Window& window) {
// there were enough effects: the entries have to scroll instead, in a // 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 // menu short enough to open below a button near the top of a 768-pixel
// screen. // 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 auto scrollers = all<Gtk::ScrolledWindow>(*menu);
const int cap = scrollers.empty() ? -1 : scrollers.front()->get_max_content_height(); int cap = 0;
check(!scrollers.empty() && isAncestor(*scrollers.front(), *menuEntry(*menu, effects.front()->title())), for (auto* scroller : scrollers) cap += scroller->get_max_content_height();
"the entries scroll"); 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(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"); "and the menu stays within it however many effects there are");
add->popdown(); add->popdown();
pump(); pump();
@@ -304,15 +341,19 @@ void runTests(Gtk::Window& window) {
} }
std::cout << "many stages\n"; 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(); pump();
const int fewSplit = split != nullptr ? split->get_position() : -1; const int fewSplit = split != nullptr ? split->get_position() : -1;
check(split != nullptr && fewSplit > emptySplit, check(split != nullptr && fewSplit > emptySplit,
"the split moves down as the chain grows"); "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(rowCount(*chainList) == 13, "the menu still works with a long chain");
check(addFromMenu(*add, effects.back()->title()) && rowCount(*chainList) == 14, check(addFromMenu(*add, imageEffects.back()->title()) && rowCount(*chainList) == 14,
"including for the last effect in the menu"); "including for the last image effect in the menu");
pump(); pump();
const int longSplit = split != nullptr ? split->get_position() : -1; const int longSplit = split != nullptr ? split->get_position() : -1;
check(longSplit > fewSplit && listScroller != nullptr && check(longSplit > fewSplit && listScroller != nullptr &&
@@ -440,6 +481,318 @@ void testRecentProjects(Gtk::Window& window, const std::filesystem::path& dir) {
"opened projects are listed newest first, a shared name with its folder"); "opened projects are listed newest first, a shared name with its folder");
} }
// Visible labels in the params grid saying `text`.
bool showsLabel(Gtk::Widget& root, const std::string& text) {
for (auto* label : all<Gtk::Label>(root)) {
if (label->get_text() == text && label->is_drawable()) return true;
}
return false;
}
void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
std::cout << "video\n";
std::filesystem::create_directories(dir);
const auto path = dir / "moving.json";
effector::Project project;
// 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");
}
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 } // namespace
int main(int argc, char** argv) { int main(int argc, char** argv) {
@@ -467,6 +820,10 @@ int main(int argc, char** argv) {
runTests(*window); runTests(*window);
testHelpWindows(*window); testHelpWindows(*window);
testRecentProjects(*window, dataDir / "projects"); testRecentProjects(*window, dataDir / "projects");
testVideo(*window, dataDir / "video");
testFont(*window, dataDir / "font");
testHistory(*window, dataDir / "history");
testMemoryGuard(*window, dataDir / "memory");
app->remove_window(*window); app->remove_window(*window);
}); });
const int status = app->run(argc > 0 ? 1 : 0, argv); const int status = app->run(argc > 0 ? 1 : 0, argv);