17 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
60 changed files with 4340 additions and 888 deletions
+104
View File
@@ -4,6 +4,110 @@ 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 ## [0.6.0] - 2026-09-26
### Added ### Added
+69 -16
View File
@@ -73,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`.
@@ -95,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.
@@ -172,6 +175,16 @@ 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 ### Video projects
A project is a still or a clip: `VideoSettings` (`src/core/video.hpp`: enabled, A project is a still or a clip: `VideoSettings` (`src/core/video.hpp`: enabled,
@@ -181,22 +194,47 @@ 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 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. duration and fps. Adding a preset is a line there.
Time reaches an effect as a `FrameTime` (`src/core/effect.hpp`; the default, Effects are of two kinds, and `Effect::video()` says which. An *image effect*
`fps == 0`, is a still) through `Effect::renderFrame`, which falls back to (the default) makes a picture and holds still in a clip; it overrides only
`render` — an effect that does not move overrides nothing. One that moves `render`, which `Effect::renderFrame` falls back to. A *video effect* is motion
overrides `renderFrame` and `animated(values)`, and marks its motion params and nothing else. `enabledStages(stages, video)` is where the kinds meet the
`.forVideo()`: the interface hides those in a still project (declaratively, chain: in a still, a video stage is left out exactly like a disabled one,
like `.styled()`), the CLI still has their flags. A still must render byte for drawing nothing from the rng. So `renderChain` (by `frame.video()`),
byte what it did before the effect learned to move. `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 `renderFrames` (`src/core/chain.cpp`) renders a clip. Every frame starts from
`Rng(seed)`, so a stage that does not move draws the same values every frame `Rng(seed)`, so an image effect draws the same values every frame and holds
and holds still. A moving effect must therefore leave the shared rng in the still, and because `VideoEffect` leaves the shared rng in one state, the stages
same state on every frame — the channel split draws its per-frame shake from a after a video effect hold still too. The stages before the first video effect
generator of its own, seeded by one draw — or everything after it would flicker. are rendered once and their image and rng state reused. The `video` tests
The stages before the first `animated()` one are rendered once and their image assert that frame k is exactly `renderChain(..., FrameTime{k, ...})`, so that
and rng state reused; the `video` tests assert that frame k is exactly shortcut can never change output.
`renderChain(..., FrameTime{k, ...})`, so that shortcut can never change output.
The determinism contract covers the frames; the encoded file is whatever the The determinism contract covers the frames; the encoded file is whatever the
machine's ffmpeg makes of them. machine's ffmpeg makes of them.
@@ -261,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
+12 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(effector VERSION 0.6.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,6 +35,7 @@ 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
@@ -45,7 +46,11 @@ add_library(effector_core STATIC
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
@@ -53,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)
+14 -9
View File
@@ -73,18 +73,23 @@ installed: `.mp4`, `.mov` and `.mkv` as H.264, `.webm` as VP9, `.gif` as a
palette GIF. palette GIF.
```bash ```bash
build/effector -o story.mp4 --video-preset instagram-story -e channelsplit --seed 1 photo.jpg 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 channelsplit 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 `--duration SECONDS` and `--fps N` make any run a video; a preset fills in
whatever was not given alongside it. Effects that move over time are video whatever was not given alongside it.
effects; for now that is **Channel split**, which in a video sways its
channels, shakes them from frame to frame and rolls a tracking bar down the Effects come in two kinds. **Image effects** make a picture and hold still in a
picture, like a worn tape on an old tube. Its motion params only show in a clip. **Video effects** are motion: the Add effect menu lists them under a
video project, and a still renders exactly as before. Every frame starts from heading of their own, and they can only be added once the project is a video.
the same seed, so stages that do not move hold still while one that does In a still project a video stage is marked as skipped and the render leaves it
moves, and a clip renders frame for frame the same again. 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).
+1 -1
View File
@@ -2,7 +2,7 @@
# so pkgver must follow the project() line of CMakeLists.txt (the version core # so pkgver must follow the project() line of CMakeLists.txt (the version core
# test checks it). From this folder: makepkg -si # test checks it). From this folder: makepkg -si
pkgname=effector pkgname=effector
pkgver=0.6.0 pkgver=0.8.1
pkgrel=1 pkgrel=1
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects' pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+25 -3
View File
@@ -133,6 +133,12 @@ Options parseOptions(int argc, char** argv) {
} }
options.video.enabled = true; options.video.enabled = true;
options.video.preset = value; 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
@@ -176,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: {
@@ -241,6 +248,8 @@ std::string usage(const char* programName) {
<< " --fps N frames per second of a video, " << kMinFps << "-" << kMaxFps << " --fps N frames per second of a video, " << kMinFps << "-" << kMaxFps
<< " (default: " << kDefaultFps << ")\n" << " (default: " << kDefaultFps << ")\n"
<< " --video-preset NAME a video's canvas and length in one (see below)\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"
@@ -263,16 +272,29 @@ std::string usage(const char* programName) {
if (preset.key.size() < 20) out << std::string(20 - preset.key.size(), ' '); if (preset.key.size() < 20) out << std::string(20 - preset.key.size(), ' ');
out << " " << preset.label << ", " << videoPresetDetail(preset) << "\n"; 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";
out << " " << flag; out << " " << flag;
if (flag.size() < 20) out << std::string(20 - flag.size(), ' '); if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
out << " " << (spec.help.empty() ? spec.label : spec.help) out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n";
<< (spec.videoOnly ? " (video only)" : "") << "\n"; }
} }
} }
return out.str(); return out.str();
+76 -33
View File
@@ -2,13 +2,17 @@
#include <algorithm> #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;
} }
@@ -34,7 +38,7 @@ ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
namespace { namespace {
// The stages that run, checked: throws for a chain with nothing to render. // The stages that run, checked: throws for a chain with nothing to render.
std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stages) { 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.");
@@ -42,12 +46,42 @@ std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stag
// 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
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; return running;
} }
// Throws OutOfMemoryError for a render that would not fit. A render holds a
// few canvases at once -- the stage before, the one being drawn, the input
// a transform copies -- and the file it is saved to; 13 bytes a pixel is
// what the heaviest chains were measured at, and the interface keeps one
// more copy to show, so 24 leaves room. A picture a stage loads itself (an
// overlay, a text box's picture) comes on top, with its alpha and pixbuf.
void ensureRenderFits(const std::vector<const ChainStage*>& running, int width, int height,
const StyleSet& styles) {
std::size_t needed = static_cast<std::size_t>(std::max(width, 0)) *
static_cast<std::size_t>(std::max(height, 0)) * 24;
for (const auto* stage : running) {
const auto values = stageValues(*stage, styles);
for (const auto& spec : stage->effect->params()) {
if (spec.type != ParamSpec::Type::Path) continue;
const std::string path = paramText(values, spec.key, "");
if (!path.empty()) needed += imageFilePixels(path) * 8;
}
}
ensureMemory(needed, "Rendering a " + std::to_string(width) + " × " + std::to_string(height) +
" canvas");
}
// Runs running[first, last) on `image`, which holds the sources' place for // 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. // 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, Image runStages(const std::vector<const ChainStage*>& running, std::size_t first,
@@ -75,69 +109,78 @@ Image runStages(const std::vector<const ChainStage*>& running, std::size_t first
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) { const FrameTime& frame) {
const auto running = runningStages(stages); const auto running = runningStages(stages, frame.video());
ensureRenderFits(running, width, height, styles);
return runStages(running, 0, running.size(), sources, Image(), width, height, rng, styles, return runStages(running, 0, running.size(), sources, Image(), width, height, rng, styles,
frame); frame);
} }
bool chainAnimated(const std::vector<ChainStage>& stages, const StyleSet& styles) {
for (const auto* stage : enabledStages(stages)) {
if (stage->effect->animated(stageValues(*stage, styles))) return true;
}
return false;
}
int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources, int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, std::uint64_t seed, const StyleSet& styles, int width, int height, std::uint64_t seed, const StyleSet& styles,
const VideoSettings& video, const VideoSettings& video,
const std::function<bool(int index, Image&& frame)>& onFrame) { const std::function<bool(int index, Image&& frame)>& onFrame,
const auto running = runningStages(stages); const std::vector<int>& order) {
const auto running = runningStages(stages, true);
ensureRenderFits(running, width, height, styles);
const int count = video.frameCount(); const int count = video.frameCount();
const int fps = std::clamp(video.fps, kMinFps, kMaxFps); const int fps = std::clamp(video.fps, kMinFps, kMaxFps);
// Everything before the first stage that moves is the same on every // Everything before the first video effect is the same on every frame --
// frame -- the same seed, the same values, no time -- so it is rendered // the same seed, the same values, no time -- so it is rendered once, as
// once, as frame 0, and picked up from there. The rng is copied along // frame 0, and picked up from there. The rng is copied along with the
// with the picture, which is what makes the shortcut exact. // picture, which is what makes the shortcut exact.
std::size_t still = 0; std::size_t still = 0;
while (still < running.size() && while (still < running.size() && !running[still]->effect->video()) ++still;
!running[still]->effect->animated(stageValues(*running[still], styles))) {
++still;
}
Rng prefixRng(seed); Rng prefixRng(seed);
const Image prefix = runStages(running, 0, still, sources, Image(), width, height, prefixRng, const Image prefix = runStages(running, 0, still, sources, Image(), width, height, prefixRng,
styles, FrameTime{0, count, fps}); styles, FrameTime{0, count, fps});
for (int index = 0; index < count; ++index) { 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}; const FrameTime frame{index, count, fps};
Rng rng = prefixRng; Rng rng = prefixRng;
Image image = still == running.size() Image image = still == running.size()
? prefix ? prefix
: runStages(running, still, running.size(), sources, prefix, width, : runStages(running, still, running.size(), sources, prefix, width,
height, rng, styles, frame); height, rng, styles, frame);
if (!onFrame(index, std::move(image))) return index + 1; ++handed;
if (!onFrame(index, std::move(image))) break;
} }
return count; return handed;
} }
bool chainNeedsSources(const std::vector<ChainStage>& stages) { std::vector<int> draftFirstOrder(int count, int step) {
const auto running = enabledStages(stages); 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();
} }
+25 -16
View File
@@ -40,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
@@ -55,38 +56,46 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
// `styles` is the project's pool; a chain built without one -- the command // `styles` is the project's pool; a chain built without one -- the command
// line's -- passes none and renders from the stages alone. // line's -- passes none and renders from the stages alone.
// //
// `frame` says where in a video the render sits, a still by default; every // `frame` says where in a video the render sits, a still by default, in
// stage is handed it, and only one that moves makes anything of it. // 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 = {}); const FrameTime& frame = {});
// Whether any stage that runs moves over time with the values it runs with.
bool chainAnimated(const std::vector<ChainStage>& stages, const StyleSet& styles = {});
// Renders every frame of a video, in order, handing each to `onFrame`; it // Renders every frame of a video, in order, handing each to `onFrame`; it
// returning false stops the render there. Frame k is exactly what // returning false stops the render there. Frame k is exactly what
// renderChain gives with a fresh Rng(seed) and FrameTime{k, count, fps} -- // 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 // every frame starts from the same seed, so a stage that does not move
// renders the same picture throughout rather than reshuffling on every // renders the same picture throughout rather than reshuffling on every
// frame. The stages before the first one that moves are rendered once, and // 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. // their picture and the rng state they leave are reused for every frame.
// Returns the number of frames handed out. // 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 renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, std::uint64_t seed, const StyleSet& styles, int width, int height, std::uint64_t seed, const StyleSet& styles,
const VideoSettings& video, const VideoSettings& video,
const std::function<bool(int index, Image&& frame)>& onFrame); 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
+12 -11
View File
@@ -58,21 +58,22 @@ public:
return title(); return title();
} }
// Whether a stage with these values changes from one frame of a video to // Whether this is a video effect rather than an image effect. An image
// the next. A stage that does not is rendered once for the whole clip. // effect makes a picture and holds still in a clip; a video effect is
virtual bool animated(const ParamValues& values) const { // motion and nothing else, so it is offered only in a video project and a
(void)values; // still skips it whole, as it skips a stage that is switched off. Video
return false; // 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 effect that // One frame of a video, or a still when `frame` says so. An image effect
// does not move needs only render(), which this falls back to. One that // needs only render(), which this falls back to; a video effect renders
// does must leave `rng` in the same state on every frame -- a frame's own // here, and must leave `rng` in the same state on every frame -- a
// randomness comes from a generator of its own -- so the stages after it // frame's own randomness comes from a generator of its own -- so the
// stay put while it moves. // stages after it stay put while it moves.
virtual Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng, virtual Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values, const FrameTime& frame) const { const ParamValues& values, const FrameTime& frame) const {
(void)frame; (void)frame;
+69 -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 {
@@ -152,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) {
@@ -186,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());
+14 -2
View File
@@ -61,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 -6
View File
@@ -16,12 +16,6 @@ ParamSpec ParamSpec::styled() const {
return copy; return copy;
} }
ParamSpec ParamSpec::forVideo() const {
ParamSpec copy = *this;
copy.videoOnly = true;
return copy;
}
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum, ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help, long step) { long maximum, std::string help, long step) {
ParamSpec spec; ParamSpec spec;
@@ -106,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) {
@@ -127,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 -8
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;
@@ -27,10 +27,6 @@ struct ParamSpec {
// Whether a named style carries this param rather than the stage it sits // Whether a named style carries this param rather than the stage it sits
// on: see core/style.hpp. // on: see core/style.hpp.
bool inStyle = false; bool inStyle = false;
// Whether the param only means something in a video project -- how a
// stage moves over time. The interface leaves it out of a still project,
// where it would change nothing.
bool videoOnly = false;
std::string flag() const; // "strip_height" -> "--strip-height" std::string flag() const; // "strip_height" -> "--strip-height"
@@ -38,9 +34,6 @@ struct ParamSpec {
// reads makeDouble("font_size", ...).styled() and the factories keep the // reads makeDouble("font_size", ...).styled() and the factories keep the
// signatures they have. // signatures they have.
ParamSpec styled() const; ParamSpec styled() const;
// A copy of this spec marked as mattering only in a video project, read
// the same way: makeInt("split_jitter", ...).forVideo().
ParamSpec forVideo() const;
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum, static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help = {}, long step = 1); long maximum, std::string help = {}, long step = 1);
@@ -63,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>;
+32
View File
@@ -366,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: {
@@ -412,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: {
@@ -462,6 +464,11 @@ std::string projectToJson(const Project& project, const std::string& directory)
if (const auto* preset = videoPresetByKey(project.video.preset)) { if (const auto* preset = videoPresetByKey(project.video.preset)) {
out << ", \"preset\": " << quote(preset->key); 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"; out << "},\n";
} }
if (project.seed) out << " \"seed\": " << *project.seed << ",\n"; if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
@@ -548,6 +555,13 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
name->kind == Json::Kind::String) { name->kind == Json::Kind::String) {
if (const auto* preset = videoPresetByKey(name->text)) project.video.preset = preset->key; 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")) {
@@ -660,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
+5
View File
@@ -54,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;
} }
+35 -4
View File
@@ -51,6 +51,31 @@ const VideoPreset* videoPresetByKey(const std::string& key) {
return nullptr; 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 // 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". // millimetres of a paper size: "7.5 s", never "7,5 s".
std::string formatSeconds(double seconds) { std::string formatSeconds(double seconds) {
@@ -106,7 +131,8 @@ bool ffmpegAvailable() {
return false; return false;
} }
VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps) VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps,
const std::string& quality)
: path_(path), width_(width), height_(height) { : path_(path), width_(width), height_(height) {
const std::string muxer = muxerFor(lowerExtension(path)); const std::string muxer = muxerFor(lowerExtension(path));
if (muxer.empty()) { if (muxer.empty()) {
@@ -120,6 +146,7 @@ VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps
static std::once_flag ignorePipe; static std::once_flag ignorePipe;
std::call_once(ignorePipe, [] { ::signal(SIGPIPE, SIG_IGN); }); std::call_once(ignorePipe, [] { ::signal(SIGPIPE, SIG_IGN); });
const VideoQuality& squeeze = videoQualityOrDefault(quality);
std::vector<std::string> args = { std::vector<std::string> args = {
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-f", "rawvideo", "-pix_fmt", "rgb24",
@@ -129,16 +156,20 @@ VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps
}; };
if (muxer == "gif") { if (muxer == "gif") {
// One palette made for the whole clip, rather than the fixed web one. // One palette made for the whole clip, rather than the fixed web one.
args.insert(args.end(), {"-vf", "split[a][b];[b]palettegen[p];[a][p]paletteuse", args.insert(args.end(), {"-vf", "split[a][b];[b]palettegen=max_colors=" +
std::to_string(squeeze.gifColors) +
"[p];[a][p]paletteuse",
"-loop", "0"}); "-loop", "0"});
} else { } else {
// 4:2:0 wants even sides; a pixel of padding beats refusing the canvas. // 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"}); args.insert(args.end(), {"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"});
if (muxer == "webm") { if (muxer == "webm") {
args.insert(args.end(), {"-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0", args.insert(args.end(), {"-c:v", "libvpx-vp9", "-crf", std::to_string(squeeze.vp9Crf),
"-b:v", "0",
"-row-mt", "1"}); "-row-mt", "1"});
} else { } else {
args.insert(args.end(), {"-c:v", "libx264", "-preset", "medium", "-crf", "18"}); args.insert(args.end(), {"-c:v", "libx264", "-preset", squeeze.h264Preset, "-crf",
std::to_string(squeeze.h264Crf)});
} }
args.insert(args.end(), {"-pix_fmt", "yuv420p"}); args.insert(args.end(), {"-pix_fmt", "yuv420p"});
if (muxer == "mp4" || muxer == "mov") args.insert(args.end(), {"-movflags", "+faststart"}); if (muxer == "mp4" || muxer == "mov") args.insert(args.end(), {"-movflags", "+faststart"});
+32 -2
View File
@@ -31,6 +31,10 @@ struct VideoSettings {
// canvas format it is the presentation of the numbers, never the truth: // canvas format it is the presentation of the numbers, never the truth:
// the render goes by duration and fps. // the render goes by duration and fps.
std::string preset; 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. // The frames the clip has: the length at the rate, never fewer than one.
int frameCount() const; int frameCount() const;
@@ -55,6 +59,30 @@ const VideoPreset* videoPresetByKey(const std::string& key);
// "1080 × 1920 px · 15 s", for the dropdown and for --help. // "1080 × 1920 px · 15 s", for the dropdown and for --help.
std::string videoPresetDetail(const VideoPreset& preset); 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. // "15 s", "7.5 s": a length as the interface and --help show it.
std::string formatSeconds(double seconds); std::string formatSeconds(double seconds);
@@ -68,7 +96,7 @@ bool ffmpegAvailable();
// program. Run as a plain argv rather than through a shell, so a file name is // 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 // 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 // extension: H.264 for .mp4, .mov and .mkv, VP9 for .webm, a palette GIF for
// .gif. The encoded bytes are ffmpeg's business; the frames going in are the // .gif, squeezed as the quality says. The encoded bytes are ffmpeg's business; the frames going in are the
// ones the seed decides. // ones the seed decides.
// //
// Throws EffectError when ffmpeg is missing, fails, or quits early, with what // Throws EffectError when ffmpeg is missing, fails, or quits early, with what
@@ -76,7 +104,9 @@ bool ffmpegAvailable();
// itself on destruction, so a half-written file is never left behind. // itself on destruction, so a half-written file is never left behind.
class VideoWriter { class VideoWriter {
public: public:
VideoWriter(const std::string& path, int width, int height, int fps); // 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();
VideoWriter(const VideoWriter&) = delete; VideoWriter(const VideoWriter&) = delete;
VideoWriter& operator=(const VideoWriter&) = delete; VideoWriter& operator=(const VideoWriter&) = delete;
+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
+8 -172
View File
@@ -2,8 +2,6 @@
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <cmath>
#include <numbers>
#include <vector> #include <vector>
#include "core/parallel.hpp" #include "core/parallel.hpp"
@@ -24,26 +22,6 @@ const std::vector<ParamSpec>& ChannelSplitEffect::params() const {
"rgb moves all three channels apart; red_cyan moves red one way and " "rgb moves all three channels apart; red_cyan moves red one way and "
"green and blue the other, as 3D glasses would; random moves one " "green and blue the other, as 3D glasses would; random moves one "
"channel picked at random."), "channel picked at random."),
ParamSpec::makeInt("split_wobble", "Wobble", 4, 0, 256,
"How far in pixels the channels sway back and forth over time.")
.forVideo(),
ParamSpec::makeDouble("split_wobble_speed", "Wobble speed", 0.5, 0.0, 10.0,
"Sways per second, rounded so that the clip loops without a jump.",
0.1, 1)
.forVideo(),
ParamSpec::makeInt("split_jitter", "Jitter", 2, 0, 256,
"Furthest in pixels the channels shake, anew on every frame and, in "
"per_band mode, for every band.")
.forVideo(),
ParamSpec::makeInt("split_roll", "Tracking bar", 16, 0, 512,
"How far in pixels a bar rolling down the picture tears the rows "
"sideways and the channels apart; 0 for none.")
.forVideo(),
ParamSpec::makeDouble("split_roll_time", "Bar pass", 4.0, 0.5, 60.0,
"Seconds the tracking bar takes from top to bottom, rounded so "
"that the clip loops without a jump.",
0.5, 1)
.forVideo(),
}; };
return specs; return specs;
} }
@@ -57,68 +35,28 @@ struct Offset {
using ChannelOffsets = std::array<Offset, 3>; using ChannelOffsets = std::array<Offset, 3>;
ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels, int& moved) { ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels) {
ChannelOffsets offsets{}; ChannelOffsets offsets{};
auto draw = [&] { auto draw = [&] {
return Offset{static_cast<int>(rng.inRange(-amount, amount)), return Offset{static_cast<int>(rng.inRange(-amount, amount)),
static_cast<int>(rng.inRange(-amount, amount))}; static_cast<int>(rng.inRange(-amount, amount))};
}; };
moved = -1;
if (channels == "red_cyan") { if (channels == "red_cyan") {
const Offset red = draw(); const Offset red = draw();
offsets[0] = red; offsets[0] = red;
offsets[1] = offsets[2] = Offset{-red.dx, -red.dy}; offsets[1] = offsets[2] = Offset{-red.dx, -red.dy};
} else if (channels == "random") { } else if (channels == "random") {
moved = static_cast<int>(rng.below(2)); offsets[rng.below(2)] = draw();
offsets[static_cast<std::size_t>(moved)] = draw();
} else { } else {
for (auto& offset : offsets) offset = draw(); for (auto& offset : offsets) offset = draw();
} }
return offsets; return offsets;
} }
// A motion of each channel, handed out the way the mode moves channels: all } // namespace
// three on their own, green and blue opposite red, or only the one channel
// that was picked -- so the motion never moves a channel the still left alone.
using Motion = std::array<double, 3>;
Motion spread(const Motion& motion, const std::string& channels, int moved) { Image ChannelSplitEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
if (channels == "red_cyan") return {motion[0], -motion[0], -motion[0]}; const ParamValues& values) const {
if (moved >= 0) {
Motion only{};
only[static_cast<std::size_t>(moved)] = motion[0];
return only;
}
return motion;
}
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
struct MotionSettings {
int wobble = 0;
double wobbleSpeed = 0.0;
int jitter = 0;
int roll = 0;
double rollTime = 1.0;
explicit MotionSettings(const ParamValues& values)
: wobble(static_cast<int>(std::max(0L, paramInt(values, "split_wobble", 4)))),
wobbleSpeed(std::max(0.0, paramDouble(values, "split_wobble_speed", 0.5))),
jitter(static_cast<int>(std::max(0L, paramInt(values, "split_jitter", 2)))),
roll(static_cast<int>(std::max(0L, paramInt(values, "split_roll", 16)))),
rollTime(std::max(0.5, paramDouble(values, "split_roll_time", 4.0))) {}
bool moves() const { return (wobble > 0 && wobbleSpeed > 0.0) || jitter > 0 || roll > 0; }
};
// Rounds a rate so a whole number of cycles fits the clip, never fewer than
// one: the last frame then leads straight back into the first.
double loopRate(double perSecond, double clipSeconds) {
return std::max(1.0, std::round(perSecond * clipSeconds)) / clipSeconds;
}
Image renderSplit(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values, const FrameTime& frame) {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const int amount = static_cast<int>(std::max(0L, paramInt(values, "split_amount", 12))); const int amount = static_cast<int>(std::max(0L, paramInt(values, "split_amount", 12)));
@@ -133,105 +71,20 @@ Image renderSplit(const std::vector<Image>& sources, int width, int height, Rng&
const int band = perBand ? bandHeight : height; const int band = perBand ? bandHeight : height;
const int bandCount = (height + band - 1) / band; const int bandCount = (height + band - 1) / band;
std::vector<ChannelOffsets> plan; std::vector<ChannelOffsets> plan;
std::vector<int> moved;
plan.reserve(static_cast<std::size_t>(bandCount)); plan.reserve(static_cast<std::size_t>(bandCount));
moved.reserve(static_cast<std::size_t>(bandCount)); for (int i = 0; i < bandCount; ++i) plan.push_back(drawOffsets(rng, amount, channels));
for (int i = 0; i < bandCount; ++i) {
int only = -1;
plan.push_back(drawOffsets(rng, amount, channels, only));
moved.push_back(only);
}
// Every row's offsets, and a sideways shift of the whole row. A still is
// just the band plan; a frame of a video adds the motion to it.
std::vector<ChannelOffsets> rows(static_cast<std::size_t>(height));
std::vector<int> shift(static_cast<std::size_t>(height), 0);
for (int y = 0; y < height; ++y) rows[static_cast<std::size_t>(y)] = plan[static_cast<std::size_t>(y / band)];
const MotionSettings motion(values);
if (frame.video() && motion.moves()) {
// The same draws on every frame, after the still's: where each
// channel starts its sway and which way it sways. The shake is the
// one thing that differs between frames, so it comes from a generator
// of the frame's own, seeded by one more draw -- the shared rng leaves
// every frame in the same state, and the stages after this one hold
// still while it moves.
std::array<double, 3> phase{};
std::array<double, 3> angle{};
for (int c = 0; c < 3; ++c) {
phase[static_cast<std::size_t>(c)] = unit(rng) * 2.0 * std::numbers::pi;
angle[static_cast<std::size_t>(c)] = unit(rng) * 2.0 * std::numbers::pi;
}
Rng shake(rng.next() ^ (0x9e3779b97f4a7c15ULL * static_cast<std::uint64_t>(frame.index + 1)));
const double clip = static_cast<double>(std::max(1, frame.count)) / frame.fps;
const double t = frame.seconds();
Motion swayX{};
Motion swayY{};
if (motion.wobble > 0 && motion.wobbleSpeed > 0.0) {
const double rate = loopRate(motion.wobbleSpeed, clip);
for (std::size_t c = 0; c < 3; ++c) {
const double sway = motion.wobble * std::sin(2.0 * std::numbers::pi * rate * t + phase[c]);
swayX[c] = sway * std::cos(angle[c]);
swayY[c] = sway * std::sin(angle[c]);
}
}
// The tracking bar: an eighth of the picture, soft at both edges,
// entering above the top and leaving below the bottom.
const double barHeight = std::max(8.0, height / 8.0);
const double passes = loopRate(1.0 / motion.rollTime, clip);
const double along = std::fmod(t * passes, 1.0);
const double barTop = -barHeight + along * (height + barHeight);
for (int b = 0; b < bandCount; ++b) {
Motion jitterX{};
Motion jitterY{};
if (motion.jitter > 0) {
for (std::size_t c = 0; c < 3; ++c) {
jitterX[c] = static_cast<double>(shake.inRange(-motion.jitter, motion.jitter));
// Mostly sideways, as a picture losing its horizontal hold.
jitterY[c] = static_cast<double>(shake.inRange(-motion.jitter, motion.jitter)) / 3.0;
}
}
const int only = moved[static_cast<std::size_t>(b)];
const Motion dx = spread({swayX[0] + jitterX[0], swayX[1] + jitterX[1], swayX[2] + jitterX[2]},
channels, only);
const Motion dy = spread({swayY[0] + jitterY[0], swayY[1] + jitterY[1], swayY[2] + jitterY[2]},
channels, only);
// The bar pulls red one way and blue the other (or the moved
// channel alone), whatever the sway is doing.
const Motion apart = spread({1.0, 0.0, -1.0}, channels, only);
const int end = std::min(height, (b + 1) * band);
for (int y = b * band; y < end; ++y) {
double bar = 0.0;
if (motion.roll > 0 && y >= barTop && y < barTop + barHeight) {
bar = motion.roll * std::sin(std::numbers::pi * (y - barTop) / barHeight);
}
auto& offsets = rows[static_cast<std::size_t>(y)];
for (std::size_t c = 0; c < 3; ++c) {
offsets[c].dx += static_cast<int>(std::lround(dx[c] + bar * apart[c]));
offsets[c].dy += static_cast<int>(std::lround(dy[c]));
}
shift[static_cast<std::size_t>(y)] = static_cast<int>(std::lround(bar / 2.0));
}
}
}
Image canvas(width, height); Image canvas(width, height);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) { parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) { for (std::size_t y = begin; y < end; ++y) {
const ChannelOffsets& offsets = rows[y]; const ChannelOffsets& offsets = plan[y / static_cast<std::size_t>(band)];
std::uint8_t* out = canvas.row(static_cast<int>(y)); std::uint8_t* out = canvas.row(static_cast<int>(y));
for (int c = 0; c < 3; ++c) { for (int c = 0; c < 3; ++c) {
const Offset& offset = offsets[static_cast<std::size_t>(c)]; const Offset& offset = offsets[static_cast<std::size_t>(c)];
const int dx = offset.dx + shift[y];
const int sy = std::clamp(static_cast<int>(y) - offset.dy, 0, height - 1); const int sy = std::clamp(static_cast<int>(y) - offset.dy, 0, height - 1);
const std::uint8_t* in = input.row(sy); const std::uint8_t* in = input.row(sy);
for (int x = 0; x < width; ++x) { for (int x = 0; x < width; ++x) {
const int sx = std::clamp(x - dx, 0, width - 1); const int sx = std::clamp(x - offset.dx, 0, width - 1);
out[static_cast<std::size_t>(x) * 3 + c] = in[static_cast<std::size_t>(sx) * 3 + c]; out[static_cast<std::size_t>(x) * 3 + c] = in[static_cast<std::size_t>(sx) * 3 + c];
} }
} }
@@ -241,21 +94,4 @@ Image renderSplit(const std::vector<Image>& sources, int width, int height, Rng&
return canvas; return canvas;
} }
} // namespace
bool ChannelSplitEffect::animated(const ParamValues& values) const {
return MotionSettings(values).moves();
}
Image ChannelSplitEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
return renderSplit(sources, width, height, rng, values, FrameTime{});
}
Image ChannelSplitEffect::renderFrame(const std::vector<Image>& sources, int width, int height,
Rng& rng, const ParamValues& values,
const FrameTime& frame) const {
return renderSplit(sources, width, height, rng, values, frame);
}
} // namespace effector } // namespace effector
-6
View File
@@ -6,9 +6,6 @@ namespace effector {
// Knocks the red, green and blue planes out of register, either the whole image // Knocks the red, green and blue planes out of register, either the whole image
// at once or band by band, each band with offsets of its own. // at once or band by band, each band with offsets of its own.
// In a video the channels do not sit still: they sway, shake from frame to
// frame, and a tracking bar rolls down the picture as on a worn tape or an old
// tube. A still picture is rendered exactly as it always was.
// A pure transform: it needs no source images, and leading a chain with // A pure transform: it needs no source images, and leading a chain with
// sources loaded it starts from a crop of a randomly chosen one. // sources loaded it starts from a crop of a randomly chosen one.
class ChannelSplitEffect : public Effect { class ChannelSplitEffect : public Effect {
@@ -21,12 +18,9 @@ public:
} }
const std::vector<ParamSpec>& params() const override; const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; } bool needsSources() const override { return false; }
bool animated(const ParamValues& values) const override;
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng, Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override; const ParamValues& values) const override;
Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values, const FrameTime& frame) const override;
}; };
} // namespace effector } // namespace effector
+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);
} }
} }
+447 -51
View File
@@ -10,6 +10,7 @@
#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"
@@ -28,6 +29,11 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*
const char* const kRecentApp = "effector"; const char* const kRecentApp = "effector";
const std::size_t kRecentCount = 10; const std::size_t kRecentCount = 10;
// Undo keeps this many steps, and an edit becomes one once the controls have
// been still this long (ms): a spin held down or a word typed is one step.
const std::size_t kHistoryDepth = 100;
constexpr unsigned kHistoryPause = 500;
Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() { Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
auto filter = Gtk::FileFilter::create(); auto filter = Gtk::FileFilter::create();
filter->set_name("Effector projects"); filter->set_name("Effector projects");
@@ -50,6 +56,10 @@ constexpr double kClipBudgetBytes = 256.0 * 1024 * 1024;
constexpr int kClipMinSide = 160; constexpr int kClipMinSide = 160;
constexpr int kClipMaxSide = 720; constexpr int kClipMaxSide = 720;
// A preview clip renders every third frame first and plays those, each held
// for three frames, while the frames between them are filled in.
constexpr int kDraftStep = 3;
int clipSide(int width, int height, int frames) { int clipSide(int width, int height, int frames) {
const double full = 3.0 * width * height * std::max(1, frames); const double full = 3.0 * width * height * std::max(1, frames);
const double scale = std::min(1.0, std::sqrt(kClipBudgetBytes / full)); const double scale = std::min(1.0, std::sqrt(kClipBudgetBytes / full));
@@ -111,13 +121,21 @@ EffectorWindow::EffectorWindow(const Options& initial) {
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished)); renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress)); loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress));
loadFinished_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadFinished)); loadFinished_.connect([this] {
onLoadFinished();
if (historyAfterLoad_ && !loading_) {
historyAfterLoad_ = false;
resetHistory();
}
});
clipProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onClipProgress)); clipProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onClipProgress));
exportProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportProgress)); exportProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportProgress));
exportDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportFinished)); exportDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportFinished));
buildUi(); buildUi();
applyInitial(initial); applyInitial(initial);
historyAfterLoad_ = loading_;
resetHistory();
suspendRender_ = false; suspendRender_ = false;
if (loading_) { if (loading_) {
@@ -131,6 +149,7 @@ EffectorWindow::EffectorWindow(const Options& initial) {
EffectorWindow::~EffectorWindow() { EffectorWindow::~EffectorWindow() {
debounce_.disconnect(); debounce_.disconnect();
historyCommit_.disconnect();
chainSplitFit_.disconnect(); chainSplitFit_.disconnect();
recentChanged_.disconnect(); recentChanged_.disconnect();
loadWindowDelay_.disconnect(); loadWindowDelay_.disconnect();
@@ -380,7 +399,7 @@ Gtk::Widget& EffectorWindow::buildSetupTab() {
// rather than left there to be filled in vain. This is the same declarative // rather than left there to be filled in vain. This is the same declarative
// answer the render gate uses -- no effect is named here. // answer the render gate uses -- no effect is named here.
void EffectorWindow::updateSourcesVisible() { void EffectorWindow::updateSourcesVisible() {
sourcesSection_.set_visible(chainNeedsSources(stages_)); sourcesSection_.set_visible(chainNeedsSources(stages_, isVideo()));
} }
// The heading carries the count, which a long list scrolled away would hide. // The heading carries the count, which a long list scrolled away would hide.
@@ -512,8 +531,8 @@ void EffectorWindow::syncCanvasWidgets() {
// ---------------------------------------------------------------- video --- // ---------------------------------------------------------------- video ---
// A still project shows only the switch; a video shows its preset, length and // A still project shows only the switch; a video shows its preset, length, rate
// rate below it. The preset list reads like the canvas one: "Custom" first, // and file quality below it. The preset list reads like the canvas one: "Custom" first,
// then every preset with what it amounts to. // then every preset with what it amounts to.
Gtk::Widget& EffectorWindow::buildVideoSection() { Gtk::Widget& EffectorWindow::buildVideoSection() {
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6); auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
@@ -572,6 +591,32 @@ Gtk::Widget& EffectorWindow::buildVideoSection() {
grid->attach(durationSpin_, 1, 0); grid->attach(durationSpin_, 1, 0);
grid->attach(*fpsLabel, 0, 1); grid->attach(*fpsLabel, 0, 1);
grid->attach(fpsSpin_, 1, 1); grid->attach(fpsSpin_, 1, 1);
// How the saved file is encoded. It changes nothing on screen, so it asks
// for no render.
std::vector<Glib::ustring> qualityLabels;
Glib::ustring qualityTooltip = "How the saved video file is encoded";
guint standard = 0;
for (std::size_t i = 0; i < videoQualities().size(); ++i) {
const auto& quality = videoQualities()[i];
qualityLabels.push_back(quality.label);
qualityTooltip += "\n" + quality.label + ": " + quality.description;
if (quality.key == kDefaultVideoQuality) standard = static_cast<guint>(i);
}
videoQualityDrop_.set_name("video-quality");
videoQualityDrop_.set_model(Gtk::StringList::create(qualityLabels));
videoQualityDrop_.set_selected(standard);
videoQualityDrop_.set_hexpand(true);
videoQualityDrop_.set_tooltip_text(qualityTooltip);
// Only the export hears of the quality, so it asks for no render; it is
// still part of the project, and of its history.
videoQualityDrop_.property_selected().signal_changed().connect([this] {
if (!suspendVideoSync_) noteChange();
});
auto* qualityLabel = Gtk::make_managed<Gtk::Label>("Quality");
qualityLabel->set_xalign(0.0F);
grid->attach(*qualityLabel, 0, 2);
grid->attach(videoQualityDrop_, 1, 2);
videoDetails_.append(*grid); videoDetails_.append(*grid);
videoDetails_.set_visible(false); videoDetails_.set_visible(false);
box->append(videoDetails_); box->append(videoDetails_);
@@ -588,6 +633,8 @@ VideoSettings EffectorWindow::videoSettings() const {
if (selected != GTK_INVALID_LIST_POSITION && selected > 0 && selected <= presets.size()) { if (selected != GTK_INVALID_LIST_POSITION && selected > 0 && selected <= presets.size()) {
video.preset = presets[selected - 1].key; video.preset = presets[selected - 1].key;
} }
const auto quality = videoQualityDrop_.get_selected();
if (quality < videoQualities().size()) video.quality = videoQualities()[quality].key;
return video; return video;
} }
@@ -602,12 +649,18 @@ void EffectorWindow::setVideoSettings(const VideoSettings& video) {
if (presets[i].key == video.preset) selected = static_cast<guint>(i + 1); if (presets[i].key == video.preset) selected = static_cast<guint>(i + 1);
} }
videoPresetDrop_.set_selected(selected); videoPresetDrop_.set_selected(selected);
const auto& quality = videoQualityOrDefault(video.quality);
for (std::size_t i = 0; i < videoQualities().size(); ++i) {
if (videoQualities()[i].key == quality.key) videoQualityDrop_.set_selected(static_cast<guint>(i));
}
videoSwitch_.set_active(video.enabled); videoSwitch_.set_active(video.enabled);
videoDetails_.set_visible(video.enabled); videoDetails_.set_visible(video.enabled);
suspendVideoSync_ = suspended; suspendVideoSync_ = suspended;
playbackBar_.set_visible(false); playbackBar_.set_visible(false);
clearClip(); clearClip();
buildParamWidgets(); // the motion params come and go with the switch updateVideoEffectItems();
rebuildChainRows(); // a video stage runs or is skipped with the switch
buildParamWidgets();
} }
void EffectorWindow::onVideoToggled() { void EffectorWindow::onVideoToggled() {
@@ -618,6 +671,8 @@ void EffectorWindow::onVideoToggled() {
result_ = Image(); result_ = Image();
clearClip(); clearClip();
saveButton_.set_sensitive(false); saveButton_.set_sensitive(false);
updateVideoEffectItems();
rebuildChainRows(); // a video stage runs or is skipped with the switch
buildParamWidgets(); buildParamWidgets();
requestRender(); requestRender();
} }
@@ -656,26 +711,59 @@ void EffectorWindow::matchVideoPreset() {
} }
// Frames come over from the worker a few at a time and are turned into // Frames come over from the worker a few at a time and are turned into
// textures here, on the main thread. The clip on screen keeps playing until // textures here, on the main thread. They come draft first (draftFirstOrder):
// the new one is complete; the very first one shows its frames as they come. // the clip on screen keeps playing until the draft frames are all in, then
// the draft takes its place and each later frame drops into it as it comes.
// The very first clip shows its frames as they come.
void EffectorWindow::onClipProgress() { void EffectorWindow::onClipProgress() {
std::vector<Image> frames; std::vector<std::pair<int, Image>> frames;
{ {
std::lock_guard<std::mutex> lock(resultMutex_); std::lock_guard<std::mutex> lock(resultMutex_);
frames.swap(pendingFrames_); frames.swap(pendingFrames_);
} }
if (!renderingVideo_) return; // left over from a render that was dropped if (!renderingVideo_) return; // left over from a render that was dropped
for (const auto& frame : frames) incomingClip_.push_back(toTexture(frame)); Glib::RefPtr<Gdk::Texture> latest;
if (frames.empty()) return; for (const auto& [index, frame] : frames) {
const auto slot = static_cast<std::size_t>(index);
if (slot >= incomingClip_.size()) continue; // the clip was cleared meanwhile
latest = toTexture(frame);
incomingClip_[slot] = latest;
++incomingReceived_;
if (draftShown_ && slot < clip_.size()) clip_[slot] = latest;
}
if (!latest) return;
if (clip_.empty()) { const int draftFrames = (incomingTotal_ + kDraftStep - 1) / kDraftStep;
picture_.set_paintable(incomingClip_.back()); if (!draftShown_ && incomingReceived_ >= draftFrames) {
showIncomingClip();
} else if (clip_.empty()) {
picture_.set_paintable(latest);
previewStack_.set_visible_child("picture"); previewStack_.set_visible_child("picture");
} }
status_.set_text("Rendering preview · frame " + std::to_string(incomingClip_.size()) + " of " + status_.set_text(std::string(draftShown_ ? "Refining preview" : "Rendering preview") +
" · frame " + std::to_string(incomingReceived_) + " of " +
std::to_string(incomingTotal_)); std::to_string(incomingTotal_));
} }
// Puts the clip being rendered on screen, a frame not rendered yet held from
// the one before it.
void EffectorWindow::showIncomingClip() {
std::vector<Glib::RefPtr<Gdk::Texture>> clip;
clip.reserve(incomingClip_.size());
Glib::RefPtr<Gdk::Texture> held;
for (const auto& texture : incomingClip_) {
if (texture) held = texture;
clip.push_back(held);
}
if (!clip.empty() && !clip.front()) return; // not even the first frame yet
clip_ = std::move(clip);
clipFps_ = incomingFps_;
draftShown_ = true;
if (playFrame_ >= static_cast<int>(clip_.size())) playFrame_ = 0;
previewStack_.set_visible_child("picture");
startPlayback();
}
void EffectorWindow::showClipFrame(int index) { void EffectorWindow::showClipFrame(int index) {
if (clip_.empty()) return; if (clip_.empty()) return;
playFrame_ = std::clamp(index, 0, static_cast<int>(clip_.size()) - 1); playFrame_ = std::clamp(index, 0, static_cast<int>(clip_.size()) - 1);
@@ -720,6 +808,7 @@ void EffectorWindow::clearClip() {
stopPlayback(); stopPlayback();
clip_.clear(); clip_.clear();
incomingClip_.clear(); incomingClip_.clear();
draftShown_ = false;
playFrame_ = 0; playFrame_ = 0;
playbackBar_.set_visible(false); playbackBar_.set_visible(false);
} }
@@ -804,8 +893,11 @@ void EffectorWindow::buildParamWidgets() {
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)]; const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
// The settings of a stage that is switched off stay editable: it is still // The settings of a stage that is switched off stay editable: it is still
// being worked on, it just is not in the picture for the moment. // being worked on, it just is not in the picture for the moment.
const bool skipped = stage.effect->video() && !isVideo();
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() + paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
(stage.enabled ? " settings" : " settings (off)")); (!stage.enabled ? " settings (off)"
: skipped ? " settings (video only)"
: " settings"));
int row = 0; int row = 0;
// The style the stage follows, when its effect takes one at all: the // The style the stage follows, when its effect takes one at all: the
@@ -828,9 +920,6 @@ void EffectorWindow::buildParamWidgets() {
} }
for (const auto& spec : stage.effect->params()) { for (const auto& spec : stage.effect->params()) {
// How a stage moves means nothing in a still, so it is left out there;
// the values stay with the stage for when the project becomes a video.
if (spec.videoOnly && !isVideo()) continue;
// A styled param is read from the style while one is followed, so the // A styled param is read from the style while one is followed, so the
// widgets show what the stage will actually render with. // widgets show what the stage will actually render with.
const bool shared = style != nullptr && spec.inStyle; const bool shared = style != nullptr && spec.inStyle;
@@ -925,6 +1014,21 @@ void EffectorWindow::buildParamWidgets() {
widget = std::move(button); widget = std::move(button);
break; break;
} }
case ParamSpec::Type::Font: {
// Only the family is chosen here: weight, slant and size are
// params of their own.
auto button = std::make_unique<Gtk::FontDialogButton>(Gtk::FontDialog::create());
button->set_halign(Gtk::Align::START);
button->set_level(Gtk::FontLevel::FAMILY);
button->set_use_font(true);
Pango::FontDescription font;
font.set_family(paramText(values, spec.key, spec.defaultChoice));
button->set_font_desc(font);
button->property_font_desc().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(button);
break;
}
case ParamSpec::Type::Choice: { case ParamSpec::Type::Choice: {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end()); std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>(); auto dropdown = std::make_unique<Gtk::DropDown>();
@@ -981,6 +1085,14 @@ ParamValues EffectorWindow::paramValues() const {
values[entry.spec.key] = formatColor(rgb); values[entry.spec.key] = formatColor(rgb);
break; break;
} }
case ParamSpec::Type::Font: {
// A family the machine lacks keeps its name, so a project
// from elsewhere still says which font it wanted.
const auto family =
static_cast<Gtk::FontDialogButton*>(entry.widget)->get_font_desc().get_family();
if (!family.empty()) values[entry.spec.key] = std::string(family);
break;
}
case ParamSpec::Type::Choice: { case ParamSpec::Type::Choice: {
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected(); const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index]; if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
@@ -1152,6 +1264,7 @@ void EffectorWindow::onRenameStyle() {
if (stage.effect->styleKind() == kind && stage.style == was) stage.style = name; if (stage.effect->styleKind() == kind && stage.style == was) stage.style = name;
} }
buildParamWidgets(); buildParamWidgets();
noteChange(); // the picture is the same, so no render asks for it
}); });
} }
@@ -1318,6 +1431,7 @@ void EffectorWindow::addPaths(const std::vector<std::string>& paths) {
loading_ = true; loading_ = true;
loadCancel_.store(false, std::memory_order_relaxed); loadCancel_.store(false, std::memory_order_relaxed);
loadSized_.store(0, std::memory_order_relaxed);
loadDone_.store(0, std::memory_order_relaxed); loadDone_.store(0, std::memory_order_relaxed);
loadTotal_ = paths.size(); loadTotal_ = paths.size();
{ {
@@ -1339,6 +1453,14 @@ void EffectorWindow::addPaths(const std::vector<std::string>& paths) {
loader_ = std::thread([this, paths] { loader_ = std::thread([this, paths] {
LoadProgress progress; LoadProgress progress;
progress.cancel = &loadCancel_; progress.cancel = &loadCancel_;
progress.onSized = [this](std::size_t sized, std::size_t, const std::string& path) {
{
std::lock_guard<std::mutex> lock(loadMutex_);
loadCurrent_ = path;
}
loadSized_.store(sized, std::memory_order_relaxed);
loadProgress_.emit();
};
progress.onFile = [this](std::size_t done, std::size_t, const std::string& path) { progress.onFile = [this](std::size_t done, std::size_t, const std::string& path) {
{ {
std::lock_guard<std::mutex> lock(loadMutex_); std::lock_guard<std::mutex> lock(loadMutex_);
@@ -1349,26 +1471,41 @@ void EffectorWindow::addPaths(const std::vector<std::string>& paths) {
}; };
std::vector<LoadFailure> failures; std::vector<LoadFailure> failures;
auto images = loadImages(paths, failures, &progress); std::vector<Image> images;
std::string refused;
try {
images = loadImages(paths, failures, &progress);
} catch (const OutOfMemoryError& error) {
refused = error.what();
}
{ {
std::lock_guard<std::mutex> lock(loadMutex_); std::lock_guard<std::mutex> lock(loadMutex_);
loadedImages_ = std::move(images); loadedImages_ = std::move(images);
loadFailures_ = std::move(failures); loadFailures_ = std::move(failures);
loadRefused_ = std::move(refused);
loadRefusedPaths_ = loadRefused_.empty() ? std::vector<std::string>{} : paths;
} }
loadFinished_.emit(); loadFinished_.emit();
}); });
} }
void EffectorWindow::onLoadProgress() { void EffectorWindow::onLoadProgress() {
if (loadCancel_.load(std::memory_order_relaxed)) return; // keep "Cancelling…" up
const std::size_t sized = loadSized_.load(std::memory_order_relaxed);
const std::size_t done = loadDone_.load(std::memory_order_relaxed); const std::size_t done = loadDone_.load(std::memory_order_relaxed);
std::string current; std::string current;
{ {
std::lock_guard<std::mutex> lock(loadMutex_); std::lock_guard<std::mutex> lock(loadMutex_);
current = loadCurrent_; current = loadCurrent_;
} }
// The headers are all read before the first decode starts, so the bar
// runs twice: once sizing the batch, then once decoding it.
if (loadBar_ != nullptr && loadTotal_ > 0) { if (loadBar_ != nullptr && loadTotal_ > 0) {
loadBar_->set_fraction(static_cast<double>(done) / static_cast<double>(loadTotal_)); const bool sizing = sized < loadTotal_ && done == 0;
loadBar_->set_text(std::to_string(done) + " of " + std::to_string(loadTotal_)); const std::size_t count = sizing ? sized : done;
loadBar_->set_fraction(static_cast<double>(count) / static_cast<double>(loadTotal_));
loadBar_->set_text((sizing ? "Checking sizes: " : "Decoding: ") + std::to_string(count) +
" of " + std::to_string(loadTotal_));
} }
if (loadFileLabel_ != nullptr && !current.empty()) { if (loadFileLabel_ != nullptr && !current.empty()) {
loadFileLabel_->set_text(std::filesystem::path(current).filename().string()); loadFileLabel_->set_text(std::filesystem::path(current).filename().string());
@@ -1384,12 +1521,18 @@ void EffectorWindow::onLoadFinished() {
std::vector<Image> images; std::vector<Image> images;
std::vector<LoadFailure> failures; std::vector<LoadFailure> failures;
std::string refused;
std::vector<std::string> refusedPaths;
{ {
std::lock_guard<std::mutex> lock(loadMutex_); std::lock_guard<std::mutex> lock(loadMutex_);
images = std::move(loadedImages_); images = std::move(loadedImages_);
failures = std::move(loadFailures_); failures = std::move(loadFailures_);
refused = std::move(loadRefused_);
refusedPaths = std::move(loadRefusedPaths_);
loadedImages_.clear(); loadedImages_.clear();
loadFailures_.clear(); loadFailures_.clear();
loadRefused_.clear();
loadRefusedPaths_.clear();
} }
if (loadCancel_.load(std::memory_order_relaxed)) { if (loadCancel_.load(std::memory_order_relaxed)) {
@@ -1406,6 +1549,22 @@ void EffectorWindow::onLoadFinished() {
dropLoadedImages_ = false; dropLoadedImages_ = false;
images.clear(); images.clear();
failures.clear(); failures.clear();
refused.clear();
refusedPaths.clear();
}
if (!refused.empty()) {
// Nothing of a refused batch was decoded. Its paths are kept for the
// project file, and whatever was queued behind it still gets its turn.
refusedSources_.insert(refusedSources_.end(), refusedPaths.begin(), refusedPaths.end());
showError(refused + " They were not loaded, but stay in the project when it is saved. "
"Close other programs, or load fewer or smaller images.");
if (!loadQueue_.empty()) {
auto queued = std::move(loadQueue_);
loadQueue_.clear();
addPaths(queued);
}
return;
} }
for (const auto& image : images) appendSourceRow(image.path()); for (const auto& image : images) appendSourceRow(image.path());
@@ -1452,7 +1611,7 @@ void EffectorWindow::showLoadWindow() {
auto* bar = Gtk::make_managed<Gtk::ProgressBar>(); auto* bar = Gtk::make_managed<Gtk::ProgressBar>();
bar->set_show_text(true); bar->set_show_text(true);
bar->set_text("0 of " + std::to_string(loadTotal_)); bar->set_text("Checking sizes: 0 of " + std::to_string(loadTotal_));
box->append(*bar); box->append(*bar);
loadBar_ = bar; loadBar_ = bar;
@@ -1676,16 +1835,51 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
return *box; return *box;
} }
// The tallest the menu of effects grows before it scrolls, in pixels. // The tallest each part of the menu of effects grows before it scrolls, in
constexpr int addMenuMaxHeight = 480; // pixels: together, with their headings, short enough to open below a button
// near the top of a small screen.
constexpr int addMenuImageHeight = 300;
constexpr int addMenuVideoHeight = 150;
// One entry per registered effect, so a new effect shows up here without this // One entry per registered effect, so a new effect shows up here without this
// file knowing anything about it. // file knowing anything about it. The image effects and the video effects
// are two sections, each under its own heading and each scrolling on its own,
// so the video effects stay in sight however many image effects there are;
// they can only be picked in a video project.
Gtk::Widget& EffectorWindow::buildAddMenu() { Gtk::Widget& EffectorWindow::buildAddMenu() {
auto* popover = Gtk::make_managed<Gtk::Popover>(); auto* popover = Gtk::make_managed<Gtk::Popover>();
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2); auto* sections = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
for (const bool video : {false, true}) {
if (video) {
auto* separator = Gtk::make_managed<Gtk::Separator>();
separator->set_margin_top(6);
separator->set_margin_bottom(4);
sections->append(*separator);
}
auto* heading = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
heading->set_margin_start(8);
heading->set_margin_top(4);
heading->set_margin_bottom(2);
heading->append(*Gtk::make_managed<Gtk::Image>(Gio::ThemedIcon::create(
video ? "camera-video-symbolic" : "camera-photo-symbolic")));
auto* title = Gtk::make_managed<Gtk::Label>(video ? "Video effects" : "Image effects");
title->add_css_class("heading");
heading->append(*title);
sections->append(*heading);
if (video) {
videoEffectsHint_ = Gtk::make_managed<Gtk::Label>("Switch on Video under Setup to add these.");
videoEffectsHint_->add_css_class("dim-label");
videoEffectsHint_->set_xalign(0.0F);
videoEffectsHint_->set_margin_start(8);
videoEffectsHint_->set_wrap(true);
videoEffectsHint_->set_max_width_chars(28);
sections->append(*videoEffectsHint_);
}
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
for (const auto& effect : effects_) { for (const auto& effect : effects_) {
if (effect->video() != video) continue;
auto* item = Gtk::make_managed<Gtk::Button>(effect->title()); auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
item->add_css_class("flat"); item->add_css_class("flat");
item->set_tooltip_text(effect->description()); item->set_tooltip_text(effect->description());
@@ -1694,21 +1888,33 @@ Gtk::Widget& EffectorWindow::buildAddMenu() {
addEffectButton_.popdown(); addEffectButton_.popdown();
appendStage(effect); appendStage(effect);
}); });
if (video) videoEffectItems_.push_back(item);
list->append(*item); list->append(*item);
} }
// A popover that does not fit on the screen is not shown at all, and the // A popover that does not fit on the screen is not shown at all, and
// list grows with every effect registered, so past a point it scrolls. // the lists grow with every effect registered, so past a point they
// scroll.
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>(); auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_propagate_natural_height(true); scroller->set_propagate_natural_height(true);
scroller->set_max_content_height(addMenuMaxHeight); scroller->set_max_content_height(video ? addMenuVideoHeight : addMenuImageHeight);
scroller->set_child(*list); scroller->set_child(*list);
sections->append(*scroller);
}
updateVideoEffectItems();
popover->set_child(*scroller); popover->set_child(*sections);
return *popover; return *popover;
} }
// A video effect has nothing to do in a still, so it is offered only once the
// project is a video.
void EffectorWindow::updateVideoEffectItems() {
for (auto* item : videoEffectItems_) item->set_sensitive(isVideo());
if (videoEffectsHint_ != nullptr) videoEffectsHint_->set_visible(!isVideo());
}
void EffectorWindow::appendStage(const EffectPtr& effect) { void EffectorWindow::appendStage(const EffectPtr& effect) {
stages_.push_back({effect, effect->defaults()}); stages_.push_back({effect, effect->defaults()});
selectedStage_ = static_cast<int>(stages_.size()) - 1; selectedStage_ = static_cast<int>(stages_.size()) - 1;
@@ -1751,7 +1957,8 @@ void EffectorWindow::rebuildChainRows() {
// The first stage that actually runs is the one handed the sources, which // The first stage that actually runs is the one handed the sources, which
// is not stage 1 once stage 1 is switched off. // is not stage 1 once stage 1 is switched off.
const auto running = enabledStages(stages_); const bool video = isVideo();
const auto running = enabledStages(stages_, video);
const int firstRunning = const int firstRunning =
running.empty() ? -1 : static_cast<int>(running.front() - stages_.data()); running.empty() ? -1 : static_cast<int>(running.front() - stages_.data());
@@ -1778,13 +1985,28 @@ void EffectorWindow::rebuildChainRows() {
}); });
box->append(*check); box->append(*check);
// A video effect is marked as one, and in a still it is dimmed like a
// stage that is switched off: the render skips it either way.
const bool videoEffect = stages_[i].effect->video();
const bool skipped = !stages_[i].enabled || (videoEffect && !video);
if (videoEffect) {
auto* icon = Gtk::make_managed<Gtk::Image>(
Gio::ThemedIcon::create("camera-video-symbolic"));
icon->set_tooltip_text("A video effect");
icon->set_margin_start(4);
icon->set_margin_end(2);
if (skipped) icon->add_css_class("dim-label");
box->append(*icon);
}
auto* label = Gtk::make_managed<Gtk::Label>(stageLabel(i)); auto* label = Gtk::make_managed<Gtk::Label>(stageLabel(i));
stageLabels_.push_back(label); stageLabels_.push_back(label);
label->set_xalign(0.0F); label->set_xalign(0.0F);
label->set_hexpand(true); label->set_hexpand(true);
label->set_ellipsize(Pango::EllipsizeMode::END); label->set_ellipsize(Pango::EllipsizeMode::END);
if (!stages_[i].enabled) label->add_css_class("dim-label"); if (skipped) label->add_css_class("dim-label");
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off" label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
: skipped ? "A video effect: skipped in a still picture"
: index == firstRunning ? "Applied to the source images" : index == firstRunning ? "Applied to the source images"
: "Applied to the output so far"); : "Applied to the output so far");
box->append(*label); box->append(*label);
@@ -2012,6 +2234,7 @@ void EffectorWindow::onRecreate() {
seed_ = Rng::randomSeed(); seed_ = Rng::randomSeed();
seedEntry_.set_text(std::to_string(seed_)); seedEntry_.set_text(std::to_string(seed_));
if (!canRender()) { if (!canRender()) {
noteChange();
showError(missingText()); showError(missingText());
return; return;
} }
@@ -2022,13 +2245,18 @@ void EffectorWindow::onRecreate() {
// ever handed the sources, so an effect that does not want them (blur) renders // ever handed the sources, so an effect that does not want them (blur) renders
// happily with none loaded. // happily with none loaded.
bool EffectorWindow::canRender() const { bool EffectorWindow::canRender() const {
if (enabledStages(stages_).empty()) return false; if (enabledStages(stages_, isVideo()).empty()) return false;
return !sources_->empty() || !chainNeedsSources(stages_); return !sources_->empty() || !chainNeedsSources(stages_, isVideo());
} }
std::string EffectorWindow::missingText() const { std::string EffectorWindow::missingText() const {
if (stages_.empty()) return "Add an effect to start."; if (stages_.empty()) return "Add an effect to start.";
if (enabledStages(stages_).empty()) return "Every effect is switched off."; if (enabledStages(stages_, isVideo()).empty()) {
if (!enabledStages(stages_, true).empty()) {
return "Video effects run only in a video: switch on Video under Setup.";
}
return "Every effect is switched off.";
}
return "Add source images under Setup to start."; return "Add source images under Setup to start.";
} }
@@ -2043,6 +2271,9 @@ void EffectorWindow::showPlaceholder() {
} }
void EffectorWindow::requestRender() { void EffectorWindow::requestRender() {
// Every edit of the project asks for a render, so this is where the
// history hears of them.
noteChange();
if (suspendRender_) return; if (suspendRender_) return;
if (!canRender()) { if (!canRender()) {
showPlaceholder(); showPlaceholder();
@@ -2088,8 +2319,11 @@ void EffectorWindow::startRender() {
std::lock_guard<std::mutex> lock(resultMutex_); std::lock_guard<std::mutex> lock(resultMutex_);
pendingFrames_.clear(); pendingFrames_.clear();
} }
incomingClip_.clear();
incomingTotal_ = video.frameCount(); incomingTotal_ = video.frameCount();
incomingClip_.assign(static_cast<std::size_t>(incomingTotal_), nullptr);
incomingReceived_ = 0;
incomingFps_ = video.fps;
draftShown_ = false;
status_.set_text("Rendering preview…"); status_.set_text("Rendering preview…");
} }
@@ -2100,19 +2334,22 @@ void EffectorWindow::startRender() {
try { try {
if (video.enabled) { if (video.enabled) {
// Rendered at the full size, so the preview shows what the // Rendered at the full size, so the preview shows what the
// file will hold, and shrunk only to be kept. // file will hold, and shrunk only to be kept. A draft of
// every few frames comes first, so the clip plays early.
const int side = clipSide(width, height, video.frameCount()); const int side = clipSide(width, height, video.frameCount());
renderFrames(stages, *sources, width, height, seed, styles, video, renderFrames(
[this, side](int, Image&& frame) { stages, *sources, width, height, seed, styles, video,
[this, side](int index, Image&& frame) {
if (renderCancel_.load(std::memory_order_relaxed)) return false; if (renderCancel_.load(std::memory_order_relaxed)) return false;
Image small = frame.scaledToFit(side); Image small = frame.scaledToFit(side);
{ {
std::lock_guard<std::mutex> lock(resultMutex_); std::lock_guard<std::mutex> lock(resultMutex_);
pendingFrames_.push_back(std::move(small)); pendingFrames_.emplace_back(index, std::move(small));
} }
clipProgress_.emit(); clipProgress_.emit();
return true; return true;
}); },
draftFirstOrder(video.frameCount(), kDraftStep));
} else { } else {
Rng rng(seed); Rng rng(seed);
image = renderChain(stages, *sources, width, height, rng, styles); image = renderChain(stages, *sources, width, height, rng, styles);
@@ -2172,16 +2409,12 @@ void EffectorWindow::onRenderFinished() {
std::ostringstream status; std::ostringstream status;
status.setf(std::ios::fixed); status.setf(std::ios::fixed);
status.precision(1); status.precision(1);
const std::size_t effects = enabledStages(stages_).size(); const std::size_t effects = enabledStages(stages_, wasVideo).size();
if (wasVideo) { if (wasVideo) {
result_ = Image(); result_ = Image();
clip_ = std::move(incomingClip_); showIncomingClip();
incomingClip_.clear(); incomingClip_.clear();
clipFps_ = videoSettings().fps;
if (playFrame_ >= static_cast<int>(clip_.size())) playFrame_ = 0;
previewStack_.set_visible_child("picture");
saveButton_.set_sensitive(!clip_.empty()); saveButton_.set_sensitive(!clip_.empty());
startPlayback();
status << clip_.size() << " frames · " << effects << (effects == 1 ? " effect" : " effects") status << clip_.size() << " frames · " << effects << (effects == 1 ? " effect" : " effects")
<< " · " << millis / 1000.0 << " s"; << " · " << millis / 1000.0 << " s";
} else { } else {
@@ -2195,7 +2428,7 @@ void EffectorWindow::onRenderFinished() {
// The line itself stays short; the full chain and setup go in the tooltip. // The line itself stays short; the full chain and setup go in the tooltip.
std::ostringstream details; std::ostringstream details;
details << chainTitle(stages_) << " · " << static_cast<int>(widthSpin_.get_value()) << "×" details << chainTitle(stages_, wasVideo) << " · " << static_cast<int>(widthSpin_.get_value()) << "×"
<< static_cast<int>(heightSpin_.get_value()) << " · "; << static_cast<int>(heightSpin_.get_value()) << " · ";
if (wasVideo) { if (wasVideo) {
const auto video = videoSettings(); const auto video = videoSettings();
@@ -2228,10 +2461,16 @@ Gtk::Widget& EffectorWindow::buildMainMenu() {
}); });
// Never enabled: it only stands in for an empty list. // Never enabled: it only stands in for an empty list.
add_action("no-recent")->set_enabled(false); add_action("no-recent")->set_enabled(false);
undoAction_ = add_action("undo", sigc::mem_fun(*this, &EffectorWindow::undo));
redoAction_ = add_action("redo", sigc::mem_fun(*this, &EffectorWindow::redo));
add_action("show-changelog", sigc::mem_fun(*this, &EffectorWindow::showChangelog)); add_action("show-changelog", sigc::mem_fun(*this, &EffectorWindow::showChangelog));
add_action("about", sigc::mem_fun(*this, &EffectorWindow::showAbout)); add_action("about", sigc::mem_fun(*this, &EffectorWindow::showAbout));
auto menu = Gio::Menu::create(); auto menu = Gio::Menu::create();
auto edit = Gio::Menu::create();
edit->append("Undo", "win.undo");
edit->append("Redo", "win.redo");
menu->append_section(edit);
auto open = Gio::Menu::create(); auto open = Gio::Menu::create();
open->append("New Project", "win.new-project"); open->append("New Project", "win.new-project");
open->append("Open Project…", "win.open-project"); open->append("Open Project…", "win.open-project");
@@ -2267,12 +2506,27 @@ Gtk::Widget& EffectorWindow::buildMainMenu() {
} }
add_controller(shortcuts); add_controller(shortcuts);
// Undo and redo are caught before the focused widget sees them: an entry
// would otherwise undo its own text, and a spin button only the digits
// typed into it, rather than the step the project took.
auto history = Gtk::ShortcutController::create();
history->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
const Accel historyAccels[] = {{"<Control>z", "win.undo"},
{"<Control>y", "win.redo"},
{"<Control><Shift>z", "win.redo"}};
for (const Accel& accel : historyAccels) {
history->add_shortcut(Gtk::Shortcut::create(
Gtk::ShortcutTrigger::parse_string(accel.keys), Gtk::NamedAction::create(accel.action)));
}
add_controller(history);
return *Gtk::make_managed<Gtk::PopoverMenu>(menu); return *Gtk::make_managed<Gtk::PopoverMenu>(menu);
} }
Project EffectorWindow::currentProject() const { Project EffectorWindow::currentProject() const {
Project project; Project project;
for (const auto& image : *sources_) project.sources.push_back(image.path()); for (const auto& image : *sources_) project.sources.push_back(image.path());
project.sources.insert(project.sources.end(), refusedSources_.begin(), refusedSources_.end());
project.stages = stages_; project.stages = stages_;
project.styles = styles_; project.styles = styles_;
project.width = static_cast<int>(widthSpin_.get_value()); project.width = static_cast<int>(widthSpin_.get_value());
@@ -2305,6 +2559,7 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat
seedEntry_.set_text(std::to_string(seed_)); seedEntry_.set_text(std::to_string(seed_));
projectPath_ = path; projectPath_ = path;
lastExport_.clear();
updateTitle(); updateTitle();
clearError(); clearError();
suspendRender_ = false; suspendRender_ = false;
@@ -2314,12 +2569,15 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat
} else { } else {
addPaths(project.sources); // renders once the images are decoded addPaths(project.sources); // renders once the images are decoded
} }
historyAfterLoad_ = loading_;
resetHistory();
} }
void EffectorWindow::clearSources() { void EffectorWindow::clearSources() {
while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row); while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row);
updateSourcesLabel(); updateSourcesLabel();
mutateSources([](SourceList& list) { list.clear(); }); mutateSources([](SourceList& list) { list.clear(); });
refusedSources_.clear();
loadQueue_.clear(); loadQueue_.clear();
if (loading_) dropLoadedImages_ = true; if (loading_) dropLoadedImages_ = true;
@@ -2577,7 +2835,7 @@ void EffectorWindow::onSaveProjectAs() {
dialog->set_title("Save project"); dialog->set_title("Save project");
dialog->set_filters(projectFilters()); dialog->set_filters(projectFilters());
dialog->set_initial_name(projectPath_.empty() dialog->set_initial_name(projectPath_.empty()
? "effector-" + chainSlug(stages_) + ".json" ? "effector-" + chainSlug(stages_, isVideo()) + ".json"
: std::filesystem::path(projectPath_).filename().string()); : std::filesystem::path(projectPath_).filename().string());
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) { dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onSaveProjectChosen(result, dialog); onSaveProjectChosen(result, dialog);
@@ -2621,6 +2879,123 @@ void EffectorWindow::updateTitle() {
set_title(std::filesystem::path(projectPath_).stem().string() + " — Effector"); set_title(std::filesystem::path(projectPath_).stem().string() + " — Effector");
} }
// -------------------------------------------------------------- history ---
EffectorWindow::HistoryState EffectorWindow::captureState() const {
HistoryState state;
state.project = currentProject();
state.json = projectToJson(state.project);
state.sources = sources_;
state.refusedSources = refusedSources_;
state.selectedStage = selectedStage_;
return state;
}
// Puts a step back the way a project is opened, but with the images already
// in hand: the source list is the very one the step held.
void EffectorWindow::restoreState(const HistoryState& state) {
suspendHistory_ = true;
suspendRender_ = true;
closeStyleWindow(); // it may name a style the step does not hold
const bool wasVideo = isVideo();
const Project& project = state.project;
stages_ = project.stages;
styles_ = project.styles;
selectedStage_ =
stages_.empty() ? 0 : std::clamp(state.selectedStage, 0, static_cast<int>(stages_.size()) - 1);
widthSpin_.set_value(project.width);
heightSpin_.set_value(project.height);
setCanvasFormat(project.format, project.dpi);
setVideoSettings(project.video); // rebuilds the chain rows and the params
seed_ = project.seed.value_or(seed_);
seedEntry_.set_text(std::to_string(seed_));
if (state.sources != sources_) {
while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row);
sources_ = state.sources;
for (const auto& image : *sources_) appendSourceRow(image.path());
updateSourcesLabel();
}
refusedSources_ = state.refusedSources;
if (isVideo() != wasVideo) {
// What is on screen is the other kind of result, as in onVideoToggled.
result_ = Image();
saveButton_.set_sensitive(false);
}
clearError();
suspendRender_ = false;
requestRender();
suspendHistory_ = false;
}
void EffectorWindow::noteChange() {
if (suspendHistory_) return;
// Something is waiting to become a step, so there is something to undo
// even before the timer runs.
if (undoAction_) undoAction_->set_enabled(true);
historyCommit_.disconnect();
historyCommit_ = Glib::signal_timeout().connect(
[this] {
commitHistory();
return false;
},
kHistoryPause);
}
void EffectorWindow::commitHistory() {
historyCommit_.disconnect();
HistoryState now = captureState();
if (now.json != committed_.json) {
// The step remembers the stage being worked on as it was left, so
// undoing it lands on that stage again.
committed_.selectedStage = selectedStage_;
undo_.push_back(std::move(committed_));
if (undo_.size() > kHistoryDepth) undo_.erase(undo_.begin());
redo_.clear();
}
committed_ = std::move(now);
updateHistoryActions();
}
void EffectorWindow::resetHistory() {
historyCommit_.disconnect();
undo_.clear();
redo_.clear();
committed_ = captureState();
updateHistoryActions();
}
void EffectorWindow::undo() {
commitHistory(); // an edit still waiting on the timer is the step undone
if (undo_.empty()) return;
committed_.selectedStage = selectedStage_;
redo_.push_back(std::move(committed_));
const HistoryState previous = std::move(undo_.back());
undo_.pop_back();
restoreState(previous);
committed_ = captureState();
updateHistoryActions();
}
void EffectorWindow::redo() {
commitHistory(); // a fresh edit clears the redo list, as it should
if (redo_.empty()) return;
committed_.selectedStage = selectedStage_;
undo_.push_back(std::move(committed_));
const HistoryState next = std::move(redo_.back());
redo_.pop_back();
restoreState(next);
committed_ = captureState();
updateHistoryActions();
}
void EffectorWindow::updateHistoryActions() {
if (undoAction_) undoAction_->set_enabled(!undo_.empty());
if (redoAction_) redoAction_->set_enabled(!redo_.empty());
}
// --------------------------------------------------------------- saving --- // --------------------------------------------------------------- saving ---
void EffectorWindow::onSave() { void EffectorWindow::onSave() {
@@ -2629,8 +3004,27 @@ void EffectorWindow::onSave() {
auto dialog = Gtk::FileDialog::create(); auto dialog = Gtk::FileDialog::create();
dialog->set_title(video ? "Save video" : "Save image"); dialog->set_title(video ? "Save video" : "Save image");
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) + // The project's own folder when it has one, else where the last save went.
(video ? ".mp4" : ".png")); std::filesystem::path folder;
if (!projectPath_.empty()) {
folder = std::filesystem::path(projectPath_).parent_path();
} else if (!lastExport_.empty()) {
folder = std::filesystem::path(lastExport_).parent_path();
}
if (!folder.empty()) dialog->set_initial_folder(Gio::File::create_for_path(folder.string()));
// The last save's name counted on, so saving again never offers to
// overwrite it; its extension only when it is the same kind of file.
std::string name;
if (lastExport_.empty()) {
name = "effector-" + chainSlug(stages_, video) + "-" + std::to_string(seed_) +
(video ? ".mp4" : ".png");
} else {
std::filesystem::path last(lastExport_);
if (isVideoPath(last.string()) != video) last.replace_extension(video ? ".mp4" : ".png");
if (!folder.empty()) last = folder / last.filename();
name = std::filesystem::path(nextExportPath(last.string())).filename().string();
}
dialog->set_initial_name(name);
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) { dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onSaveChosen(result, dialog); onSaveChosen(result, dialog);
}); });
@@ -2666,6 +3060,7 @@ void EffectorWindow::onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
return; return;
} }
clearError(); clearError();
lastExport_ = path.string();
status_.set_text("Saved " + path.string()); status_.set_text("Saved " + path.string());
status_.set_tooltip_text("Saved " + path.string()); status_.set_tooltip_text("Saved " + path.string());
} }
@@ -2731,7 +3126,7 @@ void EffectorWindow::startExport(const std::string& path) {
exporter_ = std::thread([this, path, sources, stages, styles, width, height, seed, video] { exporter_ = std::thread([this, path, sources, stages, styles, width, height, seed, video] {
std::string error; std::string error;
try { try {
VideoWriter writer(path, width, height, video.fps); VideoWriter writer(path, width, height, video.fps, video.quality);
renderFrames(stages, *sources, width, height, seed, styles, video, renderFrames(stages, *sources, width, height, seed, styles, video,
[this, &writer](int, Image&& frame) { [this, &writer](int, Image&& frame) {
if (exportCancel_.load(std::memory_order_relaxed)) return false; if (exportCancel_.load(std::memory_order_relaxed)) return false;
@@ -2784,6 +3179,7 @@ void EffectorWindow::onExportFinished() {
return; return;
} }
clearError(); clearError();
lastExport_ = exportPath_;
status_.set_text("Saved " + exportPath_); status_.set_text("Saved " + exportPath_);
status_.set_tooltip_text("Saved " + exportPath_); status_.set_tooltip_text("Saved " + exportPath_);
} }
+57 -3
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"
@@ -69,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();
@@ -102,6 +103,7 @@ private:
// The preview of a video is the whole clip, rendered first and then looped. // The preview of a video is the whole clip, rendered first and then looped.
void onClipProgress(); // dispatcher: frames arrived from the worker void onClipProgress(); // dispatcher: frames arrived from the worker
void showIncomingClip(); // the clip in flight takes the screen, gaps held
void showClipFrame(int index); void showClipFrame(int index);
void startPlayback(); void startPlayback();
void stopPlayback(); void stopPlayback();
@@ -118,6 +120,7 @@ private:
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
@@ -158,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
@@ -203,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
@@ -239,12 +283,17 @@ private:
bool renderingVideo_ = false; // what the render in flight is making bool renderingVideo_ = false; // what the render in flight is making
// The clip being previewed: small frames, ready to show, and the frames // The clip being previewed: small frames, ready to show, and the frames
// of the clip being rendered, which take its place once it is complete. // of the clip being rendered, by index, which take its place once its
// draft frames are in (showIncomingClip).
Glib::Dispatcher clipProgress_; Glib::Dispatcher clipProgress_;
std::vector<Image> pendingFrames_; // handed over under resultMutex_ std::vector<std::pair<int, Image>> pendingFrames_; // by index, under resultMutex_
std::vector<Glib::RefPtr<Gdk::Texture>> clip_; std::vector<Glib::RefPtr<Gdk::Texture>> clip_;
std::vector<Glib::RefPtr<Gdk::Texture>> incomingClip_; std::vector<Glib::RefPtr<Gdk::Texture>> incomingClip_;
int incomingTotal_ = 0; 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 clipFps_ = kDefaultFps;
int playFrame_ = 0; int playFrame_ = 0;
bool playing_ = true; bool playing_ = true;
@@ -287,6 +336,7 @@ private:
Gtk::DropDown videoPresetDrop_; Gtk::DropDown videoPresetDrop_;
Gtk::SpinButton durationSpin_; Gtk::SpinButton durationSpin_;
Gtk::SpinButton fpsSpin_; 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 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.
@@ -301,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…"};
+24 -6
View File
@@ -25,7 +25,8 @@ int renderVideo(const effector::Options& options, const std::vector<effector::Ch
const bool showProgress = isatty(fileno(stderr)) != 0; const bool showProgress = isatty(fileno(stderr)) != 0;
const auto start = std::chrono::steady_clock::now(); const auto start = std::chrono::steady_clock::now();
effector::VideoWriter writer(options.output, options.width, options.height, options.video.fps); 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, effector::renderFrames(stages, sources, options.width, options.height, seed, {}, options.video,
[&](int index, effector::Image&& frame) { [&](int index, effector::Image&& frame) {
writer.write(frame); writer.write(frame);
@@ -42,8 +43,8 @@ int renderVideo(const effector::Options& options, const std::vector<effector::Ch
std::cout << options.output << " " << options.width << "x" << options.height << " " std::cout << options.output << " " << options.width << "x" << options.height << " "
<< count << " frames at " << options.video.fps << " fps effect=" << count << " frames at " << options.video.fps << " fps effect="
<< effector::chainSlug(stages) << " seed=" << seed << " render=" << std::fixed << effector::chainSlug(stages, true) << " seed=" << seed << " render="
<< std::setprecision(1) << seconds << " s\n"; << std::fixed << std::setprecision(1) << seconds << " s\n";
return 0; return 0;
} }
@@ -71,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
@@ -80,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 << " "
@@ -91,7 +108,7 @@ 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;
} }
@@ -108,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;
} }
@@ -129,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;
+485 -37
View File
@@ -20,6 +20,7 @@
#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"
@@ -34,6 +35,7 @@
#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 {
@@ -1067,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 &&
@@ -1194,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 {
@@ -1210,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);
@@ -1240,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);
@@ -1278,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());
@@ -1690,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;
@@ -1705,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");
@@ -1718,6 +1745,72 @@ 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"; std::cout << "video\n";
{ {
using effector::ChainStage; using effector::ChainStage;
@@ -1754,44 +1847,87 @@ int main() {
"a video is recognised by its extension"); "a video is recognised by its extension");
const auto split = effector::effectByName("channelsplit"); const auto split = effector::effectByName("channelsplit");
const auto tracking = effector::effectByName("tracking");
const std::vector<effector::Image> sources = {gradient(96, 64, 40)}; const std::vector<effector::Image> sources = {gradient(96, 64, 40)};
// A still ignores every motion param: the picture is the one it check(tracking->video() && !split->video(),
// always was, whatever they are set to. "VHS tracking is a video effect, channel split an image one");
auto calm = split->defaults(); bool imagesFirst = true;
calm["split_mode"] = std::string("per_band"); bool seenVideo = false;
auto wild = calm; for (const auto& effect : effects()) {
wild["split_wobble"] = 40L; imagesFirst = imagesFirst && (effect->video() || !seenVideo);
wild["split_jitter"] = 30L; seenVideo = seenVideo || effect->video();
wild["split_roll"] = 60L; }
effector::Rng stillA(5); check(imagesFirst, "the registry lists the image effects before the video effects");
effector::Rng stillB(5);
check(sameBytes(split->render(sources, 96, 64, stillA, calm),
split->renderFrame(sources, 96, 64, stillB, wild, FrameTime{})),
"motion params leave a still untouched");
check(split->animated(split->defaults()), "channel split moves by default in a video"); auto wild = tracking->defaults();
auto frozen = split->defaults(); wild["tracking_wobble"] = 40L;
frozen["split_wobble"] = 0L; wild["tracking_jitter"] = 30L;
frozen["split_jitter"] = 0L; wild["tracking_bar"] = 60L;
frozen["split_roll"] = 0L; auto frozen = tracking->defaults();
check(!split->animated(frozen), "and holds still with every motion at 0"); frozen["tracking_wobble"] = 0L;
frozen["tracking_jitter"] = 0L;
frozen["tracking_bar"] = 0L;
// A moving stage leaves the shared rng in the same state on every // 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. // frame, so what comes after it holds still.
effector::Rng frameA(9); effector::Rng frameA(9);
effector::Rng frameB(9); effector::Rng frameB(9);
split->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30}); tracking->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
split->renderFrame(sources, 96, 64, frameB, wild, FrameTime{17, 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"); 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 = {}) { auto stage = [](const std::string& name, effector::ParamValues values = {}) {
ChainStage built; ChainStage built;
built.effect = effector::effectByName(name); built.effect = effector::effectByName(name);
built.values = values.empty() ? built.effect->defaults() : std::move(values); built.values = values.empty() ? built.effect->defaults() : std::move(values);
return built; return built;
}; };
std::vector<ChainStage> chain = {stage("tiles"), stage("channelsplit", wild), stage("grain")}; std::vector<ChainStage> chain = {stage("tiles"), stage("tracking", wild), stage("grain")};
VideoSettings video; VideoSettings video;
video.enabled = true; video.enabled = true;
video.duration = 1.0; video.duration = 1.0;
@@ -1829,7 +1965,7 @@ int main() {
check(!sameBytes(first[0], first[3]) && !sameBytes(first[3], first[7]), check(!sameBytes(first[0], first[3]) && !sameBytes(first[3], first[7]),
"frames of a moving chain differ"); "frames of a moving chain differ");
std::vector<ChainStage> steady = {stage("tiles"), stage("channelsplit", frozen), stage("grain")}; std::vector<ChainStage> steady = {stage("tiles"), stage("channelsplit"), stage("grain")};
std::vector<effector::Image> held; std::vector<effector::Image> held;
effector::renderFrames(steady, sources, 80, 48, 21, {}, video, effector::renderFrames(steady, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) { [&](int, effector::Image&& frame) {
@@ -1840,13 +1976,32 @@ int main() {
for (const auto& frame : held) same = same && sameBytes(frame, held[0]); for (const auto& frame : held) same = same && sameBytes(frame, held[0]);
effector::Rng stillRng(21); effector::Rng stillRng(21);
check(same && sameBytes(held[0], effector::renderChain(steady, sources, 80, 48, stillRng)), check(same && sameBytes(held[0], effector::renderChain(steady, sources, 80, 48, stillRng)),
"a chain that does not move renders its still on every frame"); "a chain of image effects renders its still on every frame");
int seen = 0; int seen = 0;
effector::renderFrames(chain, sources, 80, 48, 21, {}, video, effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&&) { return ++seen < 3; }); [&](int, effector::Image&&) { return ++seen < 3; });
check(seen == 3, "returning false stops a clip early"); 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; chain[1].enabled = false;
std::vector<effector::Image> skipped; std::vector<effector::Image> skipped;
effector::renderFrames(chain, sources, 80, 48, 21, {}, video, effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
@@ -1862,9 +2017,277 @@ int main() {
check(small.width() == 100 && small.height() == 40, "a preview frame keeps its shape"); 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"); 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. // The project file: a video says so, a still says nothing about time.
effector::Project project; effector::Project project;
project.stages = {stage("channelsplit", wild)}; project.stages = {stage("tracking", wild)};
const auto stillJson = effector::projectToJson(project); const auto stillJson = effector::projectToJson(project);
check(stillJson.find("\"video\"") == std::string::npos, "a still project writes no video"); 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"); check(!effector::projectFromJson(stillJson).video.enabled, "and reads back as a still");
@@ -1877,12 +2300,22 @@ int main() {
back.video.preset == "instagram-story", back.video.preset == "instagram-story",
"a video project keeps its length, rate and preset"); "a video project keeps its length, rate and preset");
const auto motion = back.stages.at(0).values; const auto motion = back.stages.at(0).values;
check(effector::paramInt(motion, "split_jitter", 0) == 30 && check(effector::paramInt(motion, "tracking_jitter", 0) == 30 &&
effector::paramInt(motion, "split_roll", 0) == 60, effector::paramInt(motion, "tracking_bar", 0) == 60,
"and its stages keep their motion"); "and its stages keep their motion");
const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": "; const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": ";
check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(), check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(),
"an unknown preset reads as custom"); "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; bool rejected = false;
try { try {
effector::projectFromJson(base + "2, \"fps\": 500}}"); effector::projectFromJson(base + "2, \"fps\": 500}}");
@@ -1907,6 +2340,9 @@ int main() {
check(!parsedOptions({"--fps", "0"}) && !parsedOptions({"--duration", "abc"}) && check(!parsedOptions({"--fps", "0"}) && !parsedOptions({"--duration", "abc"}) &&
!parsedOptions({"--duration", "400"}) && !parsedOptions({"--video-preset", "vhs"}), !parsedOptions({"--duration", "400"}) && !parsedOptions({"--video-preset", "vhs"}),
"a bad rate, length or preset is refused"); "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"); check(!parsedOptions({"-o", "x.png"})->video.enabled, "without them the output is a still");
bool wrongName = false; bool wrongName = false;
@@ -1929,6 +2365,18 @@ int main() {
} }
check(std::filesystem::exists(file) && std::filesystem::file_size(file) > 0, check(std::filesystem::exists(file) && std::filesystem::file_size(file) > 0,
"ffmpeg writes an .mp4 of odd size"); "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(); const auto dropped = (dir / "dropped.webm").string();
{ {
effector::VideoWriter writer(dropped, 16, 16, 12); effector::VideoWriter writer(dropped, 16, 16, 12);
+296 -14
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 &&
@@ -453,7 +494,9 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
std::filesystem::create_directories(dir); std::filesystem::create_directories(dir);
const auto path = dir / "moving.json"; const auto path = dir / "moving.json";
effector::Project project; effector::Project project;
project.stages.push_back({effector::effectByName("channelsplit"), {}}); // needs no sources // 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); effector::saveProject(path.string(), project);
window.activate_action("win.open-recent", window.activate_action("win.open-recent",
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string()))); Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
@@ -466,25 +509,57 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
auto* width = named<Gtk::SpinButton>(window, "canvas-width"); auto* width = named<Gtk::SpinButton>(window, "canvas-width");
auto* height = named<Gtk::SpinButton>(window, "canvas-height"); auto* height = named<Gtk::SpinButton>(window, "canvas-height");
auto* playback = named<Gtk::Box>(window, "playback"); 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 && check(toggle != nullptr && preset != nullptr && duration != nullptr && width != nullptr &&
height != nullptr && playback != nullptr, height != nullptr && playback != nullptr,
"the video switch, its settings and the playback bar are there"); "the video switch, its settings and the playback bar are there");
if (toggle == nullptr || preset == nullptr || duration == nullptr || width == nullptr || if (toggle == nullptr || preset == nullptr || duration == nullptr || width == nullptr ||
height == nullptr || playback == nullptr) { height == nullptr || playback == nullptr || chainList == nullptr || add == nullptr) {
return; 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(isAncestor(*stack->get_child_by_name("setup"), *toggle), "the video switch is on Setup");
check(!toggle->get_active() && !preset->is_drawable() && !playback->get_visible(), check(!toggle->get_active() && !preset->is_drawable() && !playback->get_visible(),
"a still project shows no video settings and no playback"); "a still project shows no video settings and no playback");
check(!showsLabel(window, "Wobble"), "and no motion params for the channel split"); 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); toggle->set_active(true);
pump(200); 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"), check(showsLabel(window, "Wobble") && showsLabel(window, "Tracking bar"),
"a video shows the channel split's motion params"); "its settings are its motion");
stack->set_visible_child("setup"); stack->set_visible_child("setup");
pump(100); pump(100);
check(preset->is_drawable() && duration->is_drawable(), "and its preset and length"); 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 preset->set_selected(1); // Instagram story, the first preset
pump(200); pump(200);
@@ -509,11 +584,215 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
toggle->set_active(false); toggle->set_active(false);
pump(300); pump(300);
check(!playback->get_visible() && !showsLabel(window, "Wobble"), videoLabel = rowLabel(1);
"switched back to a still, the playback and motion params go"); 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"); 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) {
@@ -542,6 +821,9 @@ int main(int argc, char** argv) {
testHelpWindows(*window); testHelpWindows(*window);
testRecentProjects(*window, dataDir / "projects"); testRecentProjects(*window, dataDir / "projects");
testVideo(*window, dataDir / "video"); 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);