Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c8c5bed8c | ||
|
|
ef8f728126 | ||
|
|
5402a50742 | ||
|
|
f9fe6e0fa5 | ||
|
|
8acf85ddb8 | ||
|
|
cfaa773cf7 | ||
|
|
53dca6b4b8 | ||
|
|
4b407281e2 | ||
|
|
a7cb2505d7 | ||
|
|
ce0207c41f | ||
|
|
37c02a8a7b | ||
|
|
0377cd4abb | ||
|
|
370d3e550e | ||
|
|
8537a3cf0a | ||
|
|
ac8b62208c | ||
|
|
6df4bcb079 | ||
|
|
1b30a079e6 | ||
|
|
cdaadffc89 | ||
|
|
ea59db5acd | ||
|
|
385617781e | ||
|
|
c069de3566 |
+142
@@ -4,6 +4,148 @@ Every release of Effector, newest first. The version lives in one place, the
|
|||||||
`project()` line of `CMakeLists.txt`, and each release is a `vX.Y.Z` tag on the
|
`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.2] - 2026-09-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The Swap rate of Tile swap is a slider marked with its range, and goes
|
||||||
|
down in steps of 0.01 rather than 0.1, so a clip can swap its tiles much
|
||||||
|
more slowly. Its top end is now 5 swaps a second (it was 30).
|
||||||
|
- At a low Swap rate, Tile swap now moves only some of its tiles, fewer the
|
||||||
|
lower the rate, instead of swapping every tile at least twice a clip (or,
|
||||||
|
below that, none at all). Each step down the bottom of the slider now
|
||||||
|
looks slower than the one above it.
|
||||||
|
- The Swap rate slider is curved: its low end takes up most of its length
|
||||||
|
(1 swap a second sits just past the middle), so slow rates are easy to set.
|
||||||
|
|
||||||
|
## [0.8.1] - 2026-09-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- The Font of a text stage (and of a text style) is picked from the fonts
|
||||||
|
installed on this machine with a font chooser, rather than typed by name.
|
||||||
|
The command line still takes any family, as `--font-family "DejaVu Serif"`.
|
||||||
|
- Undo and redo: Ctrl+Z steps back through the edits to the project (the
|
||||||
|
chain, params, styles, canvas, video settings, seed and images), and Ctrl+Y
|
||||||
|
or Ctrl+Shift+Z steps forward again; both are in the menu as well. A burst
|
||||||
|
of edits, like a spin held down or a word typed, is one step.
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-09-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Tile swap, a video effect: tiles cut from the photographs are laid over the
|
||||||
|
canvas as Tiles lays them, and each one is swapped for another cut at a
|
||||||
|
moment of its own, so the mosaic keeps changing piece by piece. On the
|
||||||
|
command line it is `-e tileswap` with `--tileswap-rate`,
|
||||||
|
`--tileswap-layout` and the rest.
|
||||||
|
- Echo tunnel, a video effect: the picture nested inside itself as Feedback
|
||||||
|
nests it, flying into the echoes (or backing out of them) without end, a
|
||||||
|
new echo fading in at the far end as the nearest melts into the picture.
|
||||||
|
On the command line it is `-e tunnel` with `--tunnel-speed`,
|
||||||
|
`--tunnel-scale`, `--tunnel-direction` and the rest.
|
||||||
|
- Warp flow, a video effect: the picture pushed or swirled through a field
|
||||||
|
of noise as Warp does, with the field drifting so the picture ripples like
|
||||||
|
heat haze or water. On the command line it is `-e flow` with
|
||||||
|
`--flow-strength`, `--flow-travel`, `--flow-speed` and the rest.
|
||||||
|
- Sort sweep, a video effect: runs of pixels sorted as Pixel sort sorts
|
||||||
|
them, only within a window of brightness that sweeps from dark to bright
|
||||||
|
and back, so the streaks spread over one part of the picture after
|
||||||
|
another. On the command line it is `-e sweep` with `--sweep-from`,
|
||||||
|
`--sweep-to`, `--sweep-width`, `--sweep-speed` and the rest.
|
||||||
|
- Film gate, a video effect: film running through a projector, with grain
|
||||||
|
drawn anew on every frame as Film grain draws it, the picture swaying in
|
||||||
|
the gate, flicker, dust and scratches. On the command line it is
|
||||||
|
`-e filmgate` with `--gate-grain`, `--gate-weave`, `--gate-flicker`,
|
||||||
|
`--gate-dust` and `--gate-scratches`.
|
||||||
|
- Datamosh, a video effect: blocks of the picture break as Corrupt breaks
|
||||||
|
them, each for a stretch of the clip before it heals, and a displaced
|
||||||
|
block's picture smears along while it shows. On the command line it is
|
||||||
|
`-e datamosh` with `--mosh-rate`, `--mosh-hold`, `--mosh-drift`,
|
||||||
|
`--mosh-speed` and the rest.
|
||||||
|
- Strip roll, a video effect: the canvas cut into strips of the photographs
|
||||||
|
as Scanlines cuts it, and the picture in every strip rolling up or down
|
||||||
|
through its image, or sliding sideways, while the strips stay put. On the
|
||||||
|
command line it is `-e striproll` with `--roll-speed`, `--roll-axis`,
|
||||||
|
`--roll-direction` and the rest.
|
||||||
|
- Focus pull, a video effect: a lens being focused, either the whole
|
||||||
|
picture blurring as Blur blurs it and coming back sharp, or a band of
|
||||||
|
sharpness moving down the picture and back up. On the command line it is
|
||||||
|
`-e focuspull` with `--focus-mode`, `--focus-radius`, `--focus-band` and
|
||||||
|
`--focus-speed`.
|
||||||
|
- A Quality choice under Video on Setup: Small file, Standard or High, for
|
||||||
|
how the saved video is encoded. It is kept in the project, and on the
|
||||||
|
command line it is `--video-quality small|standard|high`. Standard writes
|
||||||
|
videos exactly as before.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Loading images shows its first pass too: the progress window counts the
|
||||||
|
files as their sizes are checked ("Checking sizes: 3 of 40") before it
|
||||||
|
counts the decodes, the headers are read in parallel, and Cancel works
|
||||||
|
during that pass as well. The command line shows a `sizing` line likewise.
|
||||||
|
- The video preview plays sooner: it renders every third frame first and
|
||||||
|
plays that draft, each frame held for three, then fills in the frames
|
||||||
|
between while it plays. The finished preview and the saved video are
|
||||||
|
unchanged.
|
||||||
|
- Save opens in the project's folder, and after the first save it offers the
|
||||||
|
last saved file's name with a number counted on (`city.png`, then
|
||||||
|
`city-1.png`, `city-2.png`), skipping names already taken.
|
||||||
|
- The Add effect menu gives the video effects more room before their list
|
||||||
|
scrolls, now that there are more of them.
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-09-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Effects are image effects or video effects. The Add effect menu lists them
|
||||||
|
in two sections, each under its own heading, and offers video effects only
|
||||||
|
in a video project. In the chain a video stage has a film icon, and in a
|
||||||
|
still it is dimmed and skipped, like a stage that is switched off.
|
||||||
|
- VHS tracking, the first video effect: the colour planes sway and shake, and
|
||||||
|
a tracking bar rolls down the picture. On the command line it is
|
||||||
|
`-e tracking` with `--tracking-wobble`, `--tracking-jitter`, `--tracking-bar`
|
||||||
|
and the rest.
|
||||||
|
- `--help` lists the video effects under a heading of their own, and
|
||||||
|
`--list-effects` marks them. A video effect given for a still is skipped
|
||||||
|
with a warning.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Channel split is an image effect again. Its motion (Wobble, Jitter, Tracking
|
||||||
|
bar) has moved to VHS tracking, so a 0.6 video project that moved its channel
|
||||||
|
split needs a VHS tracking stage after it. Stills are unchanged.
|
||||||
|
|
||||||
|
## [0.6.1] - 2026-09-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Opening a project whose images would not fit in memory no longer gets
|
||||||
|
Effector killed by the system. The images are measured before any is read,
|
||||||
|
and a batch that would not fit is refused with a message saying how much it
|
||||||
|
needs; the images stay in the project, so saving it keeps them.
|
||||||
|
- A canvas too large to render in the free memory is refused the same way,
|
||||||
|
in the interface and on the command line, instead of taking the machine's
|
||||||
|
memory down with it.
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-09-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Video projects: switch Video on under Setup and give the clip a length and a
|
||||||
|
frame rate, or pick a preset that sets the canvas too: Instagram story,
|
||||||
|
Instagram reel, Instagram feed video, TikTok / YouTube Short, Square loop and
|
||||||
|
Full HD clip. The preview renders the clip and loops it, with play/pause and
|
||||||
|
a scale to look at any one frame.
|
||||||
|
- Save As… writes a video project as `.mp4`, `.mov`, `.mkv`, `.webm` or `.gif`
|
||||||
|
through `ffmpeg`, with a progress window that can cancel it.
|
||||||
|
- Channel split moves in a video: its channels sway, shake from frame to frame
|
||||||
|
and a tracking bar rolls down the picture, like an old tape on a tube. The
|
||||||
|
new Wobble, Jitter and Tracking bar settings show only in a video, and a
|
||||||
|
still renders exactly as before.
|
||||||
|
- `--duration`, `--fps` and `--video-preset` render a video from the command
|
||||||
|
line.
|
||||||
|
- A Vertical Full HD canvas format, 1080 × 1920.
|
||||||
|
|
||||||
|
## [0.5.3] - 2026-09-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Effector has its own icon, an E split into its colour channels, in the app
|
||||||
|
menu, the dock and About Effector; a one-colour version goes with it for
|
||||||
|
panels.
|
||||||
|
|
||||||
## [0.5.2] - 2026-09-26
|
## [0.5.2] - 2026-09-26
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way.
|
|||||||
The core test suite is a single hand-rolled binary (no framework), so there is no
|
The core test suite is a single hand-rolled binary (no framework), so there is no
|
||||||
per-test filter: run `./build/tests/effector_tests` directly to see the section
|
per-test filter: run `./build/tests/effector_tests` directly to see the section
|
||||||
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
|
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
|
||||||
`image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment
|
`image io`, `batch loading`, `video`, `version`) and the `ok`/`FAIL` lines. The
|
||||||
|
`video` section writes real files through `ffmpeg` when it is on the PATH and
|
||||||
|
prints a `skip` line otherwise. To iterate on one area, comment
|
||||||
out the other blocks in `tests/core_tests.cpp` or add a new
|
out the other blocks in `tests/core_tests.cpp` or add a new
|
||||||
`std::cout << "section\n"` block — checks are plain
|
`std::cout << "section\n"` block — checks are plain
|
||||||
`check(condition, "description")` calls and `main()` returns the failure count.
|
`check(condition, "description")` calls and `main()` returns the failure count.
|
||||||
@@ -71,11 +73,19 @@ GUI or the parser — add a `ParamSpec` instead. A `Path` param names a file on
|
|||||||
disk; like a source path it is written relative to the project folder. A `Text`
|
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`.
|
||||||
|
|
||||||
|
An Int or Double param marked `.slider()` is shown in
|
||||||
|
the interface as a slider with its minimum and maximum marked instead of a spin
|
||||||
|
button; nothing else changes. `.slider(curve)` bends it: the scale runs over the
|
||||||
|
slider's travel and `sliderValue`/`sliderPosition` map that onto the number as
|
||||||
|
`position^curve`, so a curve above 1 gives the low end more room.
|
||||||
|
|
||||||
A param can also be marked `.styled()`, which says a *named style* carries it
|
A param can also be marked `.styled()`, which says a *named style* carries it
|
||||||
rather than the stage it sits on: the font and the colour of a text stage, but
|
rather than the stage it sits on: the font and the colour of a text stage, but
|
||||||
never its words or its placement. The pool a style comes from is named by
|
never its words or its placement. The pool a style comes from is named by
|
||||||
@@ -93,7 +103,8 @@ first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`)
|
|||||||
just asks the first stage — a chain led by blur renders with no images loaded at
|
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.
|
||||||
@@ -170,11 +181,91 @@ the `effector` application name on each entry; the GUI tests point
|
|||||||
`XDG_DATA_HOME` at a temporary folder so they never touch the user's list. Opening one replaces the sources, so a decode still in flight
|
`XDG_DATA_HOME` at a temporary folder so they never touch the user's list. Opening one replaces the sources, so a decode still in flight
|
||||||
is discarded rather than mixed into the new project (`dropLoadedImages_`).
|
is discarded rather than mixed into the new project (`dropLoadedImages_`).
|
||||||
|
|
||||||
|
Undo and redo (Ctrl+Z, Ctrl+Y / Ctrl+Shift+Z, caught in the capture phase so
|
||||||
|
an entry never undoes its own text instead) keep whole snapshots of the
|
||||||
|
project: `captureState` is `currentProject()` plus the `sources_` list itself,
|
||||||
|
so stepping back never decodes again, compared by `projectToJson`. There is
|
||||||
|
nothing to call per edit: `requestRender()` calls `noteChange()`, which commits
|
||||||
|
a step once the controls have been still for `kHistoryPause`; an edit that
|
||||||
|
renders nothing (renaming a style) calls `noteChange()` itself. New and Open
|
||||||
|
start the history over, after the project's images are in
|
||||||
|
(`historyAfterLoad_`).
|
||||||
|
|
||||||
|
### Video projects
|
||||||
|
|
||||||
|
A project is a still or a clip: `VideoSettings` (`src/core/video.hpp`: enabled,
|
||||||
|
duration, fps, preset) rides in `Project::video` and `Options::video`, and is
|
||||||
|
written to the project file only when enabled. `videoPresets()` is one table
|
||||||
|
like `canvasFormats()` — a preset names a canvas format plus a length and rate,
|
||||||
|
and like a format it is the presentation, never the truth: the render goes by
|
||||||
|
duration and fps. Adding a preset is a line there.
|
||||||
|
|
||||||
|
Effects are of two kinds, and `Effect::video()` says which. An *image effect*
|
||||||
|
(the default) makes a picture and holds still in a clip; it overrides only
|
||||||
|
`render`, which `Effect::renderFrame` falls back to. A *video effect* is motion
|
||||||
|
and nothing else. `enabledStages(stages, video)` is where the kinds meet the
|
||||||
|
chain: in a still, a video stage is left out exactly like a disabled one,
|
||||||
|
drawing nothing from the rng. So `renderChain` (by `frame.video()`),
|
||||||
|
`chainNeedsSources`, `chainTitle` and `chainSlug` all take whether the render is
|
||||||
|
a video. The interface builds the split from `video()` alone: two sections in
|
||||||
|
the Add effect menu, with the video section insensitive in a still, a
|
||||||
|
`camera-video-symbolic` icon on a video stage's row, and the row dimmed while a
|
||||||
|
still skips it. The CLI groups `--help` the same way and warns when it skips one.
|
||||||
|
|
||||||
|
A video effect subclasses `VideoEffect` (`src/effects/video_effect.hpp`), not
|
||||||
|
`Effect`, and a transform writes only `animate(input, values, const Moment& at)`. The base
|
||||||
|
keeps the rules. It takes the input crop and exactly one value from the shared
|
||||||
|
rng, the same on every frame, and hands out randomness through the `Moment`:
|
||||||
|
`at.steady()` gives the same draws every frame (phases, directions),
|
||||||
|
`at.fresh()` gives new draws per frame (shake, noise). It hands out time as
|
||||||
|
`at.cycle(perSecond)`, rounded through `loopRate` to a whole number of cycles so
|
||||||
|
the clip loops without a jump. `TrackingEffect` (`src/effects/tracking.cpp`,
|
||||||
|
"VHS tracking") is the reference: copy its layout (params, a `Settings` struct,
|
||||||
|
steady draws, time, fresh draws, one parallel pixel pass) for the next one.
|
||||||
|
A video effect that builds its frames out of the sources rather than
|
||||||
|
transforming a crop answers `needsSources()` and writes `compose(sources,
|
||||||
|
width, height, values, at)` instead of `animate`: the base then takes no crop,
|
||||||
|
only the seed, and like an image effect it sees the photographs when it leads
|
||||||
|
the chain and the stage before it otherwise. `TileSwapEffect`
|
||||||
|
(`src/effects/tileswap.cpp`, "Tile swap") is the reference for that kind; it
|
||||||
|
shares `Tile`/`planSpans`/`paintTiles` with the tiles effect through
|
||||||
|
`src/effects/tile_plan.hpp`.
|
||||||
|
Registering it is the same one line in `registry.cpp`, after the comment that
|
||||||
|
starts the video effects. There is no per-param video flag: motion is a video
|
||||||
|
effect of its own, never extra params on an image effect.
|
||||||
|
|
||||||
|
`renderFrames` (`src/core/chain.cpp`) renders a clip. Every frame starts from
|
||||||
|
`Rng(seed)`, so an image effect draws the same values every frame and holds
|
||||||
|
still, and because `VideoEffect` leaves the shared rng in one state, the stages
|
||||||
|
after a video effect hold still too. The stages before the first video effect
|
||||||
|
are rendered once and their image and rng state reused. The `video` tests
|
||||||
|
assert that frame k is exactly `renderChain(..., FrameTime{k, ...})`, so that
|
||||||
|
shortcut can never change output.
|
||||||
|
The determinism contract covers the frames; the encoded file is whatever the
|
||||||
|
machine's ffmpeg makes of them.
|
||||||
|
|
||||||
|
`VideoWriter` pipes raw `rgb24` frames into the `ffmpeg` program
|
||||||
|
(`posix_spawnp` with an argv — no shell; `SIGPIPE` ignored; stderr captured for
|
||||||
|
the error message; codec by extension). `cancel()` SIGKILLs it — SIGTERM makes
|
||||||
|
ffmpeg flush its encoder first — and removes the file; a writer destroyed
|
||||||
|
unfinished cancels itself.
|
||||||
|
|
||||||
|
In the interface, a video render (`startRender`) renders every frame at full
|
||||||
|
size, shrinks each (`Image::scaledToFit`, sized by `clipSide` to a memory
|
||||||
|
budget) and hands them over through `pendingFrames_`/`clipProgress_`; the
|
||||||
|
clip on screen keeps looping until the new one is complete, and
|
||||||
|
`renderCancel_` stops a clip whose settings went stale. Save As… renders again
|
||||||
|
at full size on `exporter_` into a `VideoWriter`, with an `exportWindow_`
|
||||||
|
progress window (its `exportBar_` nulled in `closeExportWindow()`). Under Xvfb
|
||||||
|
the GTK file dialog goes through the desktop portal and may open on the real
|
||||||
|
session instead; set `GDK_DEBUG=no-portals` to keep it in the Xvfb display.
|
||||||
|
|
||||||
### Two link targets, deliberately
|
### Two link targets, deliberately
|
||||||
|
|
||||||
`effector_core` (static) holds image IO, params, effects, rng and links only
|
`effector_core` (static) holds image IO, params, effects, rng, video writing
|
||||||
`gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need
|
and links only `gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango
|
||||||
no display) + Threads — **no gtkmm, no display**. `effector_gui` (static) adds
|
and Cairo need no display) + Threads — **no gtkmm, no display**; `ffmpeg` is a
|
||||||
|
program it runs, not a library it links. `effector_gui` (static) adds
|
||||||
`src/cli/` and `src/gui/` and links gtkmm, and the `effector` executable is
|
`src/cli/` and `src/gui/` and links gtkmm, and the `effector` executable is
|
||||||
`src/main.cpp` on top of it; the GUI tests link `effector_gui` too. Keep GTK out of
|
`src/main.cpp` on top of it; the GUI tests link `effector_gui` too. Keep GTK out of
|
||||||
`src/core/` and `src/effects/`; the tests link the core alone and rely on this.
|
`src/core/` and `src/effects/`; the tests link the core alone and rely on this.
|
||||||
@@ -214,6 +305,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
|
||||||
|
|||||||
+18
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
cmake_minimum_required(VERSION 3.20)
|
||||||
project(effector VERSION 0.5.2 LANGUAGES CXX)
|
project(effector VERSION 0.8.2 LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
@@ -35,16 +35,22 @@ add_library(effector_core STATIC
|
|||||||
src/core/chain.cpp
|
src/core/chain.cpp
|
||||||
src/core/effect.cpp
|
src/core/effect.cpp
|
||||||
src/core/image.cpp
|
src/core/image.cpp
|
||||||
|
src/core/memory.cpp
|
||||||
src/core/params.cpp
|
src/core/params.cpp
|
||||||
src/core/project.cpp
|
src/core/project.cpp
|
||||||
src/core/registry.cpp
|
src/core/registry.cpp
|
||||||
src/core/rng.cpp
|
src/core/rng.cpp
|
||||||
src/core/style.cpp
|
src/core/style.cpp
|
||||||
|
src/core/video.cpp
|
||||||
src/effects/bitcrush.cpp
|
src/effects/bitcrush.cpp
|
||||||
src/effects/blur.cpp
|
src/effects/blur.cpp
|
||||||
src/effects/channelsplit.cpp
|
src/effects/channelsplit.cpp
|
||||||
src/effects/corrupt.cpp
|
src/effects/corrupt.cpp
|
||||||
|
src/effects/datamosh.cpp
|
||||||
src/effects/feedback.cpp
|
src/effects/feedback.cpp
|
||||||
|
src/effects/filmgate.cpp
|
||||||
|
src/effects/focuspull.cpp
|
||||||
|
src/effects/flow.cpp
|
||||||
src/effects/grain.cpp
|
src/effects/grain.cpp
|
||||||
src/effects/monochrome.cpp
|
src/effects/monochrome.cpp
|
||||||
src/effects/overlay.cpp
|
src/effects/overlay.cpp
|
||||||
@@ -52,8 +58,14 @@ add_library(effector_core STATIC
|
|||||||
src/effects/scanlines.cpp
|
src/effects/scanlines.cpp
|
||||||
src/effects/shatter.cpp
|
src/effects/shatter.cpp
|
||||||
src/effects/slitshift.cpp
|
src/effects/slitshift.cpp
|
||||||
|
src/effects/striproll.cpp
|
||||||
|
src/effects/sweep.cpp
|
||||||
src/effects/text.cpp
|
src/effects/text.cpp
|
||||||
src/effects/tiles.cpp
|
src/effects/tiles.cpp
|
||||||
|
src/effects/tileswap.cpp
|
||||||
|
src/effects/tracking.cpp
|
||||||
|
src/effects/tunnel.cpp
|
||||||
|
src/effects/video_effect.cpp
|
||||||
src/effects/warp.cpp
|
src/effects/warp.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(effector_core PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
target_include_directories(effector_core PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
||||||
@@ -70,6 +82,9 @@ add_library(effector_gui STATIC
|
|||||||
)
|
)
|
||||||
target_link_libraries(effector_gui PUBLIC effector_core PkgConfig::GTKMM)
|
target_link_libraries(effector_gui PUBLIC effector_core PkgConfig::GTKMM)
|
||||||
target_compile_options(effector_gui PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(effector_gui PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
# Where a build that was never installed finds its own icon (see app.cpp).
|
||||||
|
target_compile_definitions(effector_gui PRIVATE
|
||||||
|
EFFECTOR_SOURCE_ICON_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data/icons")
|
||||||
|
|
||||||
# --- application --------------------------------------------------------------
|
# --- application --------------------------------------------------------------
|
||||||
add_executable(effector src/main.cpp)
|
add_executable(effector src/main.cpp)
|
||||||
@@ -79,6 +94,8 @@ target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic)
|
|||||||
install(TARGETS effector RUNTIME DESTINATION bin)
|
install(TARGETS effector RUNTIME DESTINATION bin)
|
||||||
# The launcher entry, so the interface shows up in the desktop's app menu.
|
# The launcher entry, so the interface shows up in the desktop's app menu.
|
||||||
install(FILES data/org.effector.Effector.desktop DESTINATION share/applications)
|
install(FILES data/org.effector.Effector.desktop DESTINATION share/applications)
|
||||||
|
# The icon it names, in full colour and as a one-colour symbolic for panels.
|
||||||
|
install(DIRECTORY data/icons/hicolor DESTINATION share/icons)
|
||||||
|
|
||||||
# --- tests --------------------------------------------------------------------
|
# --- tests --------------------------------------------------------------------
|
||||||
include(CTest)
|
include(CTest)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ those declarations, so a new effect needs no UI or CLI code.
|
|||||||
|
|
||||||
Requirements: a C++20 compiler (GCC 13+), CMake 3.20+, `gtkmm-4.0` and
|
Requirements: a C++20 compiler (GCC 13+), CMake 3.20+, `gtkmm-4.0` and
|
||||||
`gdk-pixbuf-2.0` development packages. On Arch: `gcc cmake ninja gtkmm-4.0`.
|
`gdk-pixbuf-2.0` development packages. On Arch: `gcc cmake ninja gtkmm-4.0`.
|
||||||
|
Writing videos needs the `ffmpeg` program at run time (on Arch: `ffmpeg`);
|
||||||
|
without it everything else works and the video tests are skipped.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||||
@@ -58,6 +60,37 @@ Custom hands them back, keeping whatever the format last worked out to. A
|
|||||||
format is saved with the project, so an A4 project reopens as A4 rather than as
|
format is saved with the project, so an A4 project reopens as A4 rather than as
|
||||||
the pixels it happened to be.
|
the pixels it happened to be.
|
||||||
|
|
||||||
|
### Video
|
||||||
|
|
||||||
|
A project can be a video clip instead of a still: switch **Video** on under
|
||||||
|
Setup and give it a length and a frame rate, or pick a preset that sets the
|
||||||
|
canvas too — Instagram story (1080×1920, 15 s), Instagram reel (30 s),
|
||||||
|
Instagram feed video, TikTok / YouTube Short, Square loop (6 s) and Full HD
|
||||||
|
clip. The preview renders the whole clip first, shrunk to fit in memory, and
|
||||||
|
then loops it, with play/pause and a scale to look at any one frame. **Save
|
||||||
|
As…** then writes the video at full size through `ffmpeg`, which has to be
|
||||||
|
installed: `.mp4`, `.mov` and `.mkv` as H.264, `.webm` as VP9, `.gif` as a
|
||||||
|
palette GIF.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
build/effector -o story.mp4 --video-preset instagram-story -e channelsplit -e tracking --seed 1 photo.jpg
|
||||||
|
build/effector -o loop.gif -s 480x480 --duration 3 --fps 15 -e tracking --tracking-bar 40 photo.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
`--duration SECONDS` and `--fps N` make any run a video; a preset fills in
|
||||||
|
whatever was not given alongside it.
|
||||||
|
|
||||||
|
Effects come in two kinds. **Image effects** make a picture and hold still in a
|
||||||
|
clip. **Video effects** are motion: the Add effect menu lists them under a
|
||||||
|
heading of their own, and they can only be added once the project is a video.
|
||||||
|
In a still project a video stage is marked as skipped and the render leaves it
|
||||||
|
out, as it would a stage that is switched off. For now there is one video
|
||||||
|
effect, **VHS tracking**: the colour planes sway and shake, and a tracking bar
|
||||||
|
rolls down the picture, like a worn tape on an old tube. Put it after the
|
||||||
|
image effects it should shake. Every frame starts from the same seed, so the
|
||||||
|
image effects hold still while a video effect moves them, and a clip renders
|
||||||
|
frame for frame the same again.
|
||||||
|
|
||||||
Any option can also be passed when launching the interface; it becomes the
|
Any option can also be passed when launching the interface; it becomes the
|
||||||
starting state (images, canvas size, effect settings, seed).
|
starting state (images, canvas size, effect settings, seed).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||||
|
<!-- Effector: an E split into its colour channels, two slices slipped sideways -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2b2837"/>
|
||||||
|
<stop offset="1" stop-color="#15131c"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="face"><rect x="12" y="10" width="104" height="104" rx="24"/></clipPath>
|
||||||
|
<clipPath id="band0"><rect x="0" y="20" width="128" height="30"/></clipPath>
|
||||||
|
<clipPath id="band1"><rect x="0" y="50" width="128" height="9"/></clipPath>
|
||||||
|
<clipPath id="band2"><rect x="0" y="59" width="128" height="15"/></clipPath>
|
||||||
|
<clipPath id="band3"><rect x="0" y="74" width="128" height="6"/></clipPath>
|
||||||
|
<clipPath id="band4"><rect x="0" y="80" width="128" height="20"/></clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect x="12" y="16" width="104" height="104" rx="24" fill="#0b0a10"/>
|
||||||
|
<g clip-path="url(#face)">
|
||||||
|
<rect x="12" y="10" width="104" height="104" fill="url(#bg)"/>
|
||||||
|
<g fill="#ffffff" opacity=".05">
|
||||||
|
<rect x="12" y="12" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="16" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="20" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="24" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="28" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="32" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="36" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="40" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="44" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="48" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="52" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="56" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="60" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="64" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="68" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="72" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="76" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="80" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="84" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="88" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="92" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="96" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="100" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="104" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="108" width="104" height="1.4"/>
|
||||||
|
<rect x="12" y="112" width="104" height="1.4"/>
|
||||||
|
</g>
|
||||||
|
<g clip-path="url(#band0)"><g transform="translate(0 0)"><g transform="translate(-5 0)"><rect x="40" y="30" width="16" height="62" fill="#00e5ff"/><rect x="40" y="30" width="48" height="14" fill="#00e5ff"/><rect x="40" y="54" width="40" height="13" fill="#00e5ff"/><rect x="40" y="78" width="48" height="14" fill="#00e5ff"/></g><g transform="translate(5 0)"><rect x="40" y="30" width="16" height="62" fill="#ff2e88"/><rect x="40" y="30" width="48" height="14" fill="#ff2e88"/><rect x="40" y="54" width="40" height="13" fill="#ff2e88"/><rect x="40" y="78" width="48" height="14" fill="#ff2e88"/></g><rect x="40" y="30" width="16" height="62" fill="#ffffff"/><rect x="40" y="30" width="48" height="14" fill="#ffffff"/><rect x="40" y="54" width="40" height="13" fill="#ffffff"/><rect x="40" y="78" width="48" height="14" fill="#ffffff"/></g></g>
|
||||||
|
<g clip-path="url(#band1)"><g transform="translate(9 0)"><g transform="translate(-5 0)"><rect x="40" y="30" width="16" height="62" fill="#00e5ff"/><rect x="40" y="30" width="48" height="14" fill="#00e5ff"/><rect x="40" y="54" width="40" height="13" fill="#00e5ff"/><rect x="40" y="78" width="48" height="14" fill="#00e5ff"/></g><g transform="translate(5 0)"><rect x="40" y="30" width="16" height="62" fill="#ff2e88"/><rect x="40" y="30" width="48" height="14" fill="#ff2e88"/><rect x="40" y="54" width="40" height="13" fill="#ff2e88"/><rect x="40" y="78" width="48" height="14" fill="#ff2e88"/></g><rect x="40" y="30" width="16" height="62" fill="#ffffff"/><rect x="40" y="30" width="48" height="14" fill="#ffffff"/><rect x="40" y="54" width="40" height="13" fill="#ffffff"/><rect x="40" y="78" width="48" height="14" fill="#ffffff"/></g></g>
|
||||||
|
<g clip-path="url(#band2)"><g transform="translate(0 0)"><g transform="translate(-5 0)"><rect x="40" y="30" width="16" height="62" fill="#00e5ff"/><rect x="40" y="30" width="48" height="14" fill="#00e5ff"/><rect x="40" y="54" width="40" height="13" fill="#00e5ff"/><rect x="40" y="78" width="48" height="14" fill="#00e5ff"/></g><g transform="translate(5 0)"><rect x="40" y="30" width="16" height="62" fill="#ff2e88"/><rect x="40" y="30" width="48" height="14" fill="#ff2e88"/><rect x="40" y="54" width="40" height="13" fill="#ff2e88"/><rect x="40" y="78" width="48" height="14" fill="#ff2e88"/></g><rect x="40" y="30" width="16" height="62" fill="#ffffff"/><rect x="40" y="30" width="48" height="14" fill="#ffffff"/><rect x="40" y="54" width="40" height="13" fill="#ffffff"/><rect x="40" y="78" width="48" height="14" fill="#ffffff"/></g></g>
|
||||||
|
<g clip-path="url(#band3)"><g transform="translate(-7 0)"><g transform="translate(-5 0)"><rect x="40" y="30" width="16" height="62" fill="#00e5ff"/><rect x="40" y="30" width="48" height="14" fill="#00e5ff"/><rect x="40" y="54" width="40" height="13" fill="#00e5ff"/><rect x="40" y="78" width="48" height="14" fill="#00e5ff"/></g><g transform="translate(5 0)"><rect x="40" y="30" width="16" height="62" fill="#ff2e88"/><rect x="40" y="30" width="48" height="14" fill="#ff2e88"/><rect x="40" y="54" width="40" height="13" fill="#ff2e88"/><rect x="40" y="78" width="48" height="14" fill="#ff2e88"/></g><rect x="40" y="30" width="16" height="62" fill="#ffffff"/><rect x="40" y="30" width="48" height="14" fill="#ffffff"/><rect x="40" y="54" width="40" height="13" fill="#ffffff"/><rect x="40" y="78" width="48" height="14" fill="#ffffff"/></g></g>
|
||||||
|
<g clip-path="url(#band4)"><g transform="translate(0 0)"><g transform="translate(-5 0)"><rect x="40" y="30" width="16" height="62" fill="#00e5ff"/><rect x="40" y="30" width="48" height="14" fill="#00e5ff"/><rect x="40" y="54" width="40" height="13" fill="#00e5ff"/><rect x="40" y="78" width="48" height="14" fill="#00e5ff"/></g><g transform="translate(5 0)"><rect x="40" y="30" width="16" height="62" fill="#ff2e88"/><rect x="40" y="30" width="48" height="14" fill="#ff2e88"/><rect x="40" y="54" width="40" height="13" fill="#ff2e88"/><rect x="40" y="78" width="48" height="14" fill="#ff2e88"/></g><rect x="40" y="30" width="16" height="62" fill="#ffffff"/><rect x="40" y="30" width="48" height="14" fill="#ffffff"/><rect x="40" y="54" width="40" height="13" fill="#ffffff"/><rect x="40" y="78" width="48" height="14" fill="#ffffff"/></g></g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Effector, one colour: the E with its middle slice slipped sideways -->
|
||||||
|
<g fill="#2e3436">
|
||||||
|
<rect x="3" y="2" width="10" height="3"/>
|
||||||
|
<rect x="3" y="5" width="3" height="1.5"/>
|
||||||
|
<rect x="5" y="6.5" width="8" height="3"/>
|
||||||
|
<rect x="3" y="9.5" width="3" height="1.5"/>
|
||||||
|
<rect x="3" y="11" width="10" height="3"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 433 B |
@@ -4,7 +4,7 @@ Name=Effector
|
|||||||
GenericName=Image Effects
|
GenericName=Image Effects
|
||||||
Comment=Cascade glitch and collage effects over your photographs
|
Comment=Cascade glitch and collage effects over your photographs
|
||||||
Exec=effector %F
|
Exec=effector %F
|
||||||
Icon=applications-graphics
|
Icon=org.effector.Effector
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Graphics;2DGraphics;RasterGraphics;GTK;
|
Categories=Graphics;2DGraphics;RasterGraphics;GTK;
|
||||||
MimeType=image/jpeg;image/png;image/webp;image/tiff;image/bmp;
|
MimeType=image/jpeg;image/png;image/webp;image/tiff;image/bmp;
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
# so pkgver must follow the project() line of CMakeLists.txt (the version core
|
# so pkgver must follow the project() line of CMakeLists.txt (the version core
|
||||||
# test checks it). From this folder: makepkg -si
|
# test checks it). From this folder: makepkg -si
|
||||||
pkgname=effector
|
pkgname=effector
|
||||||
pkgver=0.5.2
|
pkgver=0.8.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
url='https://git.plan9.uber.space/hans/effector'
|
url='https://git.plan9.uber.space/hans/effector'
|
||||||
license=('GPL-3.0-or-later')
|
license=('GPL-3.0-or-later')
|
||||||
depends=('gtkmm-4.0' 'gdk-pixbuf2' 'pango' 'cairo' 'glib2' 'glibc' 'gcc-libs')
|
depends=('gtkmm-4.0' 'gdk-pixbuf2' 'pango' 'cairo' 'glib2' 'glibc' 'gcc-libs')
|
||||||
|
optdepends=('ffmpeg: write video projects')
|
||||||
makedepends=('cmake' 'ninja' 'git')
|
makedepends=('cmake' 'ninja' 'git')
|
||||||
checkdepends=('xorg-server-xvfb')
|
checkdepends=('xorg-server-xvfb')
|
||||||
source=("$pkgname::git+file://${startdir%/packaging/arch}#tag=v$pkgver")
|
source=("$pkgname::git+file://${startdir%/packaging/arch}#tag=v$pkgver")
|
||||||
|
|||||||
+91
-1
@@ -55,6 +55,11 @@ std::string requireValue(int argc, char** argv, int& i, const std::string& flag)
|
|||||||
|
|
||||||
Options parseOptions(int argc, char** argv) {
|
Options parseOptions(int argc, char** argv) {
|
||||||
Options options;
|
Options options;
|
||||||
|
// What was given explicitly, so that a --video-preset fills in only the
|
||||||
|
// rest, whichever order the flags come in.
|
||||||
|
bool sizeGiven = false;
|
||||||
|
bool durationGiven = false;
|
||||||
|
bool fpsGiven = false;
|
||||||
|
|
||||||
for (int i = 1; i < argc; ++i) {
|
for (int i = 1; i < argc; ++i) {
|
||||||
const std::string arg = argv[i];
|
const std::string arg = argv[i];
|
||||||
@@ -73,6 +78,7 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
options.output = requireValue(argc, argv, i, arg);
|
options.output = requireValue(argc, argv, i, arg);
|
||||||
} else if (arg == "-s" || arg == "--size") {
|
} else if (arg == "-s" || arg == "--size") {
|
||||||
const std::string value = requireValue(argc, argv, i, arg);
|
const std::string value = requireValue(argc, argv, i, arg);
|
||||||
|
sizeGiven = true;
|
||||||
if (!parseSize(value, options.width, options.height)) {
|
if (!parseSize(value, options.width, options.height)) {
|
||||||
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with "
|
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with "
|
||||||
"each side between " + std::to_string(kMinCanvasSide) + " and " +
|
"each side between " + std::to_string(kMinCanvasSide) + " and " +
|
||||||
@@ -92,6 +98,47 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi));
|
std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi));
|
||||||
}
|
}
|
||||||
options.dpi = static_cast<int>(dpi);
|
options.dpi = static_cast<int>(dpi);
|
||||||
|
} else if (arg == "--duration") {
|
||||||
|
const std::string value = requireValue(argc, argv, i, arg);
|
||||||
|
double seconds = 0.0;
|
||||||
|
try {
|
||||||
|
std::size_t used = 0;
|
||||||
|
seconds = std::stod(value, &used);
|
||||||
|
if (used != value.size()) seconds = 0.0;
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
seconds = 0.0;
|
||||||
|
}
|
||||||
|
if (!(seconds >= kMinDuration) || !(seconds <= kMaxDuration)) {
|
||||||
|
throw EffectError("invalid duration '" + value + "', expected seconds between " +
|
||||||
|
formatSeconds(kMinDuration) + " and " +
|
||||||
|
formatSeconds(kMaxDuration));
|
||||||
|
}
|
||||||
|
options.video.enabled = true;
|
||||||
|
options.video.duration = seconds;
|
||||||
|
durationGiven = true;
|
||||||
|
} else if (arg == "--fps") {
|
||||||
|
const std::string value = requireValue(argc, argv, i, arg);
|
||||||
|
long fps = 0;
|
||||||
|
if (!parseLong(value, fps) || fps < kMinFps || fps > kMaxFps) {
|
||||||
|
throw EffectError("invalid frame rate '" + value + "', expected " +
|
||||||
|
std::to_string(kMinFps) + "-" + std::to_string(kMaxFps));
|
||||||
|
}
|
||||||
|
options.video.enabled = true;
|
||||||
|
options.video.fps = static_cast<int>(fps);
|
||||||
|
fpsGiven = true;
|
||||||
|
} else if (arg == "--video-preset") {
|
||||||
|
const std::string value = requireValue(argc, argv, i, arg);
|
||||||
|
if (videoPresetByKey(value) == nullptr) {
|
||||||
|
throw EffectError("unknown video preset '" + value + "', see --help");
|
||||||
|
}
|
||||||
|
options.video.enabled = true;
|
||||||
|
options.video.preset = value;
|
||||||
|
} else if (arg == "--video-quality") {
|
||||||
|
const std::string value = requireValue(argc, argv, i, arg);
|
||||||
|
if (videoQualityByKey(value) == nullptr) {
|
||||||
|
throw EffectError("unknown video quality '" + value + "', see --help");
|
||||||
|
}
|
||||||
|
options.video.quality = value;
|
||||||
} else if (arg == "--disable") {
|
} else if (arg == "--disable") {
|
||||||
const std::string name = requireValue(argc, argv, i, arg);
|
const std::string name = requireValue(argc, argv, i, arg);
|
||||||
effectByName(name); // validate now, exactly as -e does
|
effectByName(name); // validate now, exactly as -e does
|
||||||
@@ -135,6 +182,7 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
case ParamSpec::Type::Path:
|
case ParamSpec::Type::Path:
|
||||||
case ParamSpec::Type::Text:
|
case ParamSpec::Type::Text:
|
||||||
|
case ParamSpec::Type::Font:
|
||||||
options.params[spec->key] = requireValue(argc, argv, i, arg);
|
options.params[spec->key] = requireValue(argc, argv, i, arg);
|
||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Color: {
|
case ParamSpec::Type::Color: {
|
||||||
@@ -162,6 +210,18 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
options.images.push_back(arg);
|
options.images.push_back(arg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A preset fills in whatever was not given on its own: the length, the
|
||||||
|
// rate and -- unless -s or --format named one -- the canvas. It keeps its
|
||||||
|
// name only while the settings still are the preset's.
|
||||||
|
if (const auto* preset = videoPresetByKey(options.video.preset)) {
|
||||||
|
if (!durationGiven) options.video.duration = preset->seconds;
|
||||||
|
if (!fpsGiven) options.video.fps = preset->fps;
|
||||||
|
if (options.format.empty() && !sizeGiven) options.format = preset->canvasFormat;
|
||||||
|
if (options.video.duration != preset->seconds || options.video.fps != preset->fps ||
|
||||||
|
options.format != preset->canvasFormat) {
|
||||||
|
options.video.preset.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
// Applied once everything is parsed, so --dpi may come before --format and
|
// Applied once everything is parsed, so --dpi may come before --format and
|
||||||
// so a format always wins over an -s given alongside it.
|
// so a format always wins over an -s given alongside it.
|
||||||
if (const auto* format = canvasFormatByKey(options.format)) {
|
if (const auto* format = canvasFormatByKey(options.format)) {
|
||||||
@@ -183,6 +243,13 @@ std::string usage(const char* programName) {
|
|||||||
<< " --format NAME canvas format, overrides --size (see below)\n"
|
<< " --format NAME canvas format, overrides --size (see below)\n"
|
||||||
<< " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi
|
<< " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi
|
||||||
<< " (default: " << kDefaultDpi << ")\n"
|
<< " (default: " << kDefaultDpi << ")\n"
|
||||||
|
<< " --duration SECS render a video this long, " << formatSeconds(kMinDuration)
|
||||||
|
<< " to " << formatSeconds(kMaxDuration) << "\n"
|
||||||
|
<< " --fps N frames per second of a video, " << kMinFps << "-" << kMaxFps
|
||||||
|
<< " (default: " << kDefaultFps << ")\n"
|
||||||
|
<< " --video-preset NAME a video's canvas and length in one (see below)\n"
|
||||||
|
<< " --video-quality NAME how a video file is encoded (default: "
|
||||||
|
<< kDefaultVideoQuality << ", see below)\n"
|
||||||
<< " --disable NAME keep this effect in the chain but skip it\n"
|
<< " --disable NAME keep this effect in the chain but skip it\n"
|
||||||
<< " --seed N random seed (default: random)\n"
|
<< " --seed N random seed (default: random)\n"
|
||||||
<< " --list-effects list available effects and exit\n"
|
<< " --list-effects list available effects and exit\n"
|
||||||
@@ -197,8 +264,30 @@ std::string usage(const char* programName) {
|
|||||||
<< (format.print ? " at --dpi" : "") << "\n";
|
<< (format.print ? " at --dpi" : "") << "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out << "\nA video is written through ffmpeg, as .mp4, .mov, .mkv, .webm or .gif by the "
|
||||||
|
"name given to\n--output.\n";
|
||||||
|
out << "\nVideo presets (--video-preset):\n";
|
||||||
|
for (const auto& preset : videoPresets()) {
|
||||||
|
out << " " << preset.key;
|
||||||
|
if (preset.key.size() < 20) out << std::string(20 - preset.key.size(), ' ');
|
||||||
|
out << " " << preset.label << ", " << videoPresetDetail(preset) << "\n";
|
||||||
|
}
|
||||||
|
out << "\nVideo qualities (--video-quality):\n";
|
||||||
|
for (const auto& quality : videoQualities()) {
|
||||||
|
out << " " << quality.key;
|
||||||
|
if (quality.key.size() < 20) out << std::string(20 - quality.key.size(), ' ');
|
||||||
|
out << " " << quality.label << ", " << quality.description << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image effects first, then the video effects under a heading of their
|
||||||
|
// own: those run only in a clip.
|
||||||
|
for (const bool video : {false, true}) {
|
||||||
|
if (video) {
|
||||||
|
out << "\nVideo effects, which run only in a video (--duration or --video-preset):\n";
|
||||||
|
}
|
||||||
for (const auto& effect : effects()) {
|
for (const auto& effect : effects()) {
|
||||||
out << "\n" << effect->title() << " options:\n";
|
if (effect->video() != video) continue;
|
||||||
|
out << "\n" << effect->title() << " options (-e " << effect->name() << "):\n";
|
||||||
for (const auto& spec : effect->params()) {
|
for (const auto& spec : effect->params()) {
|
||||||
std::string flag = spec.flag();
|
std::string flag = spec.flag();
|
||||||
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
|
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
|
||||||
@@ -207,6 +296,7 @@ std::string usage(const char* programName) {
|
|||||||
out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n";
|
out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return out.str();
|
return out.str();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include "core/canvas.hpp"
|
#include "core/canvas.hpp"
|
||||||
#include "core/chain.hpp"
|
#include "core/chain.hpp"
|
||||||
#include "core/params.hpp"
|
#include "core/params.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -23,6 +24,9 @@ struct Options {
|
|||||||
int height = 1080;
|
int height = 1080;
|
||||||
std::string format; // a named canvas format, empty for plain pixels
|
std::string format; // a named canvas format, empty for plain pixels
|
||||||
int dpi = kDefaultDpi; // only a print format is rendered at a resolution
|
int dpi = kDefaultDpi; // only a print format is rendered at a resolution
|
||||||
|
// Enabled by --duration, --fps or --video-preset: the output is then a
|
||||||
|
// video, and --output has to name one.
|
||||||
|
VideoSettings video;
|
||||||
std::optional<std::uint64_t> seed;
|
std::optional<std::uint64_t> seed;
|
||||||
ParamValues params; // only the keys the user actually passed
|
ParamValues params; // only the keys the user actually passed
|
||||||
bool listEffects = false;
|
bool listEffects = false;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const std::vector<CanvasFormat>& canvasFormats() {
|
|||||||
{"instagram-post", "Instagram post", false, 1080, 1080, 0.0, 0.0},
|
{"instagram-post", "Instagram post", false, 1080, 1080, 0.0, 0.0},
|
||||||
{"instagram-portrait", "Instagram portrait", false, 1080, 1350, 0.0, 0.0},
|
{"instagram-portrait", "Instagram portrait", false, 1080, 1350, 0.0, 0.0},
|
||||||
{"instagram-story", "Instagram story", false, 1080, 1920, 0.0, 0.0},
|
{"instagram-story", "Instagram story", false, 1080, 1920, 0.0, 0.0},
|
||||||
|
{"vertical-hd", "Vertical Full HD", false, 1080, 1920, 0.0, 0.0},
|
||||||
{"hd", "Full HD", false, 1920, 1080, 0.0, 0.0},
|
{"hd", "Full HD", false, 1920, 1080, 0.0, 0.0},
|
||||||
{"uhd", "4K UHD", false, 3840, 2160, 0.0, 0.0},
|
{"uhd", "4K UHD", false, 3840, 2160, 0.0, 0.0},
|
||||||
{"a5", "A5", true, 0, 0, 148.0, 210.0},
|
{"a5", "A5", true, 0, 0, 148.0, 210.0},
|
||||||
|
|||||||
+120
-17
@@ -1,12 +1,18 @@
|
|||||||
#include "core/chain.hpp"
|
#include "core/chain.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "core/memory.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages) {
|
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages, bool video) {
|
||||||
std::vector<const ChainStage*> running;
|
std::vector<const ChainStage*> running;
|
||||||
running.reserve(stages.size());
|
running.reserve(stages.size());
|
||||||
for (const auto& stage : stages) {
|
for (const auto& stage : stages) {
|
||||||
if (stage.enabled && stage.effect) running.push_back(&stage);
|
if (stage.enabled && stage.effect && (video || !stage.effect->video())) {
|
||||||
|
running.push_back(&stage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return running;
|
return running;
|
||||||
}
|
}
|
||||||
@@ -29,8 +35,10 @@ ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
namespace {
|
||||||
int width, int height, Rng& rng, const StyleSet& styles) {
|
|
||||||
|
// The stages that run, checked: throws for a chain with nothing to render.
|
||||||
|
std::vector<const ChainStage*> runningStages(const std::vector<ChainStage>& stages, bool video) {
|
||||||
if (stages.empty()) throw EffectError("The effect chain is empty.");
|
if (stages.empty()) throw EffectError("The effect chain is empty.");
|
||||||
for (const auto& stage : stages) {
|
for (const auto& stage : stages) {
|
||||||
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
||||||
@@ -38,46 +46,141 @@ Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image
|
|||||||
|
|
||||||
// A disabled stage never reaches the render at all: it is not handed an
|
// A disabled stage never reaches the render at all: it is not handed an
|
||||||
// image and draws nothing from the shared rng, so switching one off gives
|
// image and draws nothing from the shared rng, so switching one off gives
|
||||||
// back exactly the picture the chain made before it was added.
|
// back exactly the picture the chain made before it was added. A video
|
||||||
const auto running = enabledStages(stages);
|
// effect in a still is left out the same way.
|
||||||
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
|
auto running = enabledStages(stages, video);
|
||||||
|
if (running.empty()) {
|
||||||
|
const bool videoLeft = std::any_of(stages.begin(), stages.end(), [](const ChainStage& stage) {
|
||||||
|
return stage.enabled && stage.effect->video();
|
||||||
|
});
|
||||||
|
throw EffectError(videoLeft ? "Video effects only run in a video, and the chain has "
|
||||||
|
"no image effect switched on."
|
||||||
|
: "Every effect in the chain is disabled.");
|
||||||
|
}
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
Image image;
|
// Throws OutOfMemoryError for a render that would not fit. A render holds a
|
||||||
for (std::size_t i = 0; i < running.size(); ++i) {
|
// few canvases at once -- the stage before, the one being drawn, the input
|
||||||
|
// a transform copies -- and the file it is saved to; 13 bytes a pixel is
|
||||||
|
// what the heaviest chains were measured at, and the interface keeps one
|
||||||
|
// more copy to show, so 24 leaves room. A picture a stage loads itself (an
|
||||||
|
// overlay, a text box's picture) comes on top, with its alpha and pixbuf.
|
||||||
|
void ensureRenderFits(const std::vector<const ChainStage*>& running, int width, int height,
|
||||||
|
const StyleSet& styles) {
|
||||||
|
std::size_t needed = static_cast<std::size_t>(std::max(width, 0)) *
|
||||||
|
static_cast<std::size_t>(std::max(height, 0)) * 24;
|
||||||
|
for (const auto* stage : running) {
|
||||||
|
const auto values = stageValues(*stage, styles);
|
||||||
|
for (const auto& spec : stage->effect->params()) {
|
||||||
|
if (spec.type != ParamSpec::Type::Path) continue;
|
||||||
|
const std::string path = paramText(values, spec.key, "");
|
||||||
|
if (!path.empty()) needed += imageFilePixels(path) * 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensureMemory(needed, "Rendering a " + std::to_string(width) + " × " + std::to_string(height) +
|
||||||
|
" canvas");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs running[first, last) on `image`, which holds the sources' place for
|
||||||
|
// the first stage of the chain and the previous stage's output after it.
|
||||||
|
Image runStages(const std::vector<const ChainStage*>& running, std::size_t first,
|
||||||
|
std::size_t last, const std::vector<Image>& sources, Image image, int width,
|
||||||
|
int height, Rng& rng, const StyleSet& styles, const FrameTime& frame) {
|
||||||
|
for (std::size_t i = first; i < last; ++i) {
|
||||||
const auto& stage = *running[i];
|
const auto& stage = *running[i];
|
||||||
|
|
||||||
const auto values = stageValues(stage, styles);
|
const auto values = stageValues(stage, styles);
|
||||||
|
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
image = stage.effect->render(sources, width, height, rng, values);
|
image = stage.effect->renderFrame(sources, width, height, rng, values, frame);
|
||||||
} else {
|
} else {
|
||||||
// Feeding the previous result back in as the only source is what
|
// Feeding the previous result back in as the only source is what
|
||||||
// cascading means here; effects need to know nothing about it.
|
// cascading means here; effects need to know nothing about it.
|
||||||
const std::vector<Image> previous = {std::move(image)};
|
const std::vector<Image> previous = {std::move(image)};
|
||||||
image = stage.effect->render(previous, width, height, rng, values);
|
image = stage.effect->renderFrame(previous, width, height, rng, values, frame);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
|
} // namespace
|
||||||
const auto running = enabledStages(stages);
|
|
||||||
|
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
|
int width, int height, Rng& rng, const StyleSet& styles,
|
||||||
|
const FrameTime& frame) {
|
||||||
|
const auto running = runningStages(stages, frame.video());
|
||||||
|
ensureRenderFits(running, width, height, styles);
|
||||||
|
return runStages(running, 0, running.size(), sources, Image(), width, height, rng, styles,
|
||||||
|
frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
|
int width, int height, std::uint64_t seed, const StyleSet& styles,
|
||||||
|
const VideoSettings& video,
|
||||||
|
const std::function<bool(int index, Image&& frame)>& onFrame,
|
||||||
|
const std::vector<int>& order) {
|
||||||
|
const auto running = runningStages(stages, true);
|
||||||
|
ensureRenderFits(running, width, height, styles);
|
||||||
|
const int count = video.frameCount();
|
||||||
|
const int fps = std::clamp(video.fps, kMinFps, kMaxFps);
|
||||||
|
|
||||||
|
// Everything before the first video effect is the same on every frame --
|
||||||
|
// the same seed, the same values, no time -- so it is rendered once, as
|
||||||
|
// frame 0, and picked up from there. The rng is copied along with the
|
||||||
|
// picture, which is what makes the shortcut exact.
|
||||||
|
std::size_t still = 0;
|
||||||
|
while (still < running.size() && !running[still]->effect->video()) ++still;
|
||||||
|
Rng prefixRng(seed);
|
||||||
|
const Image prefix = runStages(running, 0, still, sources, Image(), width, height, prefixRng,
|
||||||
|
styles, FrameTime{0, count, fps});
|
||||||
|
|
||||||
|
const int total = order.empty() ? count : static_cast<int>(order.size());
|
||||||
|
int handed = 0;
|
||||||
|
for (int position = 0; position < total; ++position) {
|
||||||
|
const int index = order.empty() ? position : order[static_cast<std::size_t>(position)];
|
||||||
|
if (index < 0 || index >= count) continue;
|
||||||
|
const FrameTime frame{index, count, fps};
|
||||||
|
Rng rng = prefixRng;
|
||||||
|
Image image = still == running.size()
|
||||||
|
? prefix
|
||||||
|
: runStages(running, still, running.size(), sources, prefix, width,
|
||||||
|
height, rng, styles, frame);
|
||||||
|
++handed;
|
||||||
|
if (!onFrame(index, std::move(image))) break;
|
||||||
|
}
|
||||||
|
return handed;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<int> draftFirstOrder(int count, int step) {
|
||||||
|
step = std::max(1, step);
|
||||||
|
std::vector<int> order;
|
||||||
|
order.reserve(static_cast<std::size_t>(std::max(0, count)));
|
||||||
|
for (int index = 0; index < count; index += step) order.push_back(index);
|
||||||
|
for (int index = 0; index < count; ++index) {
|
||||||
|
if (index % step != 0) order.push_back(index);
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool chainNeedsSources(const std::vector<ChainStage>& stages, bool video) {
|
||||||
|
const auto running = enabledStages(stages, video);
|
||||||
if (running.empty()) return false;
|
if (running.empty()) return false;
|
||||||
return running.front()->effect->needsSources();
|
return running.front()->effect->needsSources();
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string chainTitle(const std::vector<ChainStage>& stages) {
|
std::string chainTitle(const std::vector<ChainStage>& stages, bool video) {
|
||||||
std::string text;
|
std::string text;
|
||||||
for (const auto* stage : enabledStages(stages)) {
|
for (const auto* stage : enabledStages(stages, video)) {
|
||||||
if (!text.empty()) text += " → ";
|
if (!text.empty()) text += " → ";
|
||||||
text += stage->effect->title();
|
text += stage->effect->title();
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string chainSlug(const std::vector<ChainStage>& stages) {
|
std::string chainSlug(const std::vector<ChainStage>& stages, bool video) {
|
||||||
std::string text;
|
std::string text;
|
||||||
for (const auto* stage : enabledStages(stages)) {
|
for (const auto* stage : enabledStages(stages, video)) {
|
||||||
if (!text.empty()) text += "-";
|
if (!text.empty()) text += "-";
|
||||||
text += stage->effect->name();
|
text += stage->effect->name();
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-10
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@
|
|||||||
#include "core/params.hpp"
|
#include "core/params.hpp"
|
||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
#include "core/style.hpp"
|
#include "core/style.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -38,11 +40,12 @@ struct ChainStage {
|
|||||||
// that style carries. The one place a style is resolved.
|
// that style carries. The one place a style is resolved.
|
||||||
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
|
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
|
||||||
|
|
||||||
// The stages that actually run, in order, as pointers into `stages`. A
|
// The stages that actually run, in order, as pointers into `stages`: those
|
||||||
// disabled stage draws nothing from the rng and is handed no image, so a chain
|
// switched on, and of those the video effects only in a video. A stage left
|
||||||
// renders exactly as it would if that stage had never been added -- this is
|
// out draws nothing from the rng and is handed no image, so a chain renders
|
||||||
// the one place the rest of the code asks what "disabled" means.
|
// exactly as it would if that stage had never been added -- this is the one
|
||||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages);
|
// place the rest of the code asks what "disabled" means.
|
||||||
|
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages, bool video);
|
||||||
|
|
||||||
// Renders the enabled stages in order. The first one sees the loaded sources;
|
// Renders the enabled stages in order. The first one sees the loaded sources;
|
||||||
// every later one sees the previous stage's output as its only source, so blur
|
// every later one sees the previous stage's output as its only source, so blur
|
||||||
@@ -52,19 +55,47 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
|
|||||||
// reproduces the cascade byte for byte. Throws when no stage is left to run.
|
// reproduces the cascade byte for byte. Throws when no stage is left to run.
|
||||||
// `styles` is the project's pool; a chain built without one -- the command
|
// `styles` is the project's pool; a chain built without one -- the command
|
||||||
// line's -- passes none and renders from the stages alone.
|
// line's -- passes none and renders from the stages alone.
|
||||||
|
//
|
||||||
|
// `frame` says where in a video the render sits, a still by default, in
|
||||||
|
// which the video effects are skipped; every stage that runs is handed it,
|
||||||
|
// and only a video effect makes anything of it.
|
||||||
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
int width, int height, Rng& rng, const StyleSet& styles = {});
|
int width, int height, Rng& rng, const StyleSet& styles = {},
|
||||||
|
const FrameTime& frame = {});
|
||||||
|
|
||||||
|
// Renders every frame of a video, in order, handing each to `onFrame`; it
|
||||||
|
// returning false stops the render there. Frame k is exactly what
|
||||||
|
// renderChain gives with a fresh Rng(seed) and FrameTime{k, count, fps} --
|
||||||
|
// every frame starts from the same seed, so a stage that does not move
|
||||||
|
// renders the same picture throughout rather than reshuffling on every
|
||||||
|
// frame. The stages before the first video effect are rendered once, and
|
||||||
|
// their picture and the rng state they leave are reused for every frame.
|
||||||
|
// Returns the number of frames handed out.
|
||||||
|
//
|
||||||
|
// `order` lists the frame indices to render, in the order to render them;
|
||||||
|
// empty means every frame from the first. Each frame is the same whichever
|
||||||
|
// order it comes in, so a preview can render a draft first (draftFirstOrder).
|
||||||
|
int renderFrames(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
|
int width, int height, std::uint64_t seed, const StyleSet& styles,
|
||||||
|
const VideoSettings& video,
|
||||||
|
const std::function<bool(int index, Image&& frame)>& onFrame,
|
||||||
|
const std::vector<int>& order = {});
|
||||||
|
|
||||||
|
// Every frame of a clip of `count`, draft first: every `step`th frame from
|
||||||
|
// the first, which held for `step` frames each plays the whole clip roughly,
|
||||||
|
// and then the frames between them, in order.
|
||||||
|
std::vector<int> draftFirstOrder(int count, int step);
|
||||||
|
|
||||||
// Whether the chain has to be given source images. Only the first stage that
|
// Whether the chain has to be given source images. Only the first stage that
|
||||||
// runs ever sees them — every later one works on the stage before it — so this
|
// runs ever sees them — every later one works on the stage before it — so this
|
||||||
// is simply whether that effect declares that it needs them.
|
// is simply whether that effect declares that it needs them.
|
||||||
bool chainNeedsSources(const std::vector<ChainStage>& stages);
|
bool chainNeedsSources(const std::vector<ChainStage>& stages, bool video);
|
||||||
|
|
||||||
// "Scanlines → Blur", for window titles and status lines. Both of these name
|
// "Scanlines → Blur", for window titles and status lines. Both of these name
|
||||||
// what was rendered, so a disabled stage is left out of them.
|
// what was rendered, so a stage that did not run is left out of them.
|
||||||
std::string chainTitle(const std::vector<ChainStage>& stages);
|
std::string chainTitle(const std::vector<ChainStage>& stages, bool video);
|
||||||
|
|
||||||
// "scanlines-blur", for file names.
|
// "scanlines-blur", for file names.
|
||||||
std::string chainSlug(const std::vector<ChainStage>& stages);
|
std::string chainSlug(const std::vector<ChainStage>& stages, bool video);
|
||||||
|
|
||||||
} // namespace effector
|
} // namespace effector
|
||||||
|
|||||||
@@ -16,6 +16,19 @@ public:
|
|||||||
using std::runtime_error::runtime_error;
|
using std::runtime_error::runtime_error;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Where a render sits in time. The default -- no frame rate -- is a still
|
||||||
|
// picture; a video hands every frame its index, the clip's length in frames
|
||||||
|
// and its rate, so an effect that moves works out where it is from these
|
||||||
|
// alone and a frame can be rendered on its own, in any order.
|
||||||
|
struct FrameTime {
|
||||||
|
int index = 0;
|
||||||
|
int count = 1;
|
||||||
|
int fps = 0;
|
||||||
|
|
||||||
|
bool video() const noexcept { return fps > 0; }
|
||||||
|
double seconds() const noexcept { return video() ? static_cast<double>(index) / fps : 0.0; }
|
||||||
|
};
|
||||||
|
|
||||||
// Base class for every effect. Canvas size and the random source are common to
|
// Base class for every effect. Canvas size and the random source are common to
|
||||||
// all of them; params() covers only the effect-specific knobs.
|
// all of them; params() covers only the effect-specific knobs.
|
||||||
class Effect {
|
class Effect {
|
||||||
@@ -45,9 +58,28 @@ public:
|
|||||||
return title();
|
return title();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether this is a video effect rather than an image effect. An image
|
||||||
|
// effect makes a picture and holds still in a clip; a video effect is
|
||||||
|
// motion and nothing else, so it is offered only in a video project and a
|
||||||
|
// still skips it whole, as it skips a stage that is switched off. Video
|
||||||
|
// effects derive from VideoEffect (effects/video_effect.hpp), which
|
||||||
|
// answers true here and keeps the rules a moving stage has to follow.
|
||||||
|
virtual bool video() const { return false; }
|
||||||
|
|
||||||
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values) const = 0;
|
const ParamValues& values) const = 0;
|
||||||
|
|
||||||
|
// One frame of a video, or a still when `frame` says so. An image effect
|
||||||
|
// needs only render(), which this falls back to; a video effect renders
|
||||||
|
// here, and must leave `rng` in the same state on every frame -- a
|
||||||
|
// frame's own randomness comes from a generator of its own -- so the
|
||||||
|
// stages after it stay put while it moves.
|
||||||
|
virtual Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
|
const ParamValues& values, const FrameTime& frame) const {
|
||||||
|
(void)frame;
|
||||||
|
return render(sources, width, height, rng, values);
|
||||||
|
}
|
||||||
|
|
||||||
ParamValues defaults() const;
|
ParamValues defaults() const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+102
-24
@@ -11,6 +11,8 @@
|
|||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
|
||||||
|
#include "core/memory.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -91,6 +93,39 @@ Image Image::load(const std::string& path, std::vector<std::uint8_t>* alphaOut)
|
|||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Image Image::scaledToFit(int maxSide) const {
|
||||||
|
const int longest = std::max(width_, height_);
|
||||||
|
if (empty() || maxSide < 1 || longest <= maxSide) return *this;
|
||||||
|
|
||||||
|
const double scale = static_cast<double>(maxSide) / longest;
|
||||||
|
const int outWidth = std::max(1, static_cast<int>(width_ * scale));
|
||||||
|
const int outHeight = std::max(1, static_cast<int>(height_ * scale));
|
||||||
|
Image out(outWidth, outHeight);
|
||||||
|
for (int y = 0; y < outHeight; ++y) {
|
||||||
|
const int y0 = static_cast<int>(static_cast<long>(y) * height_ / outHeight);
|
||||||
|
const int y1 = std::max(y0 + 1, static_cast<int>(static_cast<long>(y + 1) * height_ / outHeight));
|
||||||
|
std::uint8_t* target = out.row(y);
|
||||||
|
for (int x = 0; x < outWidth; ++x) {
|
||||||
|
const int x0 = static_cast<int>(static_cast<long>(x) * width_ / outWidth);
|
||||||
|
const int x1 = std::max(x0 + 1, static_cast<int>(static_cast<long>(x + 1) * width_ / outWidth));
|
||||||
|
unsigned sum[3] = {0, 0, 0};
|
||||||
|
for (int sy = y0; sy < y1; ++sy) {
|
||||||
|
const std::uint8_t* in = row(sy) + static_cast<std::size_t>(x0) * 3;
|
||||||
|
for (int sx = x0; sx < x1; ++sx, in += 3) {
|
||||||
|
sum[0] += in[0];
|
||||||
|
sum[1] += in[1];
|
||||||
|
sum[2] += in[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unsigned area = static_cast<unsigned>((y1 - y0) * (x1 - x0));
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
target[static_cast<std::size_t>(x) * 3 + c] = static_cast<std::uint8_t>(sum[c] / area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
void Image::save(const std::string& path) const {
|
void Image::save(const std::string& path) const {
|
||||||
if (empty()) throw std::runtime_error("nothing to save");
|
if (empty()) throw std::runtime_error("nothing to save");
|
||||||
|
|
||||||
@@ -119,31 +154,81 @@ void Image::save(const std::string& path) const {
|
|||||||
if (saved == FALSE) throw std::runtime_error(takeError(error));
|
if (saved == FALSE) throw std::runtime_error(takeError(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::size_t imageFilePixels(const std::string& path) {
|
||||||
|
int width = 0;
|
||||||
|
int height = 0;
|
||||||
|
if (gdk_pixbuf_get_file_info(path.c_str(), &width, &height) == nullptr) return 0;
|
||||||
|
return static_cast<std::size_t>(std::max(width, 0)) * static_cast<std::size_t>(std::max(height, 0));
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<Image> loadImages(const std::vector<std::string>& paths,
|
std::vector<Image> loadImages(const std::vector<std::string>& paths,
|
||||||
std::vector<LoadFailure>& failures,
|
std::vector<LoadFailure>& failures,
|
||||||
const LoadProgress* progress) {
|
const LoadProgress* progress) {
|
||||||
// Decoding is the dominant cost of a headless run, and the files are
|
const auto cancelled = [progress] {
|
||||||
// independent, so they are decoded in parallel. Threads take the next path
|
return progress != nullptr && progress->cancel != nullptr &&
|
||||||
// from a shared cursor rather than a fixed slice, so one huge file cannot
|
progress->cancel->load(std::memory_order_relaxed);
|
||||||
// stall the files behind it and progress arrives at a steady rate. Slots
|
};
|
||||||
// keep the result in the order the paths were given.
|
// Runs `work(i)` for every path on `workers` threads. Threads take the next
|
||||||
std::vector<Image> slots(paths.size());
|
// path from a shared cursor rather than a fixed slice, so one huge or slow
|
||||||
std::vector<std::string> errors(paths.size());
|
// file cannot stall the files behind it and progress arrives at a steady
|
||||||
|
// rate. A cancel stops every thread before its next path.
|
||||||
|
const auto forEachPath = [&](std::size_t workers, const auto& work) {
|
||||||
std::atomic<std::size_t> cursor{0};
|
std::atomic<std::size_t> cursor{0};
|
||||||
std::atomic<std::size_t> done{0};
|
const auto run = [&] {
|
||||||
|
for (;;) {
|
||||||
|
if (cancelled()) return;
|
||||||
|
const std::size_t i = cursor.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
if (i >= paths.size()) return;
|
||||||
|
work(i);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::vector<std::thread> threads;
|
||||||
|
threads.reserve(workers > 0 ? workers - 1 : 0);
|
||||||
|
for (std::size_t w = 1; w < workers; ++w) threads.emplace_back(run);
|
||||||
|
run();
|
||||||
|
for (auto& thread : threads) thread.join();
|
||||||
|
};
|
||||||
|
|
||||||
unsigned hardware = std::thread::hardware_concurrency();
|
unsigned hardware = std::thread::hardware_concurrency();
|
||||||
if (hardware == 0) hardware = 1;
|
if (hardware == 0) hardware = 1;
|
||||||
const std::size_t workers = std::min<std::size_t>(hardware, std::max<std::size_t>(paths.size(), 1));
|
std::size_t workers = std::min<std::size_t>(hardware, std::max<std::size_t>(paths.size(), 1));
|
||||||
|
|
||||||
const auto decode = [&] {
|
// What the batch holds once decoded, and what one decode needs on top
|
||||||
for (;;) {
|
// while it runs: the pixbuf (with alpha, at worst) and its rotated copy.
|
||||||
if (progress != nullptr && progress->cancel != nullptr &&
|
// Reading a header can mean a round trip to a slow disk or a network
|
||||||
progress->cancel->load(std::memory_order_relaxed)) {
|
// share, so the headers are read in parallel too, and reported.
|
||||||
return;
|
std::vector<std::size_t> pixels(paths.size(), 0);
|
||||||
|
std::atomic<std::size_t> sized{0};
|
||||||
|
forEachPath(workers, [&](std::size_t i) {
|
||||||
|
pixels[i] = imageFilePixels(paths[i]);
|
||||||
|
if (progress != nullptr && progress->onSized) {
|
||||||
|
progress->onSized(sized.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(),
|
||||||
|
paths[i]);
|
||||||
}
|
}
|
||||||
const std::size_t i = cursor.fetch_add(1, std::memory_order_relaxed);
|
});
|
||||||
if (i >= paths.size()) return;
|
if (cancelled()) return {};
|
||||||
|
|
||||||
|
std::size_t kept = 0;
|
||||||
|
std::size_t largest = 0;
|
||||||
|
for (const std::size_t count : pixels) {
|
||||||
|
kept += count * 3;
|
||||||
|
largest = std::max(largest, count * 8);
|
||||||
|
}
|
||||||
|
const std::string what = paths.size() == 1
|
||||||
|
? std::string("Loading this image")
|
||||||
|
: "Loading these " + std::to_string(paths.size()) + " images";
|
||||||
|
ensureMemory(kept + largest, what);
|
||||||
|
const std::size_t spare = usableMemory() - std::min(usableMemory(), kept);
|
||||||
|
|
||||||
|
// Decoding is the dominant cost of a headless run, and the files are
|
||||||
|
// independent, so they are decoded in parallel. Slots keep the result in
|
||||||
|
// the order the paths were given.
|
||||||
|
std::vector<Image> slots(paths.size());
|
||||||
|
std::vector<std::string> errors(paths.size());
|
||||||
|
std::atomic<std::size_t> done{0};
|
||||||
|
if (largest > 0) workers = std::clamp<std::size_t>(spare / largest, 1, workers);
|
||||||
|
|
||||||
|
forEachPath(workers, [&](std::size_t i) {
|
||||||
try {
|
try {
|
||||||
slots[i] = Image::load(paths[i]);
|
slots[i] = Image::load(paths[i]);
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
@@ -153,14 +238,7 @@ std::vector<Image> loadImages(const std::vector<std::string>& paths,
|
|||||||
progress->onFile(done.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(),
|
progress->onFile(done.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(),
|
||||||
paths[i]);
|
paths[i]);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
std::vector<std::thread> threads;
|
|
||||||
threads.reserve(workers > 0 ? workers - 1 : 0);
|
|
||||||
for (std::size_t w = 1; w < workers; ++w) threads.emplace_back(decode);
|
|
||||||
decode();
|
|
||||||
for (auto& thread : threads) thread.join();
|
|
||||||
|
|
||||||
std::vector<Image> images;
|
std::vector<Image> images;
|
||||||
images.reserve(paths.size());
|
images.reserve(paths.size());
|
||||||
|
|||||||
+19
-2
@@ -40,6 +40,11 @@ public:
|
|||||||
// which is what an effect compositing the image needs.
|
// which is what an effect compositing the image needs.
|
||||||
static Image load(const std::string& path, std::vector<std::uint8_t>* alphaOut = nullptr);
|
static Image load(const std::string& path, std::vector<std::uint8_t>* alphaOut = nullptr);
|
||||||
|
|
||||||
|
// A copy shrunk so that its longer side is at most `maxSide`, each pixel
|
||||||
|
// the average of the ones it covers; an image already that small comes
|
||||||
|
// back as it is. For previews: nothing rendered goes through it.
|
||||||
|
Image scaledToFit(int maxSide) const;
|
||||||
|
|
||||||
// Encodes by file extension; png is used when the extension is unknown.
|
// Encodes by file extension; png is used when the extension is unknown.
|
||||||
void save(const std::string& path) const;
|
void save(const std::string& path) const;
|
||||||
|
|
||||||
@@ -56,16 +61,28 @@ struct LoadFailure {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Lets a caller follow a long batch decode and abort it. Both members are
|
// Lets a caller follow a long batch decode and abort it. Both members are
|
||||||
// touched from the decoding threads, so `onFile` must be thread-safe and must
|
// touched from the decoding threads, so `onSized` and `onFile` must be
|
||||||
// not block: the GUI only bumps a counter from it and wakes the main thread.
|
// thread-safe and must not block: the GUI only bumps a counter from them and
|
||||||
|
// wakes the main thread. `onSized` follows the first pass, which reads every
|
||||||
|
// header to size the batch; `onFile` follows the decodes after it.
|
||||||
struct LoadProgress {
|
struct LoadProgress {
|
||||||
|
std::function<void(std::size_t done, std::size_t total, const std::string& path)> onSized;
|
||||||
std::function<void(std::size_t done, std::size_t total, const std::string& path)> onFile;
|
std::function<void(std::size_t done, std::size_t total, const std::string& path)> onFile;
|
||||||
std::atomic<bool>* cancel = nullptr;
|
std::atomic<bool>* cancel = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The pixel count of an image file from its header alone, without decoding
|
||||||
|
// it; 0 when the header cannot be read.
|
||||||
|
std::size_t imageFilePixels(const std::string& path);
|
||||||
|
|
||||||
// Loads every path, collecting rather than throwing on individual failures.
|
// Loads every path, collecting rather than throwing on individual failures.
|
||||||
// When `progress->cancel` is set the remaining paths are skipped, so the result
|
// When `progress->cancel` is set the remaining paths are skipped, so the result
|
||||||
// is short and should be discarded rather than used.
|
// is short and should be discarded rather than used.
|
||||||
|
//
|
||||||
|
// Before decoding anything the whole batch is sized from the file headers,
|
||||||
|
// and a batch that would not fit in the free memory throws OutOfMemoryError
|
||||||
|
// (core/memory.hpp) with nothing loaded; the decodes that run at once are
|
||||||
|
// also held to what is left over.
|
||||||
std::vector<Image> loadImages(const std::vector<std::string>& paths,
|
std::vector<Image> loadImages(const std::vector<std::string>& paths,
|
||||||
std::vector<LoadFailure>& failures,
|
std::vector<LoadFailure>& failures,
|
||||||
const LoadProgress* progress = nullptr);
|
const LoadProgress* progress = nullptr);
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#include "core/memory.hpp"
|
||||||
|
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::atomic<std::size_t> cap{0};
|
||||||
|
|
||||||
|
// MemAvailable from /proc/meminfo, falling back on the free pages; 0 when
|
||||||
|
// neither says.
|
||||||
|
std::size_t systemAvailable() {
|
||||||
|
std::ifstream meminfo("/proc/meminfo");
|
||||||
|
std::string key;
|
||||||
|
std::size_t kilobytes = 0;
|
||||||
|
std::string unit;
|
||||||
|
while (meminfo >> key >> kilobytes >> unit) {
|
||||||
|
if (key == "MemAvailable:") return kilobytes * 1024;
|
||||||
|
}
|
||||||
|
const long pages = sysconf(_SC_AVPHYS_PAGES);
|
||||||
|
const long pageSize = sysconf(_SC_PAGESIZE);
|
||||||
|
if (pages > 0 && pageSize > 0) {
|
||||||
|
return static_cast<std::size_t>(pages) * static_cast<std::size_t>(pageSize);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A number from a cgroup file, or nothing for "max" or a file that is not
|
||||||
|
// there.
|
||||||
|
bool readCount(const std::filesystem::path& file, std::size_t& out) {
|
||||||
|
std::ifstream in(file);
|
||||||
|
std::string word;
|
||||||
|
if (!(in >> word) || word == "max") return false;
|
||||||
|
try {
|
||||||
|
out = static_cast<std::size_t>(std::stoull(word));
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t inactiveFile(const std::filesystem::path& dir) {
|
||||||
|
std::ifstream in(dir / "memory.stat");
|
||||||
|
std::string key;
|
||||||
|
std::size_t value = 0;
|
||||||
|
while (in >> key >> value) {
|
||||||
|
if (key == "inactive_file") return value;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The room left under the tightest memory.max on the way from this
|
||||||
|
// process's cgroup (v2) up to the root, counting the page cache it could
|
||||||
|
// drop as room; the largest value when nothing caps it.
|
||||||
|
std::size_t cgroupAvailable() {
|
||||||
|
std::size_t room = std::numeric_limits<std::size_t>::max();
|
||||||
|
std::ifstream self("/proc/self/cgroup");
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(self, line)) {
|
||||||
|
if (line.rfind("0::", 0) != 0) continue;
|
||||||
|
// "0::/user.slice/app.slice/x.scope", walked from the scope upwards.
|
||||||
|
for (std::filesystem::path group = line.substr(4); !group.empty();
|
||||||
|
group = group.parent_path()) {
|
||||||
|
const auto dir = std::filesystem::path("/sys/fs/cgroup") / group;
|
||||||
|
std::size_t max = 0;
|
||||||
|
std::size_t current = 0;
|
||||||
|
if (!readCount(dir / "memory.max", max) || !readCount(dir / "memory.current", current)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const std::size_t used = current - std::min(current, inactiveFile(dir));
|
||||||
|
room = std::min(room, max - std::min(max, used));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::size_t availableMemory() {
|
||||||
|
std::size_t available = systemAvailable();
|
||||||
|
const std::size_t room = cgroupAvailable();
|
||||||
|
if (room != std::numeric_limits<std::size_t>::max()) {
|
||||||
|
available = available == 0 ? room : std::min(available, room);
|
||||||
|
}
|
||||||
|
const std::size_t capped = cap.load(std::memory_order_relaxed);
|
||||||
|
if (capped != 0 && (available == 0 || capped < available)) available = capped;
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
void limitAvailableMemory(std::size_t bytes) { cap.store(bytes, std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
std::size_t usableMemory() {
|
||||||
|
const std::size_t available = availableMemory();
|
||||||
|
return available == 0 ? std::numeric_limits<std::size_t>::max() : available / 4 * 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ensureMemory(std::size_t needed, const std::string& what) {
|
||||||
|
const std::size_t available = availableMemory();
|
||||||
|
if (available == 0 || needed <= available / 4 * 3) return;
|
||||||
|
throw OutOfMemoryError(what + " needs about " + formatBytes(needed) +
|
||||||
|
" of memory, more than is safe to take with " + formatBytes(available) +
|
||||||
|
" free.");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string formatBytes(std::size_t bytes) {
|
||||||
|
char text[32];
|
||||||
|
const double gb = static_cast<double>(bytes) / (1024.0 * 1024.0 * 1024.0);
|
||||||
|
if (gb >= 1.0) {
|
||||||
|
std::snprintf(text, sizeof text, "%.1f GB", gb);
|
||||||
|
} else {
|
||||||
|
std::snprintf(text, sizeof text, "%.0f MB", static_cast<double>(bytes) / (1024.0 * 1024.0));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// Thrown instead of starting work that would not fit in memory. Decoding a
|
||||||
|
// batch of photographs or rendering a huge canvas past what the machine has
|
||||||
|
// free does not fail cleanly: the kernel's OOM killer ends the program, and
|
||||||
|
// often others with it.
|
||||||
|
class OutOfMemoryError : public std::runtime_error {
|
||||||
|
public:
|
||||||
|
using std::runtime_error::runtime_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The bytes this process could still allocate without swapping: the
|
||||||
|
// system's MemAvailable, or less when a cgroup (a systemd unit, a container)
|
||||||
|
// caps the process below it. Zero when neither can be read.
|
||||||
|
std::size_t availableMemory();
|
||||||
|
|
||||||
|
// Caps what availableMemory() reports, for the tests; 0 lifts the cap.
|
||||||
|
void limitAvailableMemory(std::size_t bytes);
|
||||||
|
|
||||||
|
// Throws OutOfMemoryError when `needed` more bytes would take more than three
|
||||||
|
// quarters of availableMemory() -- the rest is left to the desktop and to
|
||||||
|
// whatever else the program holds. `what` names the work in the message.
|
||||||
|
// Does nothing when the free memory cannot be read.
|
||||||
|
void ensureMemory(std::size_t needed, const std::string& what);
|
||||||
|
|
||||||
|
// The bytes ensureMemory() would let through right now; the largest value
|
||||||
|
// when the free memory cannot be read.
|
||||||
|
std::size_t usableMemory();
|
||||||
|
|
||||||
|
// "4.6 GB", "380 MB".
|
||||||
|
std::string formatBytes(std::size_t bytes);
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "core/params.hpp"
|
#include "core/params.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -16,6 +17,27 @@ ParamSpec ParamSpec::styled() const {
|
|||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ParamSpec ParamSpec::slider(double curve) const {
|
||||||
|
ParamSpec copy = *this;
|
||||||
|
copy.onSlider = true;
|
||||||
|
copy.curve = std::max(1e-3, curve);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
double ParamSpec::sliderValue(double position) const {
|
||||||
|
const double span = maximum - minimum;
|
||||||
|
const double raw = minimum + span * std::pow(std::clamp(position, 0.0, 1.0), curve);
|
||||||
|
const double unit = type == Type::Int ? std::max(1.0, std::round(step)) : step;
|
||||||
|
const double stepped = unit > 0.0 ? minimum + std::round((raw - minimum) / unit) * unit : raw;
|
||||||
|
return std::clamp(stepped, minimum, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
double ParamSpec::sliderPosition(double value) const {
|
||||||
|
const double span = maximum - minimum;
|
||||||
|
if (span <= 0.0) return 0.0;
|
||||||
|
return std::pow(std::clamp((value - minimum) / span, 0.0, 1.0), 1.0 / curve);
|
||||||
|
}
|
||||||
|
|
||||||
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
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;
|
||||||
@@ -100,6 +122,17 @@ ParamSpec ParamSpec::makeColor(std::string key, std::string label, std::string d
|
|||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ParamSpec ParamSpec::makeFont(std::string key, std::string label, std::string defaultFamily,
|
||||||
|
std::string help) {
|
||||||
|
ParamSpec spec;
|
||||||
|
spec.type = Type::Font;
|
||||||
|
spec.key = std::move(key);
|
||||||
|
spec.label = std::move(label);
|
||||||
|
spec.help = std::move(help);
|
||||||
|
spec.defaultChoice = std::move(defaultFamily);
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
const ParamValue* find(const ParamValues& values, const std::string& key) {
|
const ParamValue* find(const ParamValues& values, const std::string& key) {
|
||||||
@@ -121,6 +154,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;
|
||||||
|
|||||||
+25
-1
@@ -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,6 +27,13 @@ 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 interface shows this number as a slider marked with its
|
||||||
|
// range rather than as a spin button: see slider().
|
||||||
|
bool onSlider = false;
|
||||||
|
// How a slider's travel is spread over its range: the value is
|
||||||
|
// minimum + (maximum - minimum) * position^curve, so 1 is even and a
|
||||||
|
// larger curve gives the low end more of the slider. See slider().
|
||||||
|
double curve = 1.0;
|
||||||
|
|
||||||
std::string flag() const; // "strip_height" -> "--strip-height"
|
std::string flag() const; // "strip_height" -> "--strip-height"
|
||||||
|
|
||||||
@@ -35,6 +42,18 @@ struct ParamSpec {
|
|||||||
// signatures they have.
|
// signatures they have.
|
||||||
ParamSpec styled() const;
|
ParamSpec styled() const;
|
||||||
|
|
||||||
|
// A copy of this Int or Double spec shown as a slider in the interface, for
|
||||||
|
// a number whose place between its minimum and maximum says more than its
|
||||||
|
// digits do. A `curve` above 1 spreads the low end of the range over more
|
||||||
|
// of the slider, for a number whose small values differ the most. The
|
||||||
|
// command line and the project file are unchanged.
|
||||||
|
ParamSpec slider(double curve = 1.0) const;
|
||||||
|
|
||||||
|
// Where a slider at `position` (0 to 1) puts this number, rounded to its
|
||||||
|
// step and kept in range, and where on the slider `value` sits.
|
||||||
|
double sliderValue(double position) const;
|
||||||
|
double sliderPosition(double value) const;
|
||||||
|
|
||||||
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
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);
|
||||||
static ParamSpec makeDouble(std::string key, std::string label, double defaultValue,
|
static ParamSpec makeDouble(std::string key, std::string label, double defaultValue,
|
||||||
@@ -56,6 +75,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>;
|
||||||
|
|||||||
@@ -319,6 +319,25 @@ int canvasDpi(const Json& value) {
|
|||||||
return static_cast<int>(value.number);
|
return static_cast<int>(value.number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A clip's length in seconds and its frame rate, checked like the canvas.
|
||||||
|
double videoDuration(const Json& value) {
|
||||||
|
if (value.kind != Json::Kind::Number || !(value.number >= kMinDuration) ||
|
||||||
|
!(value.number <= kMaxDuration)) {
|
||||||
|
throw EffectError("the video duration must be between " + formatSeconds(kMinDuration) +
|
||||||
|
" and " + formatSeconds(kMaxDuration));
|
||||||
|
}
|
||||||
|
return value.number;
|
||||||
|
}
|
||||||
|
|
||||||
|
int videoFps(const Json& value) {
|
||||||
|
if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number) ||
|
||||||
|
!(value.number >= kMinFps) || !(value.number <= kMaxFps)) {
|
||||||
|
throw EffectError("the video frame rate must be a whole number between " +
|
||||||
|
std::to_string(kMinFps) + " and " + std::to_string(kMaxFps));
|
||||||
|
}
|
||||||
|
return static_cast<int>(value.number);
|
||||||
|
}
|
||||||
|
|
||||||
std::string resolve(const std::string& path, const std::string& directory) {
|
std::string resolve(const std::string& path, const std::string& directory) {
|
||||||
std::filesystem::path file(path);
|
std::filesystem::path file(path);
|
||||||
if (file.is_absolute() || directory.empty()) return path;
|
if (file.is_absolute() || directory.empty()) return path;
|
||||||
@@ -347,6 +366,7 @@ void writeParams(std::ostream& out, const std::vector<ParamSpec>& specs, const P
|
|||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Choice:
|
case ParamSpec::Type::Choice:
|
||||||
case ParamSpec::Type::Text:
|
case ParamSpec::Type::Text:
|
||||||
|
case ParamSpec::Type::Font:
|
||||||
out << quote(paramText(values, spec.key, spec.defaultChoice));
|
out << quote(paramText(values, spec.key, spec.defaultChoice));
|
||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Color: {
|
case ParamSpec::Type::Color: {
|
||||||
@@ -393,6 +413,7 @@ void readParams(const std::vector<ParamSpec>& specs, const Json& params,
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Text:
|
case ParamSpec::Type::Text:
|
||||||
|
case ParamSpec::Type::Font:
|
||||||
if (value->kind == Json::Kind::String) values[spec.key] = value->text;
|
if (value->kind == Json::Kind::String) values[spec.key] = value->text;
|
||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Color: {
|
case ParamSpec::Type::Color: {
|
||||||
@@ -435,6 +456,21 @@ std::string projectToJson(const Project& project, const std::string& directory)
|
|||||||
if (format->print) out << ", \"dpi\": " << project.dpi;
|
if (format->print) out << ", \"dpi\": " << project.dpi;
|
||||||
}
|
}
|
||||||
out << "},\n";
|
out << "},\n";
|
||||||
|
// Only a video says anything about time: a still project reads exactly as
|
||||||
|
// it did before there were videos.
|
||||||
|
if (project.video.enabled) {
|
||||||
|
out << " \"video\": {\"duration\": " << number(project.video.duration, 3)
|
||||||
|
<< ", \"fps\": " << project.video.fps;
|
||||||
|
if (const auto* preset = videoPresetByKey(project.video.preset)) {
|
||||||
|
out << ", \"preset\": " << quote(preset->key);
|
||||||
|
}
|
||||||
|
// Written only when it is not the standard one.
|
||||||
|
if (const auto* quality = videoQualityByKey(project.video.quality);
|
||||||
|
quality != nullptr && quality->key != kDefaultVideoQuality) {
|
||||||
|
out << ", \"quality\": " << quote(quality->key);
|
||||||
|
}
|
||||||
|
out << "},\n";
|
||||||
|
}
|
||||||
if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
|
if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
|
||||||
|
|
||||||
out << " \"sources\": [";
|
out << " \"sources\": [";
|
||||||
@@ -506,6 +542,28 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (const auto* video = root.member("video"); video != nullptr &&
|
||||||
|
video->kind == Json::Kind::Object) {
|
||||||
|
project.video.enabled = true;
|
||||||
|
if (const auto* duration = video->member("duration")) {
|
||||||
|
project.video.duration = videoDuration(*duration);
|
||||||
|
}
|
||||||
|
if (const auto* fps = video->member("fps")) project.video.fps = videoFps(*fps);
|
||||||
|
// Like a canvas format, a preset this build does not know is just the
|
||||||
|
// user's own settings: the numbers were written too.
|
||||||
|
if (const auto* name = video->member("preset"); name != nullptr &&
|
||||||
|
name->kind == Json::Kind::String) {
|
||||||
|
if (const auto* preset = videoPresetByKey(name->text)) project.video.preset = preset->key;
|
||||||
|
}
|
||||||
|
// An unknown quality is the standard one, as it is everywhere else.
|
||||||
|
if (const auto* name = video->member("quality"); name != nullptr &&
|
||||||
|
name->kind == Json::Kind::String) {
|
||||||
|
if (const auto* quality = videoQualityByKey(name->text)) {
|
||||||
|
project.video.quality = quality->key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (const auto* seed = root.member("seed")) {
|
if (const auto* seed = root.member("seed")) {
|
||||||
// Taken from the digits rather than from the double: a 64-bit seed has
|
// Taken from the digits rather than from the double: a 64-bit seed has
|
||||||
// more bits than a double has mantissa, and a seed that comes back off
|
// more bits than a double has mantissa, and a seed that comes back off
|
||||||
@@ -616,4 +674,22 @@ Project loadProject(const std::string& path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string nextExportPath(const std::string& previous) {
|
||||||
|
const std::filesystem::path path(previous);
|
||||||
|
std::string stem = path.stem().string();
|
||||||
|
long count = 0;
|
||||||
|
const auto dash = stem.rfind('-');
|
||||||
|
if (dash != std::string::npos && dash + 1 < stem.size() && stem.size() - dash <= 10 &&
|
||||||
|
stem.find_first_not_of("0123456789", dash + 1) == std::string::npos) {
|
||||||
|
count = std::strtol(stem.c_str() + dash + 1, nullptr, 10);
|
||||||
|
stem.resize(dash);
|
||||||
|
}
|
||||||
|
std::filesystem::path next;
|
||||||
|
std::error_code error;
|
||||||
|
do {
|
||||||
|
next = path.parent_path() / (stem + "-" + std::to_string(++count) + path.extension().string());
|
||||||
|
} while (std::filesystem::exists(next, error));
|
||||||
|
return next.string();
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace effector
|
} // namespace effector
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "core/chain.hpp"
|
#include "core/chain.hpp"
|
||||||
#include "core/params.hpp"
|
#include "core/params.hpp"
|
||||||
#include "core/style.hpp"
|
#include "core/style.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ struct Project {
|
|||||||
// resolution for.
|
// resolution for.
|
||||||
std::string format;
|
std::string format;
|
||||||
int dpi = kDefaultDpi;
|
int dpi = kDefaultDpi;
|
||||||
|
// Whether the project is a video, and how long and how fast.
|
||||||
|
VideoSettings video;
|
||||||
std::optional<std::uint64_t> seed;
|
std::optional<std::uint64_t> seed;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,4 +54,9 @@ Project projectFromJson(const std::string& text, const std::string& directory =
|
|||||||
void saveProject(const std::string& path, const Project& project);
|
void saveProject(const std::string& path, const Project& project);
|
||||||
Project loadProject(const std::string& path);
|
Project loadProject(const std::string& path);
|
||||||
|
|
||||||
|
// The name to offer for the next export after `previous`: its stem with the
|
||||||
|
// trailing `-N` counted on (`city.png` → `city-1.png`, `city-4.png` →
|
||||||
|
// `city-5.png`), skipping any that already exist in its folder.
|
||||||
|
std::string nextExportPath(const std::string& previous);
|
||||||
|
|
||||||
} // namespace effector
|
} // namespace effector
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
#include "core/video.hpp"
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <spawn.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <locale>
|
||||||
|
#include <mutex>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#include "core/canvas.hpp"
|
||||||
|
#include "core/effect.hpp"
|
||||||
|
|
||||||
|
extern char** environ;
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
int VideoSettings::frameCount() const {
|
||||||
|
const double seconds = std::clamp(duration, kMinDuration, kMaxDuration);
|
||||||
|
const int rate = std::clamp(fps, kMinFps, kMaxFps);
|
||||||
|
return std::max(1, static_cast<int>(std::lround(seconds * rate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<VideoPreset>& videoPresets() {
|
||||||
|
// A story segment runs fifteen seconds, a reel is best kept to half a
|
||||||
|
// minute, and a loop is short enough to watch go round a few times.
|
||||||
|
static const std::vector<VideoPreset> presets = {
|
||||||
|
{"instagram-story", "Instagram story", "instagram-story", 15.0, 30},
|
||||||
|
{"instagram-reel", "Instagram reel", "instagram-story", 30.0, 30},
|
||||||
|
{"instagram-feed", "Instagram feed video", "instagram-portrait", 10.0, 30},
|
||||||
|
{"vertical-short", "TikTok / YouTube Short", "vertical-hd", 30.0, 30},
|
||||||
|
{"square-loop", "Square loop", "instagram-post", 6.0, 30},
|
||||||
|
{"hd-clip", "Full HD clip", "hd", 10.0, 30},
|
||||||
|
};
|
||||||
|
return presets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoPreset* videoPresetByKey(const std::string& key) {
|
||||||
|
if (key.empty()) return nullptr;
|
||||||
|
for (const auto& preset : videoPresets()) {
|
||||||
|
if (preset.key == key) return &preset;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<VideoQuality>& videoQualities() {
|
||||||
|
// Standard is what every video was written with before there was a
|
||||||
|
// choice: CRF 18 is close to what the eye can tell from the frames.
|
||||||
|
static const std::vector<VideoQuality> qualities = {
|
||||||
|
{"small", "Small file", "smaller and quicker to share, with visible softening", 26, "fast",
|
||||||
|
40, 128},
|
||||||
|
{kDefaultVideoQuality, "Standard", "close to the rendered frames at a moderate size", 18,
|
||||||
|
"medium", 32, 256},
|
||||||
|
{"high", "High", "as faithful as it gets, larger and slower to write", 12, "slow", 24, 256},
|
||||||
|
};
|
||||||
|
return qualities;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoQuality* videoQualityByKey(const std::string& key) {
|
||||||
|
for (const auto& quality : videoQualities()) {
|
||||||
|
if (quality.key == key) return &quality;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoQuality& videoQualityOrDefault(const std::string& key) {
|
||||||
|
if (const auto* quality = videoQualityByKey(key)) return *quality;
|
||||||
|
return *videoQualityByKey(kDefaultVideoQuality);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In the C locale whatever the user's is, for the same reason as the
|
||||||
|
// millimetres of a paper size: "7.5 s", never "7,5 s".
|
||||||
|
std::string formatSeconds(double seconds) {
|
||||||
|
std::ostringstream out;
|
||||||
|
out.imbue(std::locale::classic());
|
||||||
|
out.setf(std::ios::fixed);
|
||||||
|
out.precision(1);
|
||||||
|
out << seconds;
|
||||||
|
std::string text = out.str();
|
||||||
|
if (text.size() > 2 && text.compare(text.size() - 2, 2, ".0") == 0) text.resize(text.size() - 2);
|
||||||
|
return text + " s";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string videoPresetDetail(const VideoPreset& preset) {
|
||||||
|
const auto* format = canvasFormatByKey(preset.canvasFormat);
|
||||||
|
std::string text = format != nullptr ? canvasFormatDetail(*format) + " · " : std::string();
|
||||||
|
return text + formatSeconds(preset.seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string lowerExtension(const std::string& path) {
|
||||||
|
std::string extension = std::filesystem::path(path).extension().string();
|
||||||
|
std::transform(extension.begin(), extension.end(), extension.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
return extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The container ffmpeg is told to write, by extension; empty for one that
|
||||||
|
// is not a video.
|
||||||
|
std::string muxerFor(const std::string& extension) {
|
||||||
|
if (extension == ".mp4") return "mp4";
|
||||||
|
if (extension == ".mov") return "mov";
|
||||||
|
if (extension == ".mkv") return "matroska";
|
||||||
|
if (extension == ".webm") return "webm";
|
||||||
|
if (extension == ".gif") return "gif";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool isVideoPath(const std::string& path) { return !muxerFor(lowerExtension(path)).empty(); }
|
||||||
|
|
||||||
|
bool ffmpegAvailable() {
|
||||||
|
const char* path = std::getenv("PATH");
|
||||||
|
if (path == nullptr) return false;
|
||||||
|
std::stringstream dirs(path);
|
||||||
|
std::string dir;
|
||||||
|
while (std::getline(dirs, dir, ':')) {
|
||||||
|
if (dir.empty()) dir = ".";
|
||||||
|
if (::access((dir + "/ffmpeg").c_str(), X_OK) == 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoWriter::VideoWriter(const std::string& path, int width, int height, int fps,
|
||||||
|
const std::string& quality)
|
||||||
|
: path_(path), width_(width), height_(height) {
|
||||||
|
const std::string muxer = muxerFor(lowerExtension(path));
|
||||||
|
if (muxer.empty()) {
|
||||||
|
throw EffectError("A video is written as .mp4, .mov, .mkv, .webm or .gif, not '" +
|
||||||
|
std::filesystem::path(path).filename().string() + "'.");
|
||||||
|
}
|
||||||
|
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||||
|
|
||||||
|
// A write to an ffmpeg that has already died must come back as an error
|
||||||
|
// to report, not as a signal that takes the whole program down with it.
|
||||||
|
static std::once_flag ignorePipe;
|
||||||
|
std::call_once(ignorePipe, [] { ::signal(SIGPIPE, SIG_IGN); });
|
||||||
|
|
||||||
|
const VideoQuality& squeeze = videoQualityOrDefault(quality);
|
||||||
|
std::vector<std::string> args = {
|
||||||
|
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||||
|
"-f", "rawvideo", "-pix_fmt", "rgb24",
|
||||||
|
"-s", std::to_string(width) + "x" + std::to_string(height),
|
||||||
|
"-r", std::to_string(std::clamp(fps, kMinFps, kMaxFps)),
|
||||||
|
"-i", "pipe:0", "-an",
|
||||||
|
};
|
||||||
|
if (muxer == "gif") {
|
||||||
|
// One palette made for the whole clip, rather than the fixed web one.
|
||||||
|
args.insert(args.end(), {"-vf", "split[a][b];[b]palettegen=max_colors=" +
|
||||||
|
std::to_string(squeeze.gifColors) +
|
||||||
|
"[p];[a][p]paletteuse",
|
||||||
|
"-loop", "0"});
|
||||||
|
} else {
|
||||||
|
// 4:2:0 wants even sides; a pixel of padding beats refusing the canvas.
|
||||||
|
args.insert(args.end(), {"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"});
|
||||||
|
if (muxer == "webm") {
|
||||||
|
args.insert(args.end(), {"-c:v", "libvpx-vp9", "-crf", std::to_string(squeeze.vp9Crf),
|
||||||
|
"-b:v", "0",
|
||||||
|
"-row-mt", "1"});
|
||||||
|
} else {
|
||||||
|
args.insert(args.end(), {"-c:v", "libx264", "-preset", squeeze.h264Preset, "-crf",
|
||||||
|
std::to_string(squeeze.h264Crf)});
|
||||||
|
}
|
||||||
|
args.insert(args.end(), {"-pix_fmt", "yuv420p"});
|
||||||
|
if (muxer == "mp4" || muxer == "mov") args.insert(args.end(), {"-movflags", "+faststart"});
|
||||||
|
}
|
||||||
|
// "file:" keeps a name that starts with a dash, or holds a colon, a file.
|
||||||
|
args.insert(args.end(), {"-f", muxer, "file:" + path});
|
||||||
|
|
||||||
|
int pipeEnds[2] = {-1, -1};
|
||||||
|
if (::pipe2(pipeEnds, O_CLOEXEC) != 0) {
|
||||||
|
throw EffectError(std::string("Could not start ffmpeg: ") + std::strerror(errno));
|
||||||
|
}
|
||||||
|
errors_ = std::tmpfile();
|
||||||
|
if (errors_ != nullptr) ::fcntl(fileno(errors_), F_SETFD, FD_CLOEXEC);
|
||||||
|
|
||||||
|
posix_spawn_file_actions_t actions;
|
||||||
|
posix_spawn_file_actions_init(&actions);
|
||||||
|
posix_spawn_file_actions_adddup2(&actions, pipeEnds[0], STDIN_FILENO);
|
||||||
|
if (errors_ != nullptr) posix_spawn_file_actions_adddup2(&actions, fileno(errors_), STDERR_FILENO);
|
||||||
|
|
||||||
|
std::vector<char*> argv;
|
||||||
|
for (auto& arg : args) argv.push_back(arg.data());
|
||||||
|
argv.push_back(nullptr);
|
||||||
|
|
||||||
|
pid_t child = -1;
|
||||||
|
const int spawned = ::posix_spawnp(&child, "ffmpeg", &actions, nullptr, argv.data(), environ);
|
||||||
|
posix_spawn_file_actions_destroy(&actions);
|
||||||
|
::close(pipeEnds[0]);
|
||||||
|
if (spawned != 0) {
|
||||||
|
::close(pipeEnds[1]);
|
||||||
|
if (errors_ != nullptr) std::fclose(errors_);
|
||||||
|
errors_ = nullptr;
|
||||||
|
if (spawned == ENOENT) {
|
||||||
|
throw EffectError("Writing a video needs ffmpeg, which is not installed or not on "
|
||||||
|
"the PATH.");
|
||||||
|
}
|
||||||
|
throw EffectError(std::string("Could not start ffmpeg: ") + std::strerror(spawned));
|
||||||
|
}
|
||||||
|
child_ = child;
|
||||||
|
input_ = pipeEnds[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoWriter::~VideoWriter() {
|
||||||
|
if (child_ > 0) cancel();
|
||||||
|
if (errors_ != nullptr) std::fclose(errors_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoWriter::write(const Image& frame) {
|
||||||
|
if (input_ < 0) throw EffectError("The video has already been closed.");
|
||||||
|
if (frame.width() != width_ || frame.height() != height_) {
|
||||||
|
throw EffectError("Every frame of a video must be the same size.");
|
||||||
|
}
|
||||||
|
const std::uint8_t* data = frame.data();
|
||||||
|
std::size_t left = frame.byteSize();
|
||||||
|
while (left > 0) {
|
||||||
|
const ssize_t written = ::write(input_, data, left);
|
||||||
|
if (written < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
failed("ffmpeg stopped taking frames");
|
||||||
|
}
|
||||||
|
data += written;
|
||||||
|
left -= static_cast<std::size_t>(written);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int VideoWriter::wait() {
|
||||||
|
int status = 0;
|
||||||
|
while (::waitpid(child_, &status, 0) < 0) {
|
||||||
|
if (errno != EINTR) {
|
||||||
|
status = -1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child_ = -1;
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoWriter::finish() {
|
||||||
|
if (child_ <= 0) return;
|
||||||
|
::close(input_);
|
||||||
|
input_ = -1;
|
||||||
|
const int status = wait();
|
||||||
|
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
|
||||||
|
std::error_code ignored;
|
||||||
|
std::filesystem::remove(path_, ignored);
|
||||||
|
std::string message = "ffmpeg could not write the video";
|
||||||
|
const std::string said = errorOutput();
|
||||||
|
if (!said.empty()) message += ": " + said;
|
||||||
|
throw EffectError(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoWriter::cancel() {
|
||||||
|
if (input_ >= 0) {
|
||||||
|
::close(input_);
|
||||||
|
input_ = -1;
|
||||||
|
}
|
||||||
|
if (child_ > 0) {
|
||||||
|
// Not SIGTERM: ffmpeg takes that as "finish up", and flushing a slow
|
||||||
|
// encoder can take many seconds for a file that is thrown away.
|
||||||
|
::kill(child_, SIGKILL);
|
||||||
|
wait();
|
||||||
|
}
|
||||||
|
std::error_code ignored;
|
||||||
|
std::filesystem::remove(path_, ignored);
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoWriter::failed(const std::string& what) {
|
||||||
|
cancel();
|
||||||
|
std::string message = what;
|
||||||
|
const std::string said = errorOutput();
|
||||||
|
if (!said.empty()) message += ": " + said;
|
||||||
|
throw EffectError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last thing ffmpeg said, which is where it names the problem.
|
||||||
|
std::string VideoWriter::errorOutput() const {
|
||||||
|
if (errors_ == nullptr) return {};
|
||||||
|
std::string text;
|
||||||
|
std::rewind(errors_);
|
||||||
|
char buffer[4096];
|
||||||
|
std::size_t read = 0;
|
||||||
|
while ((read = std::fread(buffer, 1, sizeof buffer, errors_)) > 0) text.append(buffer, read);
|
||||||
|
while (!text.empty() && (text.back() == '\n' || text.back() == '\r' || text.back() == ' ')) {
|
||||||
|
text.pop_back();
|
||||||
|
}
|
||||||
|
const auto lastLine = text.rfind('\n');
|
||||||
|
if (lastLine != std::string::npos) text.erase(0, lastLine + 1);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <sys/types.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/image.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// The frame rates and lengths both front-ends accept. Five minutes at sixty
|
||||||
|
// frames is 18000 frames -- already a long wait -- and a tenth of a second is
|
||||||
|
// the shortest clip that still has a few frames in it.
|
||||||
|
inline constexpr int kMinFps = 1;
|
||||||
|
inline constexpr int kMaxFps = 60;
|
||||||
|
inline constexpr int kDefaultFps = 30;
|
||||||
|
inline constexpr double kMinDuration = 0.1;
|
||||||
|
inline constexpr double kMaxDuration = 300.0;
|
||||||
|
inline constexpr double kDefaultDuration = 10.0;
|
||||||
|
|
||||||
|
// Whether a project is a video, and how long and how fast. A still project
|
||||||
|
// carries the other fields too, so switching video off and on again gives
|
||||||
|
// the same clip back.
|
||||||
|
struct VideoSettings {
|
||||||
|
bool enabled = false;
|
||||||
|
double duration = kDefaultDuration; // seconds
|
||||||
|
int fps = kDefaultFps;
|
||||||
|
// The preset the settings came from, empty for the user's own. Like a
|
||||||
|
// canvas format it is the presentation of the numbers, never the truth:
|
||||||
|
// the render goes by duration and fps.
|
||||||
|
std::string preset;
|
||||||
|
// How hard the file is squeezed: a key of videoQualities(), empty or
|
||||||
|
// unknown for the standard one. Only the encoded file hears of it; the
|
||||||
|
// frames are the same at every quality.
|
||||||
|
std::string quality;
|
||||||
|
|
||||||
|
// The frames the clip has: the length at the rate, never fewer than one.
|
||||||
|
int frameCount() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A named kind of clip both front-ends offer: a canvas and a length that go
|
||||||
|
// together, so "Instagram story" is one choice rather than three.
|
||||||
|
struct VideoPreset {
|
||||||
|
std::string key; // written in a project and given to --video-preset
|
||||||
|
std::string label; // shown in the interface
|
||||||
|
std::string canvasFormat; // a key of canvasFormats()
|
||||||
|
double seconds = 0.0;
|
||||||
|
int fps = kDefaultFps;
|
||||||
|
};
|
||||||
|
|
||||||
|
// In the order they are offered.
|
||||||
|
const std::vector<VideoPreset>& videoPresets();
|
||||||
|
|
||||||
|
// Null when the key names no preset -- an empty or unknown key is "custom".
|
||||||
|
const VideoPreset* videoPresetByKey(const std::string& key);
|
||||||
|
|
||||||
|
// "1080 × 1920 px · 15 s", for the dropdown and for --help.
|
||||||
|
std::string videoPresetDetail(const VideoPreset& preset);
|
||||||
|
|
||||||
|
// How a video file is encoded, from small and quick to large and faithful.
|
||||||
|
// The numbers go to ffmpeg by the codec the file's extension picks.
|
||||||
|
struct VideoQuality {
|
||||||
|
std::string key; // written in a project and given to --video-quality
|
||||||
|
std::string label; // shown in the interface
|
||||||
|
std::string description; // for the tooltip and --help
|
||||||
|
int h264Crf = 18; // .mp4, .mov, .mkv: lower is better and larger
|
||||||
|
std::string h264Preset; // x264's speed preset: slower packs tighter
|
||||||
|
int vp9Crf = 32; // .webm
|
||||||
|
int gifColors = 256; // .gif palette size
|
||||||
|
};
|
||||||
|
|
||||||
|
// In the order they are offered, smallest first.
|
||||||
|
const std::vector<VideoQuality>& videoQualities();
|
||||||
|
|
||||||
|
// The standard quality, which an empty or unknown key stands for.
|
||||||
|
inline constexpr const char* kDefaultVideoQuality = "standard";
|
||||||
|
|
||||||
|
// Null when the key names no quality.
|
||||||
|
const VideoQuality* videoQualityByKey(const std::string& key);
|
||||||
|
|
||||||
|
// The quality a key stands for: the standard one for an empty or unknown key.
|
||||||
|
const VideoQuality& videoQualityOrDefault(const std::string& key);
|
||||||
|
|
||||||
|
// "15 s", "7.5 s": a length as the interface and --help show it.
|
||||||
|
std::string formatSeconds(double seconds);
|
||||||
|
|
||||||
|
// Whether a file name asks for a video: .mp4, .mov, .mkv, .webm or .gif.
|
||||||
|
bool isVideoPath(const std::string& path);
|
||||||
|
|
||||||
|
// Whether an `ffmpeg` the writer below can run is on the PATH.
|
||||||
|
bool ffmpegAvailable();
|
||||||
|
|
||||||
|
// Encodes frames into a video file by piping them, raw, into the `ffmpeg`
|
||||||
|
// program. Run as a plain argv rather than through a shell, so a file name is
|
||||||
|
// never read as anything but a file name. The container and codec follow the
|
||||||
|
// extension: H.264 for .mp4, .mov and .mkv, VP9 for .webm, a palette GIF for
|
||||||
|
// .gif, squeezed as the quality says. The encoded bytes are ffmpeg's business; the frames going in are the
|
||||||
|
// ones the seed decides.
|
||||||
|
//
|
||||||
|
// Throws EffectError when ffmpeg is missing, fails, or quits early, with what
|
||||||
|
// it had to say. A writer that is neither finished nor cancelled cancels
|
||||||
|
// itself on destruction, so a half-written file is never left behind.
|
||||||
|
class VideoWriter {
|
||||||
|
public:
|
||||||
|
// The quality is a key of videoQualities(); empty means the standard one.
|
||||||
|
VideoWriter(const std::string& path, int width, int height, int fps,
|
||||||
|
const std::string& quality = {});
|
||||||
|
~VideoWriter();
|
||||||
|
VideoWriter(const VideoWriter&) = delete;
|
||||||
|
VideoWriter& operator=(const VideoWriter&) = delete;
|
||||||
|
|
||||||
|
// Every frame must be the size the writer was opened with.
|
||||||
|
void write(const Image& frame);
|
||||||
|
// Closes the stream, waits for ffmpeg and throws if it failed.
|
||||||
|
void finish();
|
||||||
|
// Stops ffmpeg and removes the unfinished file.
|
||||||
|
void cancel();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void failed(const std::string& what); // cancels, then throws with ffmpeg's words
|
||||||
|
std::string errorOutput() const;
|
||||||
|
int wait();
|
||||||
|
|
||||||
|
std::string path_;
|
||||||
|
int width_ = 0;
|
||||||
|
int height_ = 0;
|
||||||
|
pid_t child_ = -1;
|
||||||
|
int input_ = -1; // the write end of ffmpeg's stdin
|
||||||
|
std::FILE* errors_ = nullptr; // ffmpeg's stderr, read back on failure
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
+2
-64
@@ -1,10 +1,9 @@
|
|||||||
#include "effects/blur.hpp"
|
#include "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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "core/image.hpp"
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// One separable box blur pass. `stride` is the distance in bytes between two
|
||||||
|
// neighbouring samples along the blurred axis, `count` how many of them there
|
||||||
|
// are; the window is clamped at both ends.
|
||||||
|
inline void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride,
|
||||||
|
int radius) {
|
||||||
|
const int window = 2 * radius + 1;
|
||||||
|
int sum[3] = {0, 0, 0};
|
||||||
|
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
sum[c] = in[c] * (radius + 1);
|
||||||
|
for (int i = 1; i <= radius; ++i) {
|
||||||
|
sum[c] += in[static_cast<std::size_t>(std::min(i, count - 1)) * stride + c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
out[static_cast<std::size_t>(i) * stride + c] =
|
||||||
|
static_cast<std::uint8_t>(sum[c] / window);
|
||||||
|
}
|
||||||
|
const int add = std::min(i + radius + 1, count - 1);
|
||||||
|
const int drop = std::max(i - radius, 0);
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
sum[c] += in[static_cast<std::size_t>(add) * stride + c] -
|
||||||
|
in[static_cast<std::size_t>(drop) * stride + c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void blurHorizontal(const Image& src, Image& dst, int radius) {
|
||||||
|
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
|
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
|
||||||
|
radius);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void blurVertical(const Image& src, Image& dst, int radius) {
|
||||||
|
const std::size_t stride = src.stride();
|
||||||
|
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t x = begin; x < end; ++x) {
|
||||||
|
const std::size_t offset = x * 3;
|
||||||
|
boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blurs `canvas` in place: `passes` box blurs of `radius` along the axes
|
||||||
|
// `direction` names ("both", "horizontal" or "vertical"). Shared by the blur
|
||||||
|
// and the focus pull effects.
|
||||||
|
inline void boxBlur(Image& canvas, int radius, int passes, const std::string& direction) {
|
||||||
|
const int width = canvas.width();
|
||||||
|
const int height = canvas.height();
|
||||||
|
Image scratch(width, height);
|
||||||
|
for (int pass = 0; pass < passes; ++pass) {
|
||||||
|
if (direction != "vertical") {
|
||||||
|
blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1)));
|
||||||
|
std::swap(canvas, scratch);
|
||||||
|
}
|
||||||
|
if (direction != "horizontal") {
|
||||||
|
blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1)));
|
||||||
|
std::swap(canvas, scratch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
+3
-75
@@ -1,10 +1,9 @@
|
|||||||
#include "effects/corrupt.hpp"
|
#include "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 = █
|
if (bleed) carried = █
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/image.hpp"
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "core/rng.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// The damage a codec does to one block of the picture. Shared by the corrupt
|
||||||
|
// and the datamosh effects.
|
||||||
|
enum class Damage : std::uint8_t { None, Repeat, Displace, Colour };
|
||||||
|
|
||||||
|
struct Block {
|
||||||
|
Damage damage = Damage::None;
|
||||||
|
int sx = 0; // displace: where the block is read from
|
||||||
|
int sy = 0;
|
||||||
|
int shift[3] = {0, 0, 0}; // colour: added to each channel
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draws the damage of one block, of the kind `kinds` names ("mixed" picks
|
||||||
|
// one), for a block `bx` blocks from the left of a canvas `width` by `height`.
|
||||||
|
inline Block drawDamage(Rng& rng, const std::string& kinds, int bx, int width, int height,
|
||||||
|
int size) {
|
||||||
|
Block block;
|
||||||
|
if (kinds == "repeat") block.damage = Damage::Repeat;
|
||||||
|
else if (kinds == "displace") block.damage = Damage::Displace;
|
||||||
|
else if (kinds == "colour") block.damage = Damage::Colour;
|
||||||
|
else block.damage = static_cast<Damage>(1 + rng.below(2));
|
||||||
|
// The first block of a row has nothing to its left to repeat.
|
||||||
|
if (block.damage == Damage::Repeat && bx == 0) block.damage = Damage::Displace;
|
||||||
|
if (block.damage == Damage::Displace) {
|
||||||
|
block.sx = static_cast<int>(rng.inRange(0, std::max(0, width - size)));
|
||||||
|
block.sy = static_cast<int>(rng.inRange(0, std::max(0, height - size)));
|
||||||
|
} else if (block.damage == Damage::Colour) {
|
||||||
|
for (int& shift : block.shift) shift = static_cast<int>(rng.inRange(-128, 128));
|
||||||
|
}
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lays the damage map `plan` (`cols` by `rows` blocks of `size` pixels, in
|
||||||
|
// reading order) over `canvas`, which starts as a copy of `input`. A displaced
|
||||||
|
// block reads `input` from (sx, sy) on, wrapping across its width.
|
||||||
|
inline void paintBlocks(Image& canvas, const Image& input, const std::vector<Block>& plan, int cols,
|
||||||
|
int rows, int size) {
|
||||||
|
const int width = canvas.width();
|
||||||
|
const int height = canvas.height();
|
||||||
|
// Block rows are independent (a repeat only ever reads to its left), so
|
||||||
|
// they run in parallel and each walks its row in order.
|
||||||
|
parallelFor(static_cast<std::size_t>(rows), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t by = begin; by < end; ++by) {
|
||||||
|
const int y0 = static_cast<int>(by) * size;
|
||||||
|
const int h = std::min(size, height - y0);
|
||||||
|
for (int bx = 0; bx < cols; ++bx) {
|
||||||
|
const Block& block = plan[by * static_cast<std::size_t>(cols) + bx];
|
||||||
|
if (block.damage == Damage::None) continue;
|
||||||
|
const int x0 = bx * size;
|
||||||
|
const int w = std::min(size, width - x0);
|
||||||
|
const std::size_t bytes = static_cast<std::size_t>(w) * 3;
|
||||||
|
|
||||||
|
for (int line = 0; line < h; ++line) {
|
||||||
|
std::uint8_t* out = canvas.row(y0 + line) + static_cast<std::size_t>(x0) * 3;
|
||||||
|
switch (block.damage) {
|
||||||
|
case Damage::Repeat:
|
||||||
|
std::memmove(out, out - static_cast<std::size_t>(size) * 3, bytes);
|
||||||
|
break;
|
||||||
|
case Damage::Displace: {
|
||||||
|
const int sy = std::min(block.sy + line, height - 1);
|
||||||
|
const std::uint8_t* in = input.row(sy);
|
||||||
|
for (int x = 0; x < w; ++x) {
|
||||||
|
const int sx = (block.sx + x) % width;
|
||||||
|
std::memcpy(out + static_cast<std::size_t>(x) * 3,
|
||||||
|
in + static_cast<std::size_t>(sx) * 3, 3);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Damage::Colour:
|
||||||
|
for (std::size_t i = 0; i < bytes; ++i) {
|
||||||
|
out[i] = static_cast<std::uint8_t>(
|
||||||
|
std::clamp(out[i] + block.shift[i % 3], 0, 255));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Damage::None:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#include "effects/datamosh.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "effects/corrupt_blocks.hpp"
|
||||||
|
#include "effects/sampling.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& DatamoshEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeInt("mosh_block", "Block size", 16, 2, 256,
|
||||||
|
"Width and height in pixels of one block, as a codec would cut them."),
|
||||||
|
ParamSpec::makeDouble("mosh_rate", "Rate", 0.1, 0.0, 1.0,
|
||||||
|
"Chance of each block breaking at some moment of the cycle."),
|
||||||
|
ParamSpec::makeChoice("mosh_kinds", "Damage", "mixed",
|
||||||
|
{"mixed", "repeat", "displace", "colour"},
|
||||||
|
"repeat freezes a block into a copy of the one to its left, "
|
||||||
|
"displace pulls one in from elsewhere in the image, colour knocks "
|
||||||
|
"it off colour; mixed picks one of them for every block."),
|
||||||
|
ParamSpec::makeDouble("mosh_hold", "Hold", 0.3, 0.01, 1.0,
|
||||||
|
"How much of each cycle a broken block stays broken, give or take "
|
||||||
|
"half; 1 keeps it broken throughout."),
|
||||||
|
ParamSpec::makeInt("mosh_drift", "Drift", 24, 0, 4096,
|
||||||
|
"How far in pixels a displaced block's picture slides while it "
|
||||||
|
"shows."),
|
||||||
|
ParamSpec::makeDouble("mosh_speed", "Speed", 0.5, 0.0, 10.0,
|
||||||
|
"Cycles per second, rounded so that the clip loops without a jump; "
|
||||||
|
"0 holds still.",
|
||||||
|
0.05, 2),
|
||||||
|
ParamSpec::makeBool("mosh_bleed", "Bleed", false,
|
||||||
|
"Carry each broken block's fault on to the end of its row, the way "
|
||||||
|
"one broken bit spoils the rest of a stream."),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Settings {
|
||||||
|
int size;
|
||||||
|
double rate;
|
||||||
|
std::string kinds;
|
||||||
|
double hold;
|
||||||
|
int drift;
|
||||||
|
double speed;
|
||||||
|
bool bleed;
|
||||||
|
|
||||||
|
explicit Settings(const ParamValues& values)
|
||||||
|
: size(static_cast<int>(std::max(2L, paramInt(values, "mosh_block", 16)))),
|
||||||
|
rate(std::clamp(paramDouble(values, "mosh_rate", 0.1), 0.0, 1.0)),
|
||||||
|
kinds(paramChoice(values, "mosh_kinds", "mixed")),
|
||||||
|
hold(std::clamp(paramDouble(values, "mosh_hold", 0.3), 0.01, 1.0)),
|
||||||
|
drift(static_cast<int>(std::max(0L, paramInt(values, "mosh_drift", 24)))),
|
||||||
|
speed(std::max(0.0, paramDouble(values, "mosh_speed", 0.5))),
|
||||||
|
bleed(paramBool(values, "mosh_bleed", false)) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A block that breaks at some moment of the cycle: how, from when, for how
|
||||||
|
// long (both as parts of a cycle), and which way a displaced one smears.
|
||||||
|
struct Break {
|
||||||
|
Block block;
|
||||||
|
double start = 0.0;
|
||||||
|
double length = 0.0;
|
||||||
|
double driftX = 0.0;
|
||||||
|
double driftY = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image DatamoshEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||||
|
const Settings settings(values);
|
||||||
|
const int width = input.width();
|
||||||
|
const int height = input.height();
|
||||||
|
const int size = settings.size;
|
||||||
|
const int cols = (width + size - 1) / size;
|
||||||
|
const int rows = (height + size - 1) / size;
|
||||||
|
|
||||||
|
// What stays put for the whole clip: the whole map of breaks, block by
|
||||||
|
// block in reading order, as Corrupt draws its damage.
|
||||||
|
Rng steady = at.steady();
|
||||||
|
std::vector<Break> breaks(static_cast<std::size_t>(cols) * rows);
|
||||||
|
bool any = false;
|
||||||
|
for (int by = 0; by < rows; ++by) {
|
||||||
|
const Break* carried = nullptr;
|
||||||
|
for (int bx = 0; bx < cols; ++bx) {
|
||||||
|
Break& piece = breaks[static_cast<std::size_t>(by) * cols + bx];
|
||||||
|
if (carried != nullptr) {
|
||||||
|
piece = *carried;
|
||||||
|
// A displaced run keeps moving along with the row, so the
|
||||||
|
// stretch reads as one shifted strip.
|
||||||
|
if (piece.block.damage == Damage::Displace) piece.block.sx += size;
|
||||||
|
carried = &piece;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!chance(steady.next(), settings.rate)) continue;
|
||||||
|
piece.block = drawDamage(steady, settings.kinds, bx, width, height, size);
|
||||||
|
piece.start = unitDouble(steady.next());
|
||||||
|
piece.length = std::min(1.0, settings.hold * (0.5 + unitDouble(steady.next())));
|
||||||
|
sinCosDegrees(unitDouble(steady.next()) * 360.0, piece.driftY, piece.driftX);
|
||||||
|
if (settings.bleed) carried = &piece;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!any) return input;
|
||||||
|
|
||||||
|
// Where this frame is in time: which blocks are broken, and how far
|
||||||
|
// through its stretch each displaced one is.
|
||||||
|
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||||
|
std::vector<Block> plan(breaks.size());
|
||||||
|
for (std::size_t i = 0; i < breaks.size(); ++i) {
|
||||||
|
const Break& piece = breaks[i];
|
||||||
|
if (piece.block.damage == Damage::None) continue;
|
||||||
|
const double into = t - piece.start - std::floor(t - piece.start);
|
||||||
|
if (piece.length < 1.0 && into >= piece.length) continue;
|
||||||
|
Block& block = plan[i];
|
||||||
|
block = piece.block;
|
||||||
|
if (block.damage == Damage::Displace && settings.drift > 0) {
|
||||||
|
const double slide = settings.drift * into / piece.length;
|
||||||
|
const int sx = block.sx + static_cast<int>(std::lround(slide * piece.driftX));
|
||||||
|
block.sx = (sx % width + width) % width;
|
||||||
|
block.sy = std::clamp(block.sy + static_cast<int>(std::lround(slide * piece.driftY)), 0,
|
||||||
|
height - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Image canvas = input;
|
||||||
|
paintBlocks(canvas, input, plan, cols, rows, size);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// The corrupt effect in motion: blocks of the picture break as a damaged
|
||||||
|
// stream breaks them, and each broken block holds for a stretch of the clip
|
||||||
|
// before it heals, a displaced one smearing its picture along while it shows.
|
||||||
|
//
|
||||||
|
// Which blocks break, how, and when in the cycle each is broken is drawn from
|
||||||
|
// at.steady(); a frame only asks which of them are broken at its moment, so
|
||||||
|
// the clip loops without a jump.
|
||||||
|
class DatamoshEffect : public VideoEffect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "datamosh"; }
|
||||||
|
std::string title() const override { return "Datamosh"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "Breaks blocks of the picture as Corrupt does, each for a stretch of the clip, "
|
||||||
|
"displaced blocks smearing along while they show.";
|
||||||
|
}
|
||||||
|
const std::vector<ParamSpec>& params() const override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#include "effects/filmgate.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <numbers>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "effects/grain_field.hpp"
|
||||||
|
#include "effects/sampling.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& FilmGateEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeDouble("gate_grain", "Grain", 0.3, 0.0, 1.0,
|
||||||
|
"How far a grain may push a pixel, as a fraction of full scale; "
|
||||||
|
"the grain is new on every frame."),
|
||||||
|
ParamSpec::makeInt("gate_grain_size", "Grain size", 2, 1, 32,
|
||||||
|
"Width in pixels of one grain; larger sizes look coarser."),
|
||||||
|
ParamSpec::makeInt("gate_weave", "Weave", 2, 0, 64,
|
||||||
|
"Furthest in pixels the picture sways sideways in the gate, and half "
|
||||||
|
"that up and down."),
|
||||||
|
ParamSpec::makeDouble("gate_flicker", "Flicker", 0.06, 0.0, 1.0,
|
||||||
|
"How far the brightness may jump from one frame to the next, as a "
|
||||||
|
"fraction of itself."),
|
||||||
|
ParamSpec::makeInt("gate_dust", "Dust", 3, 0, 500,
|
||||||
|
"Specks of dust on each frame, every one there for that frame alone."),
|
||||||
|
ParamSpec::makeInt("gate_scratches", "Scratches", 1, 0, 16,
|
||||||
|
"Thin bright lines running down the film, wandering a little and "
|
||||||
|
"now and then missing a frame."),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Settings {
|
||||||
|
double grain;
|
||||||
|
int grainSize;
|
||||||
|
int weave;
|
||||||
|
double flicker;
|
||||||
|
int dust;
|
||||||
|
int scratches;
|
||||||
|
|
||||||
|
explicit Settings(const ParamValues& values)
|
||||||
|
: grain(std::clamp(paramDouble(values, "gate_grain", 0.3), 0.0, 1.0)),
|
||||||
|
grainSize(static_cast<int>(std::max(1L, paramInt(values, "gate_grain_size", 2)))),
|
||||||
|
weave(static_cast<int>(std::max(0L, paramInt(values, "gate_weave", 2)))),
|
||||||
|
flicker(std::clamp(paramDouble(values, "gate_flicker", 0.06), 0.0, 1.0)),
|
||||||
|
dust(static_cast<int>(std::max(0L, paramInt(values, "gate_dust", 3)))),
|
||||||
|
scratches(static_cast<int>(std::max(0L, paramInt(values, "gate_scratches", 1)))) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
double unit(Rng& rng) { return unitDouble(rng.next()); }
|
||||||
|
|
||||||
|
// The swings the sway is made of: roughly how often each comes round a
|
||||||
|
// second, and how much of the sway it carries.
|
||||||
|
constexpr std::array<double, 3> kSwingRate = {0.7, 1.9, 4.3};
|
||||||
|
constexpr std::array<double, 3> kSwingShare = {0.5, 0.3, 0.2};
|
||||||
|
|
||||||
|
// Blends a pixel towards `shade` by `mix` out of 256.
|
||||||
|
void towards(std::uint8_t* px, int shade, int mix) {
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
px[c] = static_cast<std::uint8_t>(px[c] + (shade - px[c]) * mix / 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image FilmGateEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||||
|
const Settings settings(values);
|
||||||
|
const int width = input.width();
|
||||||
|
const int height = input.height();
|
||||||
|
constexpr double tau = 2.0 * std::numbers::pi;
|
||||||
|
|
||||||
|
// What stays put for the whole clip: where each swing of the sway
|
||||||
|
// starts, and where each scratch runs down the film.
|
||||||
|
Rng steady = at.steady();
|
||||||
|
std::array<double, 3> phaseX{};
|
||||||
|
std::array<double, 3> phaseY{};
|
||||||
|
for (std::size_t i = 0; i < kSwingRate.size(); ++i) {
|
||||||
|
phaseX[i] = unit(steady) * tau;
|
||||||
|
phaseY[i] = unit(steady) * tau;
|
||||||
|
}
|
||||||
|
std::vector<double> scratchAt(static_cast<std::size_t>(settings.scratches));
|
||||||
|
for (double& x : scratchAt) x = unit(steady);
|
||||||
|
|
||||||
|
// Where this frame is in time: how far the picture has swayed.
|
||||||
|
double swayX = 0.0;
|
||||||
|
double swayY = 0.0;
|
||||||
|
for (std::size_t i = 0; i < kSwingRate.size(); ++i) {
|
||||||
|
const double turn = at.cycle(kSwingRate[i]) * tau;
|
||||||
|
swayX += kSwingShare[i] * std::sin(turn + phaseX[i]);
|
||||||
|
swayY += kSwingShare[i] * std::sin(turn + phaseY[i]);
|
||||||
|
}
|
||||||
|
const int dx = static_cast<int>(std::lround(swayX * settings.weave));
|
||||||
|
const int dy = static_cast<int>(std::lround(swayY * settings.weave * 0.5));
|
||||||
|
|
||||||
|
// What changes on every frame, all drawn before a pixel is touched: the
|
||||||
|
// flicker, the grain, the dust, and which scratches show and where.
|
||||||
|
Rng fresh = at.fresh();
|
||||||
|
const double flicker = settings.flicker * (unit(fresh) * 2.0 - 1.0);
|
||||||
|
const int gain = static_cast<int>(std::lround((1.0 + flicker) * 256.0));
|
||||||
|
GrainField grain;
|
||||||
|
if (settings.grain > 0.0) grain = drawGrain(fresh, width, height, settings.grainSize, false);
|
||||||
|
struct Speck {
|
||||||
|
int x;
|
||||||
|
int y;
|
||||||
|
int radius;
|
||||||
|
};
|
||||||
|
std::vector<Speck> specks(static_cast<std::size_t>(settings.dust));
|
||||||
|
for (Speck& speck : specks) {
|
||||||
|
speck.x = static_cast<int>(fresh.inRange(0, width - 1));
|
||||||
|
speck.y = static_cast<int>(fresh.inRange(0, height - 1));
|
||||||
|
speck.radius = static_cast<int>(fresh.inRange(0, 2));
|
||||||
|
}
|
||||||
|
std::vector<int> scratchX;
|
||||||
|
for (const double x : scratchAt) {
|
||||||
|
const int wander = static_cast<int>(fresh.inRange(-2, 2));
|
||||||
|
if (unit(fresh) < 0.85) scratchX.push_back(static_cast<int>(x * (width - 1)) + wander);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The picture moved in the gate, the edges it leaves held from the last
|
||||||
|
// row and column, and lit up or down by the flicker.
|
||||||
|
Image frame(width, height);
|
||||||
|
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
|
const int sy = std::clamp(static_cast<int>(y) - dy, 0, height - 1);
|
||||||
|
const std::uint8_t* in = input.row(sy);
|
||||||
|
std::uint8_t* out = frame.row(static_cast<int>(y));
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
const int sx = std::clamp(x - dx, 0, width - 1);
|
||||||
|
const std::uint8_t* from = in + static_cast<std::size_t>(sx) * 3;
|
||||||
|
std::uint8_t* to = out + static_cast<std::size_t>(x) * 3;
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
to[c] = static_cast<std::uint8_t>(std::min(255, from[c] * gain >> 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (settings.grain > 0.0) applyGrain(frame, grain, settings.grain, true);
|
||||||
|
|
||||||
|
// Dust lies dark on the picture; a scratch through the emulsion lets the
|
||||||
|
// light through.
|
||||||
|
for (const Speck& speck : specks) {
|
||||||
|
for (int y = speck.y - speck.radius; y <= speck.y + speck.radius; ++y) {
|
||||||
|
if (y < 0 || y >= height) continue;
|
||||||
|
for (int x = speck.x - speck.radius; x <= speck.x + speck.radius; ++x) {
|
||||||
|
if (x < 0 || x >= width) continue;
|
||||||
|
const int ox = x - speck.x;
|
||||||
|
const int oy = y - speck.y;
|
||||||
|
if (ox * ox + oy * oy > speck.radius * speck.radius) continue;
|
||||||
|
towards(frame.row(y) + static_cast<std::size_t>(x) * 3, 16, 220);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const int x : scratchX) {
|
||||||
|
if (x < 0 || x >= width) continue;
|
||||||
|
for (int y = 0; y < height; ++y) {
|
||||||
|
towards(frame.row(y) + static_cast<std::size_t>(x) * 3, 255, 150);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// Film running through a projector: the grain is new on every frame, the
|
||||||
|
// picture sways a little in the gate and its brightness flickers, and dust
|
||||||
|
// and scratches pass over it.
|
||||||
|
//
|
||||||
|
// The sway is a sum of slow swings, each a whole number of times in the
|
||||||
|
// clip, so it loops; grain, flicker and dust are drawn from at.fresh() and
|
||||||
|
// are different on every frame, as they are on film.
|
||||||
|
class FilmGateEffect : public VideoEffect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "filmgate"; }
|
||||||
|
std::string title() const override { return "Film gate"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "Film running through a projector: grain new on every frame, as Film grain draws "
|
||||||
|
"it, a picture swaying in the gate, flicker, dust and scratches.";
|
||||||
|
}
|
||||||
|
const std::vector<ParamSpec>& params() const override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#include "effects/flow.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "effects/noise_field.hpp"
|
||||||
|
#include "effects/sampling.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& FlowEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeInt("flow_strength", "Strength", 30, 0, 4096,
|
||||||
|
"How far in pixels the field pushes the image at its strongest."),
|
||||||
|
ParamSpec::makeInt("flow_scale", "Scale", 160, 4, 8192,
|
||||||
|
"Size in pixels of the largest features in the field."),
|
||||||
|
ParamSpec::makeInt("flow_octaves", "Octaves", 3, 1, 6,
|
||||||
|
"Finer layers of noise laid over the first; more give a turbulent "
|
||||||
|
"edge to the flow."),
|
||||||
|
ParamSpec::makeChoice("flow_mode", "Mode", "displace", {"displace", "swirl"},
|
||||||
|
"displace pushes along the field; swirl flows along its contour "
|
||||||
|
"lines, which curls the image into eddies."),
|
||||||
|
ParamSpec::makeInt("flow_travel", "Travel", 80, 0, 4096,
|
||||||
|
"Across how many pixels the field drifts in each cycle; the finer "
|
||||||
|
"layers drift half as far and turn faster."),
|
||||||
|
ParamSpec::makeDouble("flow_speed", "Speed", 0.25, 0.0, 10.0,
|
||||||
|
"Cycles per second, rounded so that the clip loops without a jump; "
|
||||||
|
"0 holds still.",
|
||||||
|
0.05, 2),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Settings {
|
||||||
|
double strength;
|
||||||
|
double scale;
|
||||||
|
int octaves;
|
||||||
|
bool swirl;
|
||||||
|
int travel;
|
||||||
|
double speed;
|
||||||
|
|
||||||
|
explicit Settings(const ParamValues& values)
|
||||||
|
: strength(static_cast<double>(std::max(0L, paramInt(values, "flow_strength", 30)))),
|
||||||
|
scale(static_cast<double>(std::max(4L, paramInt(values, "flow_scale", 160)))),
|
||||||
|
octaves(static_cast<int>(std::clamp(paramInt(values, "flow_octaves", 3), 1L, 6L))),
|
||||||
|
swirl(paramChoice(values, "flow_mode", "displace") == "swirl"),
|
||||||
|
travel(static_cast<int>(std::max(0L, paramInt(values, "flow_travel", 80)))),
|
||||||
|
speed(std::max(0.0, paramDouble(values, "flow_speed", 0.25))) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which way round, and from where, one layer of a field drifts.
|
||||||
|
struct Orbit {
|
||||||
|
double start; // degrees
|
||||||
|
double turn; // +1 or -1
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image FlowEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||||
|
const Settings settings(values);
|
||||||
|
const int width = input.width();
|
||||||
|
const int height = input.height();
|
||||||
|
if (settings.strength <= 0.0) return input;
|
||||||
|
|
||||||
|
// What stays put for the whole clip: the field, drawn a circle's radius
|
||||||
|
// larger on every side than the picture, so it can drift without running
|
||||||
|
// out, and where each of its layers starts its circle, and which way.
|
||||||
|
const double reach = settings.travel * 0.5;
|
||||||
|
const int margin = static_cast<int>(reach) + 2;
|
||||||
|
Rng steady = at.steady();
|
||||||
|
const WarpField field(steady, width + 2 * margin, height + 2 * margin, settings.scale,
|
||||||
|
settings.octaves, settings.swirl);
|
||||||
|
std::vector<Orbit> orbits(static_cast<std::size_t>(settings.octaves) * 2);
|
||||||
|
for (Orbit& orbit : orbits) {
|
||||||
|
orbit.start = unitDouble(steady.next()) * 360.0;
|
||||||
|
orbit.turn = (steady.next() >> 63) != 0 ? 1.0 : -1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where this frame is in time: layer i has gone i + 1 times round a
|
||||||
|
// circle half as wide as the one before in a whole cycle.
|
||||||
|
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||||
|
std::vector<NoiseShift> firstShifts(static_cast<std::size_t>(settings.octaves));
|
||||||
|
std::vector<NoiseShift> secondShifts(static_cast<std::size_t>(settings.octaves));
|
||||||
|
double radius = reach;
|
||||||
|
for (int i = 0; i < settings.octaves; ++i) {
|
||||||
|
for (int f = 0; f < 2; ++f) {
|
||||||
|
const Orbit& orbit = orbits[static_cast<std::size_t>(i * 2 + f)];
|
||||||
|
double sine = 0.0;
|
||||||
|
double cosine = 1.0;
|
||||||
|
sinCosDegrees(orbit.start + orbit.turn * (i + 1) * 360.0 * t, sine, cosine);
|
||||||
|
NoiseShift& shift = (f == 0 ? firstShifts : secondShifts)[static_cast<std::size_t>(i)];
|
||||||
|
shift.x = margin + radius * cosine;
|
||||||
|
shift.y = margin + radius * sine;
|
||||||
|
}
|
||||||
|
radius *= 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
Image frame(width, height);
|
||||||
|
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t row = begin; row < end; ++row) {
|
||||||
|
std::uint8_t* out = frame.row(static_cast<int>(row));
|
||||||
|
const double y = static_cast<double>(row);
|
||||||
|
for (int col = 0; col < width; ++col) {
|
||||||
|
const double x = col;
|
||||||
|
double dx = 0.0;
|
||||||
|
double dy = 0.0;
|
||||||
|
field.push(x, y, settings.strength, dx, dy, &firstShifts, &secondShifts);
|
||||||
|
sampleBilinear(input, x + dx, y + dy, out + static_cast<std::size_t>(col) * 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// The warp effect in motion: the same field of noise pushes or swirls the
|
||||||
|
// picture, and its layers drift, each round a circle of its own, so the
|
||||||
|
// picture ripples like heat haze or water under wind.
|
||||||
|
//
|
||||||
|
// Every layer of the field goes round its circle a whole number of times in a
|
||||||
|
// cycle -- the finer layers smaller and faster -- so the field, and with it
|
||||||
|
// the clip, comes back to where it started without a jump.
|
||||||
|
class FlowEffect : public VideoEffect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "flow"; }
|
||||||
|
std::string title() const override { return "Warp flow"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "Pushes the picture through a field of noise, as Warp does, and sets the field "
|
||||||
|
"drifting so the picture ripples.";
|
||||||
|
}
|
||||||
|
const std::vector<ParamSpec>& params() const override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#include "effects/focuspull.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <numbers>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "effects/box_blur.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& FocusPullEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeChoice("focus_mode", "Mode", "breathe", {"breathe", "band"},
|
||||||
|
"breathe pulls the whole picture out of focus and back; band "
|
||||||
|
"moves a band of sharpness down the picture and back up."),
|
||||||
|
ParamSpec::makeInt("focus_radius", "Radius", 12, 0, 256,
|
||||||
|
"Half width in pixels of the blur at its strongest."),
|
||||||
|
ParamSpec::makeDouble("focus_band", "Band", 0.3, 0.05, 1.0,
|
||||||
|
"Height of the sharp band as a share of the picture, with as much "
|
||||||
|
"again fading into the blur; band mode only."),
|
||||||
|
ParamSpec::makeDouble("focus_speed", "Speed", 0.25, 0.0, 10.0,
|
||||||
|
"Pulls there and back per second, rounded so that the clip loops "
|
||||||
|
"without a jump; 0 holds still.",
|
||||||
|
0.05, 2),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Two passes of a box blur come close enough to a lens.
|
||||||
|
constexpr int kPasses = 2;
|
||||||
|
|
||||||
|
struct Settings {
|
||||||
|
bool band;
|
||||||
|
int radius;
|
||||||
|
double bandShare;
|
||||||
|
double speed;
|
||||||
|
|
||||||
|
explicit Settings(const ParamValues& values)
|
||||||
|
: band(paramChoice(values, "focus_mode", "breathe") == "band"),
|
||||||
|
radius(static_cast<int>(std::max(0L, paramInt(values, "focus_radius", 12)))),
|
||||||
|
bandShare(std::clamp(paramDouble(values, "focus_band", 0.3), 0.05, 1.0)),
|
||||||
|
speed(std::max(0.0, paramDouble(values, "focus_speed", 0.25))) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
Image blurred(const Image& input, int radius) {
|
||||||
|
Image out = input;
|
||||||
|
if (radius > 0) boxBlur(out, radius, kPasses, "both");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lays `over` on `under` in place, row y at `mix(y)` out of 256.
|
||||||
|
template <typename Mix>
|
||||||
|
void blendRows(Image& under, const Image& over, Mix mix) {
|
||||||
|
const std::size_t bytes = static_cast<std::size_t>(under.width()) * 3;
|
||||||
|
parallelFor(static_cast<std::size_t>(under.height()), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
|
const int weight = mix(static_cast<int>(y));
|
||||||
|
if (weight <= 0) continue;
|
||||||
|
std::uint8_t* out = under.row(static_cast<int>(y));
|
||||||
|
const std::uint8_t* in = over.row(static_cast<int>(y));
|
||||||
|
for (std::size_t i = 0; i < bytes; ++i) {
|
||||||
|
out[i] = static_cast<std::uint8_t>(out[i] + (in[i] - out[i]) * weight / 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image FocusPullEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||||
|
const Settings settings(values);
|
||||||
|
if (settings.radius <= 0) return input;
|
||||||
|
const int height = input.height();
|
||||||
|
|
||||||
|
// Where this frame is in time: how far the pull has gone, easing out from
|
||||||
|
// the start and back to it once a cycle.
|
||||||
|
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||||
|
const double pull = (1.0 - std::cos(2.0 * std::numbers::pi * t)) * 0.5;
|
||||||
|
|
||||||
|
if (!settings.band) {
|
||||||
|
// A radius between two whole ones is the two blurs mixed, so the
|
||||||
|
// focus drifts smoothly rather than in steps.
|
||||||
|
const double radius = settings.radius * pull;
|
||||||
|
const int lower = static_cast<int>(radius);
|
||||||
|
const int mix = static_cast<int>(std::lround((radius - lower) * 256.0));
|
||||||
|
Image frame = blurred(input, lower);
|
||||||
|
if (mix > 0) blendRows(frame, blurred(input, lower + 1), [mix](int) { return mix; });
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The band's middle goes from the top to the bottom and back; the blur
|
||||||
|
// grows over as much again as the band is high on either side of it.
|
||||||
|
const double middle = pull * (height - 1);
|
||||||
|
const double half = settings.bandShare * height * 0.5;
|
||||||
|
const double fade = std::max(1.0, settings.bandShare * height);
|
||||||
|
const Image soft = blurred(input, settings.radius);
|
||||||
|
blendRows(input, soft, [&](int y) {
|
||||||
|
const double away = std::abs(y - middle) - half;
|
||||||
|
return static_cast<int>(std::lround(std::clamp(away / fade, 0.0, 1.0) * 256.0));
|
||||||
|
});
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// The blur effect in motion, as a lens being focused: either the whole
|
||||||
|
// picture drifts out of focus and back, or a band of sharpness travels down
|
||||||
|
// the picture and back up with the rest blurred, as a tilted lens shows it.
|
||||||
|
//
|
||||||
|
// The pull goes there and back once a cycle, so the clip loops without a
|
||||||
|
// jump; nothing in it is random.
|
||||||
|
class FocusPullEffect : public VideoEffect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "focuspull"; }
|
||||||
|
std::string title() const override { return "Focus pull"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "Blurs the picture as Blur does, pulling it out of focus and back, or moving a "
|
||||||
|
"band of sharpness up and down it.";
|
||||||
|
}
|
||||||
|
const std::vector<ParamSpec>& params() const override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
+3
-38
@@ -4,7 +4,7 @@
|
|||||||
#include <cstdint>
|
#include <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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/image.hpp"
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "core/rng.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// A field of grain over a canvas: one value in [-127, 127] per cell of
|
||||||
|
// `size` pixels square, per channel when the grain is coloured. Shared by the
|
||||||
|
// film grain and the film gate effects.
|
||||||
|
struct GrainField {
|
||||||
|
int size = 1;
|
||||||
|
int cellsX = 0;
|
||||||
|
bool colour = false;
|
||||||
|
std::vector<std::int8_t> cells;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draws the whole field serially from the rng, so that only the pixel
|
||||||
|
// arithmetic that applies it runs in parallel.
|
||||||
|
inline GrainField drawGrain(Rng& rng, int width, int height, int size, bool colour) {
|
||||||
|
GrainField field;
|
||||||
|
field.size = size;
|
||||||
|
field.colour = colour;
|
||||||
|
field.cellsX = (width + size - 1) / size;
|
||||||
|
const int cellsY = (height + size - 1) / size;
|
||||||
|
field.cells.resize(static_cast<std::size_t>(field.cellsX) * cellsY * (colour ? 3 : 1));
|
||||||
|
for (auto& cell : field.cells) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
|
||||||
|
// roughly how grain behaves on film.
|
||||||
|
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
|
||||||
|
|
||||||
|
// Pushes every pixel by its grain, at most `amount` of full scale, and less in
|
||||||
|
// the shadows and highlights when `midtones` is set.
|
||||||
|
inline void applyGrain(Image& canvas, const GrainField& field, double amount, bool midtones) {
|
||||||
|
const int width = canvas.width();
|
||||||
|
const int channels = field.colour ? 3 : 1;
|
||||||
|
// 127 * 255 is the largest product of a grain and a midtone weight;
|
||||||
|
// scaling by `amount` there keeps the whole thing in integers.
|
||||||
|
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
|
||||||
|
|
||||||
|
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
|
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||||
|
const std::size_t rowCell =
|
||||||
|
static_cast<std::size_t>(static_cast<int>(y) / field.size) * field.cellsX;
|
||||||
|
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
const std::size_t cell =
|
||||||
|
(rowCell + static_cast<std::size_t>(x / field.size)) * channels;
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
const int value = out[static_cast<std::size_t>(x) * 3 + c];
|
||||||
|
const std::size_t plane = field.colour ? static_cast<std::size_t>(c) : 0;
|
||||||
|
const int grain = field.cells[cell + plane];
|
||||||
|
const int weight = midtones ? midtoneWeight(value) : 255;
|
||||||
|
const int delta = grain * weight / 255 * scale / 65536;
|
||||||
|
out[static_cast<std::size_t>(x) * 3 + c] =
|
||||||
|
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/rng.hpp"
|
||||||
|
#include "effects/sampling.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// One layer of value noise: random heights on a lattice `spacing` pixels
|
||||||
|
// apart, blended smoothly between the lattice points. Positions are read from
|
||||||
|
// (0, 0) to the width and height it was built for.
|
||||||
|
class NoiseLayer {
|
||||||
|
public:
|
||||||
|
NoiseLayer(Rng& rng, int width, int height, double spacing)
|
||||||
|
: spacing_(spacing),
|
||||||
|
cols_(static_cast<int>(width / spacing) + 2),
|
||||||
|
rows_(static_cast<int>(height / spacing) + 2),
|
||||||
|
lattice_(static_cast<std::size_t>(cols_) * rows_) {
|
||||||
|
for (auto& value : lattice_) value = unitDouble(rng.next()) * 2.0 - 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The height at (x, y) in [-1, 1], and its slope per pixel along x and y.
|
||||||
|
void sample(double x, double y, double& value, double& slopeX, double& slopeY) const {
|
||||||
|
const double gx = x / spacing_;
|
||||||
|
const double gy = y / spacing_;
|
||||||
|
const int ix = std::min(static_cast<int>(gx), cols_ - 2);
|
||||||
|
const int iy = std::min(static_cast<int>(gy), rows_ - 2);
|
||||||
|
const double fx = gx - ix;
|
||||||
|
const double fy = gy - iy;
|
||||||
|
const double sx = fx * fx * (3.0 - 2.0 * fx);
|
||||||
|
const double sy = fy * fy * (3.0 - 2.0 * fy);
|
||||||
|
|
||||||
|
const double a = at(ix, iy);
|
||||||
|
const double b = at(ix + 1, iy);
|
||||||
|
const double c = at(ix, iy + 1);
|
||||||
|
const double d = at(ix + 1, iy + 1);
|
||||||
|
const double cross = a - b - c + d;
|
||||||
|
value = a + (b - a) * sx + (c - a) * sy + cross * sx * sy;
|
||||||
|
slopeX = 6.0 * fx * (1.0 - fx) * ((b - a) + cross * sy) / spacing_;
|
||||||
|
slopeY = 6.0 * fy * (1.0 - fy) * ((c - a) + cross * sx) / spacing_;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
double at(int x, int y) const { return lattice_[static_cast<std::size_t>(y) * cols_ + x]; }
|
||||||
|
|
||||||
|
double spacing_;
|
||||||
|
int cols_;
|
||||||
|
int rows_;
|
||||||
|
std::vector<double> lattice_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Where one layer of a field is read from, against where it is asked for.
|
||||||
|
struct NoiseShift {
|
||||||
|
double x = 0.0;
|
||||||
|
double y = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Octaves of noise summed, each twice as fine and half as strong as the one
|
||||||
|
// before, and scaled back so the total stays in [-1, 1].
|
||||||
|
class NoiseField {
|
||||||
|
public:
|
||||||
|
NoiseField(Rng& rng, int width, int height, double scale, int octaves) {
|
||||||
|
double spacing = scale;
|
||||||
|
double weight = 1.0;
|
||||||
|
for (int i = 0; i < octaves; ++i) {
|
||||||
|
layers_.emplace_back(rng, width, height, std::max(1.0, spacing));
|
||||||
|
weights_.push_back(weight);
|
||||||
|
total_ += weight;
|
||||||
|
spacing *= 0.5;
|
||||||
|
weight *= 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// As above, with each layer read that far off from (x, y), one shift per
|
||||||
|
// octave -- which is how a field is set moving: its layers slide apart.
|
||||||
|
void sample(double x, double y, double& value, double& slopeX, double& slopeY,
|
||||||
|
const std::vector<NoiseShift>* shifts = nullptr) const {
|
||||||
|
value = slopeX = slopeY = 0.0;
|
||||||
|
for (std::size_t i = 0; i < layers_.size(); ++i) {
|
||||||
|
double v = 0.0, sx = 0.0, sy = 0.0;
|
||||||
|
if (shifts != nullptr) {
|
||||||
|
layers_[i].sample(x + (*shifts)[i].x, y + (*shifts)[i].y, v, sx, sy);
|
||||||
|
} else {
|
||||||
|
layers_[i].sample(x, y, v, sx, sy);
|
||||||
|
}
|
||||||
|
value += v * weights_[i];
|
||||||
|
slopeX += sx * weights_[i];
|
||||||
|
slopeY += sy * weights_[i];
|
||||||
|
}
|
||||||
|
value /= total_;
|
||||||
|
slopeX /= total_;
|
||||||
|
slopeY /= total_;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<NoiseLayer> layers_;
|
||||||
|
std::vector<double> weights_;
|
||||||
|
double total_ = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The push the warp effects give a pixel: one field for a swirl, which flows
|
||||||
|
// along its contours, two for a push along x and y. The fields are drawn in
|
||||||
|
// full, in that order, when this is built.
|
||||||
|
class WarpField {
|
||||||
|
public:
|
||||||
|
WarpField(Rng& rng, int width, int height, double scale, int octaves, bool swirl)
|
||||||
|
: first_(rng, width, height, scale, octaves), scale_(scale), swirl_(swirl) {
|
||||||
|
if (!swirl) second_.emplace_back(rng, width, height, scale, octaves);
|
||||||
|
}
|
||||||
|
|
||||||
|
// How far the pixel at (x, y) is pushed, at `strength` pixels at most;
|
||||||
|
// `shifts` (one list per field) slides the fields' layers as NoiseField does.
|
||||||
|
void push(double x, double y, double strength, double& dx, double& dy,
|
||||||
|
const std::vector<NoiseShift>* firstShifts = nullptr,
|
||||||
|
const std::vector<NoiseShift>* secondShifts = nullptr) const {
|
||||||
|
double value = 0.0, slopeX = 0.0, slopeY = 0.0;
|
||||||
|
first_.sample(x, y, value, slopeX, slopeY, firstShifts);
|
||||||
|
if (swirl_) {
|
||||||
|
// Along the contour: the slope turned a quarter, which never piles
|
||||||
|
// pixels up or tears them apart. The slope of a field `scale` wide
|
||||||
|
// is about 1/scale, hence the factor.
|
||||||
|
dx = slopeY * scale_ * strength * 0.5;
|
||||||
|
dy = -slopeX * scale_ * strength * 0.5;
|
||||||
|
} else {
|
||||||
|
double other = 0.0;
|
||||||
|
second_.front().sample(x, y, other, slopeX, slopeY, secondShifts);
|
||||||
|
dx = value * strength;
|
||||||
|
dy = other * strength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
NoiseField first_;
|
||||||
|
std::vector<NoiseField> second_;
|
||||||
|
double scale_;
|
||||||
|
bool swirl_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include "core/parallel.hpp"
|
#include "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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/image.hpp"
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "core/rng.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// What orders the pixels inside a run. Shared by the pixel sort and the sort
|
||||||
|
// sweep effects.
|
||||||
|
enum class SortKey { Luma, Hue, Saturation };
|
||||||
|
|
||||||
|
inline SortKey sortKeyNamed(const std::string& name) {
|
||||||
|
if (name == "hue") return SortKey::Hue;
|
||||||
|
if (name == "saturation") return SortKey::Saturation;
|
||||||
|
return SortKey::Luma;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline int sortLuma(const std::uint8_t* px) { return (px[0] * 54 + px[1] * 183 + px[2] * 19) >> 8; }
|
||||||
|
|
||||||
|
// Hue on a 0..1535 scale (six sectors of 256), in integers.
|
||||||
|
inline int sortHue(const std::uint8_t* px) {
|
||||||
|
const int r = px[0], g = px[1], b = px[2];
|
||||||
|
const int hi = std::max({r, g, b});
|
||||||
|
const int lo = std::min({r, g, b});
|
||||||
|
const int range = hi - lo;
|
||||||
|
if (range == 0) return 0;
|
||||||
|
if (hi == r) return g >= b ? (g - b) * 255 / range : 1535 - (b - g) * 255 / range;
|
||||||
|
if (hi == g) return 512 + (b - r) * 255 / range;
|
||||||
|
return 1024 + (r - g) * 255 / range;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline int sortSaturation(const std::uint8_t* px) {
|
||||||
|
return std::max({px[0], px[1], px[2]}) - std::min({px[0], px[1], px[2]});
|
||||||
|
}
|
||||||
|
|
||||||
|
inline int sortKeyOf(SortKey key, const std::uint8_t* px) {
|
||||||
|
switch (key) {
|
||||||
|
case SortKey::Hue: return sortHue(px);
|
||||||
|
case SortKey::Saturation: return sortSaturation(px);
|
||||||
|
case SortKey::Luma: break;
|
||||||
|
}
|
||||||
|
return sortLuma(px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The image with rows and columns swapped, so vertical sorting is horizontal
|
||||||
|
// sorting of the transpose.
|
||||||
|
inline Image transposed(const Image& src) {
|
||||||
|
Image out(src.height(), src.width());
|
||||||
|
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t x = begin; x < end; ++x) {
|
||||||
|
std::uint8_t* row = out.row(static_cast<int>(x));
|
||||||
|
for (int y = 0; y < src.height(); ++y) {
|
||||||
|
std::memcpy(row + static_cast<std::size_t>(y) * 3, src.row(y) + x * 3, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// How one line is sorted: the brightness a pixel must lie within (0..255) to
|
||||||
|
// join a run, what orders a run, and the longest a run may grow (0 for no
|
||||||
|
// limit), its lengths drawn from `runRng`.
|
||||||
|
struct SortRule {
|
||||||
|
int low = 0;
|
||||||
|
int high = 255;
|
||||||
|
SortKey key = SortKey::Luma;
|
||||||
|
bool reverse = false;
|
||||||
|
int maxRun = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sorts every run of one line of `span` pixels in place. `run` is scratch
|
||||||
|
// space, reused from line to line.
|
||||||
|
inline void sortLine(std::uint8_t* row, int span, const SortRule& rule, Rng& runRng,
|
||||||
|
std::vector<std::pair<int, std::array<std::uint8_t, 3>>>& run) {
|
||||||
|
auto flush = [&](int start) {
|
||||||
|
// A stable sort, so equal keys keep their order on every standard
|
||||||
|
// library.
|
||||||
|
std::stable_sort(run.begin(), run.end(), [&](const auto& a, const auto& b) {
|
||||||
|
return rule.reverse ? a.first > b.first : a.first < b.first;
|
||||||
|
});
|
||||||
|
for (std::size_t i = 0; i < run.size(); ++i) {
|
||||||
|
std::memcpy(row + (static_cast<std::size_t>(start) + i) * 3, run[i].second.data(), 3);
|
||||||
|
}
|
||||||
|
run.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
int start = 0;
|
||||||
|
int limit = rule.maxRun > 0 ? static_cast<int>(runRng.inRange(1, rule.maxRun)) : span;
|
||||||
|
for (int x = 0; x < span; ++x) {
|
||||||
|
const std::uint8_t* px = row + static_cast<std::size_t>(x) * 3;
|
||||||
|
const int brightness = sortLuma(px);
|
||||||
|
if (brightness < rule.low || brightness > rule.high) {
|
||||||
|
flush(start);
|
||||||
|
start = x + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (static_cast<int>(run.size()) >= limit) {
|
||||||
|
flush(start);
|
||||||
|
start = x;
|
||||||
|
if (rule.maxRun > 0) limit = static_cast<int>(runRng.inRange(1, rule.maxRun));
|
||||||
|
}
|
||||||
|
run.push_back({sortKeyOf(rule.key, px), {px[0], px[1], px[2]}});
|
||||||
|
}
|
||||||
|
flush(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#include "effects/striproll.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& StripRollEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeInt("roll_height", "Strip height", 24, 1, 512,
|
||||||
|
"Height in pixels of each strip."),
|
||||||
|
ParamSpec::makeInt("roll_jitter", "Height jitter", 0, 0, 256,
|
||||||
|
"Random +/- variation added to the strip height (0 = uniform)."),
|
||||||
|
ParamSpec::makeBool("roll_keep_order", "Cycle images in order", false,
|
||||||
|
"Take strips from the images in order instead of picking randomly."),
|
||||||
|
ParamSpec::makeChoice("roll_axis", "Axis", "vertical", {"vertical", "horizontal"},
|
||||||
|
"vertical rolls the picture in each strip up or down through its "
|
||||||
|
"image; horizontal slides it sideways."),
|
||||||
|
ParamSpec::makeChoice("roll_direction", "Direction", "alternate",
|
||||||
|
{"alternate", "same", "random"},
|
||||||
|
"alternate rolls every other strip the other way; same rolls them "
|
||||||
|
"all one way; random picks for each strip."),
|
||||||
|
ParamSpec::makeInt("roll_speed", "Speed", 120, 0, 8192,
|
||||||
|
"Pixels per second the picture rolls, rounded so that each strip goes "
|
||||||
|
"round its image a whole number of times in the clip; 0 holds still."),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Settings {
|
||||||
|
int stripHeight;
|
||||||
|
int jitter;
|
||||||
|
bool keepOrder;
|
||||||
|
bool horizontal;
|
||||||
|
std::string direction;
|
||||||
|
int speed;
|
||||||
|
|
||||||
|
explicit Settings(const ParamValues& values)
|
||||||
|
: stripHeight(static_cast<int>(std::max(1L, paramInt(values, "roll_height", 24)))),
|
||||||
|
jitter(static_cast<int>(std::max(0L, paramInt(values, "roll_jitter", 0)))),
|
||||||
|
keepOrder(paramBool(values, "roll_keep_order", false)),
|
||||||
|
horizontal(paramChoice(values, "roll_axis", "vertical") == "horizontal"),
|
||||||
|
direction(paramChoice(values, "roll_direction", "alternate")),
|
||||||
|
speed(static_cast<int>(std::max(0L, paramInt(values, "roll_speed", 120)))) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// One strip of the canvas: where it lies, the image it shows, where in that
|
||||||
|
// image its picture starts, and which way it rolls.
|
||||||
|
struct Strip {
|
||||||
|
int dstY;
|
||||||
|
int height;
|
||||||
|
std::size_t sourceIndex;
|
||||||
|
int sx;
|
||||||
|
int sy;
|
||||||
|
int way; // +1 or -1
|
||||||
|
};
|
||||||
|
|
||||||
|
int wrap(long value, int span) { return static_cast<int>((value % span + span) % span); }
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image StripRollEffect::compose(const std::vector<Image>& sources, int width, int height,
|
||||||
|
const ParamValues& values, const Moment& at) const {
|
||||||
|
const Settings settings(values);
|
||||||
|
|
||||||
|
// What stays put for the whole clip: the strips, drawn as Scanlines draws
|
||||||
|
// them, and the way each one rolls.
|
||||||
|
Rng steady = at.steady();
|
||||||
|
std::vector<Strip> strips;
|
||||||
|
strips.reserve(static_cast<std::size_t>(height / settings.stripHeight) + 8);
|
||||||
|
for (int y = 0; y < height;) {
|
||||||
|
Strip strip{};
|
||||||
|
strip.dstY = y;
|
||||||
|
strip.sourceIndex = settings.keepOrder ? strips.size() % sources.size()
|
||||||
|
: steady.below(sources.size() - 1);
|
||||||
|
const Image& src = sources[strip.sourceIndex];
|
||||||
|
int h = settings.stripHeight;
|
||||||
|
if (settings.jitter > 0) {
|
||||||
|
h += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
||||||
|
}
|
||||||
|
strip.height = std::max(1, std::min(h, height - y));
|
||||||
|
strip.sy = static_cast<int>(steady.inRange(0, src.height() - 1));
|
||||||
|
strip.sx = static_cast<int>(steady.inRange(0, src.width() - 1));
|
||||||
|
if (settings.direction == "random") {
|
||||||
|
strip.way = (steady.next() >> 63) != 0 ? 1 : -1;
|
||||||
|
} else if (settings.direction == "alternate") {
|
||||||
|
strip.way = strips.size() % 2 == 0 ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
strip.way = 1;
|
||||||
|
}
|
||||||
|
strips.push_back(strip);
|
||||||
|
y += strip.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where this frame is in time: how far round its image each strip has
|
||||||
|
// rolled, a whole number of rounds in the clip, the nearest to the speed.
|
||||||
|
std::vector<long> offsets(strips.size(), 0);
|
||||||
|
if (settings.speed > 0) {
|
||||||
|
for (std::size_t i = 0; i < strips.size(); ++i) {
|
||||||
|
const Image& src = sources[strips[i].sourceIndex];
|
||||||
|
const int span = settings.horizontal ? src.width() : src.height();
|
||||||
|
const double rounds = std::max(1.0, std::round(settings.speed * at.length() / span));
|
||||||
|
const double into = std::fmod(at.seconds() * rounds / at.length(), 1.0);
|
||||||
|
offsets[i] = strips[i].way * std::lround(into * span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Image canvas(width, height);
|
||||||
|
parallelFor(strips.size(), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t i = begin; i < end; ++i) {
|
||||||
|
const Strip& strip = strips[i];
|
||||||
|
const Image& src = sources[strip.sourceIndex];
|
||||||
|
const long down = settings.horizontal ? 0 : offsets[i];
|
||||||
|
const long across = settings.horizontal ? offsets[i] : 0;
|
||||||
|
const int startX = wrap(strip.sx + across, src.width());
|
||||||
|
for (int line = 0; line < strip.height; ++line) {
|
||||||
|
const std::uint8_t* in = src.row(wrap(strip.sy + line + down, src.height()));
|
||||||
|
std::uint8_t* out = canvas.row(strip.dstY + line);
|
||||||
|
// Round the image's width as often as the canvas needs.
|
||||||
|
int x = 0;
|
||||||
|
int from = startX;
|
||||||
|
while (x < width) {
|
||||||
|
const int run = std::min(width - x, src.width() - from);
|
||||||
|
std::memcpy(out + static_cast<std::size_t>(x) * 3,
|
||||||
|
in + static_cast<std::size_t>(from) * 3,
|
||||||
|
static_cast<std::size_t>(run) * 3);
|
||||||
|
x += run;
|
||||||
|
from = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// The scanlines effect in motion: the canvas is cut into strips as Scanlines
|
||||||
|
// cuts it, each showing a slice of one of the source images, and the picture
|
||||||
|
// in every strip rolls through its image -- up or down, or sideways -- while
|
||||||
|
// the strips themselves stay put.
|
||||||
|
//
|
||||||
|
// Built from the sources, as TileSwapEffect is: it answers needsSources() and
|
||||||
|
// writes compose(). A strip goes round its image a whole number of times in
|
||||||
|
// the clip, so the clip loops without a jump.
|
||||||
|
class StripRollEffect : public VideoEffect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "striproll"; }
|
||||||
|
std::string title() const override { return "Strip roll"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "Cuts the canvas into strips of the source images, as Scanlines does, and rolls "
|
||||||
|
"the picture in each strip through its image.";
|
||||||
|
}
|
||||||
|
const std::vector<ParamSpec>& params() const override;
|
||||||
|
bool needsSources() const override { return true; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
Image compose(const std::vector<Image>& sources, int width, int height,
|
||||||
|
const ParamValues& values, const Moment& at) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#include "effects/sweep.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <numbers>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "effects/sampling.hpp"
|
||||||
|
#include "effects/sort_runs.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& SweepEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeDouble("sweep_from", "From", 0.1, 0.0, 1.0,
|
||||||
|
"Brightness the middle of the window starts the sweep at."),
|
||||||
|
ParamSpec::makeDouble("sweep_to", "To", 0.9, 0.0, 1.0,
|
||||||
|
"Brightness the middle of the window turns back at."),
|
||||||
|
ParamSpec::makeDouble("sweep_width", "Window", 0.35, 0.0, 1.0,
|
||||||
|
"How wide a band of brightness is sorted at once; pixels outside "
|
||||||
|
"it break a run and stay where they are."),
|
||||||
|
ParamSpec::makeDouble("sweep_speed", "Speed", 0.25, 0.0, 10.0,
|
||||||
|
"Sweeps there and back per second, rounded so that the clip loops "
|
||||||
|
"without a jump; 0 holds still.",
|
||||||
|
0.05, 2),
|
||||||
|
ParamSpec::makeChoice("sweep_key", "Sort by", "luma", {"luma", "hue", "saturation"},
|
||||||
|
"What orders the pixels inside a run."),
|
||||||
|
ParamSpec::makeChoice("sweep_direction", "Direction", "horizontal",
|
||||||
|
{"horizontal", "vertical"}, "Whether rows or columns are sorted."),
|
||||||
|
ParamSpec::makeBool("sweep_reverse", "Reverse", false,
|
||||||
|
"Sort from high to low instead of from low to high."),
|
||||||
|
ParamSpec::makeDouble("sweep_line_chance", "Line chance", 1.0, 0.0, 1.0,
|
||||||
|
"Chance of each row (or column) being sorted at all."),
|
||||||
|
ParamSpec::makeInt("sweep_max_run", "Longest run", 0, 0, 8192,
|
||||||
|
"Each run is cut after a random length up to this many pixels, which "
|
||||||
|
"frays the edges of the sorted streaks (0 = no limit)."),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Settings {
|
||||||
|
double from;
|
||||||
|
double to;
|
||||||
|
double width;
|
||||||
|
double speed;
|
||||||
|
SortKey key;
|
||||||
|
bool vertical;
|
||||||
|
bool reverse;
|
||||||
|
double lineChance;
|
||||||
|
int maxRun;
|
||||||
|
|
||||||
|
explicit Settings(const ParamValues& values)
|
||||||
|
: from(std::clamp(paramDouble(values, "sweep_from", 0.1), 0.0, 1.0)),
|
||||||
|
to(std::clamp(paramDouble(values, "sweep_to", 0.9), 0.0, 1.0)),
|
||||||
|
width(std::clamp(paramDouble(values, "sweep_width", 0.35), 0.0, 1.0)),
|
||||||
|
speed(std::max(0.0, paramDouble(values, "sweep_speed", 0.25))),
|
||||||
|
key(sortKeyNamed(paramChoice(values, "sweep_key", "luma"))),
|
||||||
|
vertical(paramChoice(values, "sweep_direction", "horizontal") == "vertical"),
|
||||||
|
reverse(paramBool(values, "sweep_reverse", false)),
|
||||||
|
lineChance(std::clamp(paramDouble(values, "sweep_line_chance", 1.0), 0.0, 1.0)),
|
||||||
|
maxRun(static_cast<int>(std::max(0L, paramInt(values, "sweep_max_run", 0)))) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LinePlan {
|
||||||
|
bool sorted = false;
|
||||||
|
std::uint64_t seed = 0; // for the run lengths, the same on every frame
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image SweepEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||||
|
const Settings settings(values);
|
||||||
|
Image canvas = settings.vertical ? transposed(input) : std::move(input);
|
||||||
|
const int lines = canvas.height();
|
||||||
|
const int span = canvas.width();
|
||||||
|
|
||||||
|
// What stays put for the whole clip: which lines are sorted, and where
|
||||||
|
// their runs are cut.
|
||||||
|
Rng steady = at.steady();
|
||||||
|
std::vector<LinePlan> plan(static_cast<std::size_t>(lines));
|
||||||
|
for (auto& line : plan) {
|
||||||
|
line.sorted = chance(steady.next(), settings.lineChance);
|
||||||
|
line.seed = steady.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where this frame is in time: the middle of the window eases from
|
||||||
|
// `from` to `to` and back once a cycle.
|
||||||
|
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||||
|
const double ease = (1.0 - std::cos(2.0 * std::numbers::pi * t)) * 0.5;
|
||||||
|
const double middle = settings.from + (settings.to - settings.from) * ease;
|
||||||
|
SortRule rule;
|
||||||
|
rule.low = static_cast<int>(std::lround((middle - settings.width * 0.5) * 255.0));
|
||||||
|
rule.high = static_cast<int>(std::lround((middle + settings.width * 0.5) * 255.0));
|
||||||
|
rule.key = settings.key;
|
||||||
|
rule.reverse = settings.reverse;
|
||||||
|
rule.maxRun = settings.maxRun;
|
||||||
|
|
||||||
|
parallelFor(static_cast<std::size_t>(lines), [&](std::size_t begin, std::size_t end) {
|
||||||
|
std::vector<std::pair<int, std::array<std::uint8_t, 3>>> run;
|
||||||
|
for (std::size_t line = begin; line < end; ++line) {
|
||||||
|
if (!plan[line].sorted) continue;
|
||||||
|
Rng runRng(plan[line].seed);
|
||||||
|
sortLine(canvas.row(static_cast<int>(line)), span, rule, runRng, run);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return settings.vertical ? transposed(canvas) : canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// The pixel sort in motion: only pixels inside a window of brightness are
|
||||||
|
// sorted, and the window sweeps from the dark end to the bright end and back,
|
||||||
|
// so the sorted streaks spread over one part of the picture after another,
|
||||||
|
// melt away and return.
|
||||||
|
//
|
||||||
|
// Which lines are sorted, and how their runs are cut, is drawn from
|
||||||
|
// at.steady() and stays put; the window alone moves, there and back once a
|
||||||
|
// cycle, so the clip loops without a jump.
|
||||||
|
class SweepEffect : public VideoEffect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "sweep"; }
|
||||||
|
std::string title() const override { return "Sort sweep"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "Sorts runs of pixels as Pixel sort does, only within a window of brightness that "
|
||||||
|
"sweeps from dark to bright and back.";
|
||||||
|
}
|
||||||
|
const std::vector<ParamSpec>& params() const override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -16,7 +16,7 @@ const std::vector<ParamSpec>& TextEffect::params() const {
|
|||||||
static const std::vector<ParamSpec> specs = {
|
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(),
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/image.hpp"
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "core/rng.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// One cut-out: `w` by `h` pixels taken from source `sourceIndex` at (sx, sy)
|
||||||
|
// and laid down at (dstX, dstY). Shared by the tiles and the tile swap effects.
|
||||||
|
struct Tile {
|
||||||
|
int dstX;
|
||||||
|
int dstY;
|
||||||
|
int w;
|
||||||
|
int h;
|
||||||
|
int sourceIndex;
|
||||||
|
int sx;
|
||||||
|
int sy;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Splits `span` into consecutive pieces of `base` pixels give or take `jitter`,
|
||||||
|
// the last one clipped so that the pieces cover the span exactly.
|
||||||
|
inline std::vector<int> planSpans(Rng& rng, int base, int jitter, int span) {
|
||||||
|
std::vector<int> spans;
|
||||||
|
spans.reserve(static_cast<std::size_t>(span / base) + 8);
|
||||||
|
int used = 0;
|
||||||
|
while (used < span) {
|
||||||
|
int size = base;
|
||||||
|
if (jitter > 0) size += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||||
|
size = std::max(1, std::min(size, span - used));
|
||||||
|
spans.push_back(size);
|
||||||
|
used += size;
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void paintTile(Image& canvas, const Image& src, const Tile& tile) {
|
||||||
|
const int copyWidth = std::min(tile.w, src.width() - tile.sx);
|
||||||
|
const int copyHeight = std::min(tile.h, src.height() - tile.sy);
|
||||||
|
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
|
||||||
|
const std::size_t total = static_cast<std::size_t>(tile.w) * 3;
|
||||||
|
const std::size_t offset = static_cast<std::size_t>(tile.dstX) * 3;
|
||||||
|
|
||||||
|
for (int line = 0; line < copyHeight; ++line) {
|
||||||
|
const std::uint8_t* in = src.row(tile.sy + line) + static_cast<std::size_t>(tile.sx) * 3;
|
||||||
|
std::uint8_t* out = canvas.row(tile.dstY + line) + offset;
|
||||||
|
|
||||||
|
std::memcpy(out, in, copyBytes);
|
||||||
|
// Repeat the cut sideways when the source is narrower than the tile,
|
||||||
|
// doubling the filled span on each pass.
|
||||||
|
std::size_t filled = copyBytes;
|
||||||
|
while (filled < total) {
|
||||||
|
const std::size_t chunk = std::min(filled, total - filled);
|
||||||
|
std::memcpy(out + filled, out, chunk);
|
||||||
|
filled += chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Same again downwards, out of the lines already written.
|
||||||
|
for (int line = copyHeight; line < tile.h; ++line) {
|
||||||
|
std::memcpy(canvas.row(tile.dstY + line) + offset,
|
||||||
|
canvas.row(tile.dstY + (line % copyHeight)) + offset, total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paints the whole plan. Overlapping tiles (the scattered layout) are painted
|
||||||
|
// in plan order on one thread: letting threads race for the same pixels would
|
||||||
|
// make the output depend on their scheduling.
|
||||||
|
inline void paintTiles(Image& canvas, const std::vector<Image>& sources,
|
||||||
|
const std::vector<Tile>& plan, bool overlapping) {
|
||||||
|
if (overlapping) {
|
||||||
|
for (const Tile& tile : plan) {
|
||||||
|
paintTile(canvas, sources[static_cast<std::size_t>(tile.sourceIndex)], tile);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t i = begin; i < end; ++i) {
|
||||||
|
paintTile(canvas, sources[static_cast<std::size_t>(plan[i].sourceIndex)], plan[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
+2
-76
@@ -1,11 +1,10 @@
|
|||||||
#include "effects/tiles.hpp"
|
#include "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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
#include "effects/tileswap.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "effects/source_fill.hpp"
|
||||||
|
#include "effects/tile_plan.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& TileSwapEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeChoice("tileswap_layout", "Layout", "grid", {"grid", "brick", "scatter"},
|
||||||
|
"grid lines the tiles up in rows and columns; brick packs every row "
|
||||||
|
"on its own so the seams do not meet; scatter drops loose tiles over "
|
||||||
|
"a filled background, overlapping."),
|
||||||
|
ParamSpec::makeInt("tileswap_width", "Tile width", 96, 1, 2048,
|
||||||
|
"Width in pixels of each cut-out tile."),
|
||||||
|
ParamSpec::makeInt("tileswap_height", "Tile height", 96, 1, 2048,
|
||||||
|
"Height in pixels of each cut-out tile."),
|
||||||
|
ParamSpec::makeInt("tileswap_jitter", "Size jitter", 0, 0, 512,
|
||||||
|
"Random +/- variation added to both tile sizes (0 = uniform)."),
|
||||||
|
ParamSpec::makeInt("tileswap_count", "Tile count", 64, 1, 4096,
|
||||||
|
"How many tiles are dropped on the canvas; scattered layout only."),
|
||||||
|
ParamSpec::makeBool("tileswap_keep_order", "Cycle images in order", false,
|
||||||
|
"Take tiles from the images in order instead of picking randomly."),
|
||||||
|
ParamSpec::makeDouble("tileswap_rate", "Swap rate", 1.0, 0.0, 5.0,
|
||||||
|
"Swaps per second of each tile, on average; 0 for none. Below two "
|
||||||
|
"swaps in the clip only some of the tiles swap, fewer the lower it "
|
||||||
|
"goes, so that the clip still loops without a jump.",
|
||||||
|
0.01, 2)
|
||||||
|
.slider(3.0),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Settings {
|
||||||
|
std::string layout;
|
||||||
|
int tileWidth;
|
||||||
|
int tileHeight;
|
||||||
|
int jitter;
|
||||||
|
long count;
|
||||||
|
bool keepOrder;
|
||||||
|
double rate;
|
||||||
|
|
||||||
|
explicit Settings(const ParamValues& values)
|
||||||
|
: layout(paramChoice(values, "tileswap_layout", "grid")),
|
||||||
|
tileWidth(static_cast<int>(std::max(1L, paramInt(values, "tileswap_width", 96)))),
|
||||||
|
tileHeight(static_cast<int>(std::max(1L, paramInt(values, "tileswap_height", 96)))),
|
||||||
|
jitter(static_cast<int>(std::max(0L, paramInt(values, "tileswap_jitter", 0)))),
|
||||||
|
count(std::max(1L, paramInt(values, "tileswap_count", 64))),
|
||||||
|
keepOrder(paramBool(values, "tileswap_keep_order", false)),
|
||||||
|
rate(std::max(0.0, paramDouble(values, "tileswap_rate", 1.0))) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
|
||||||
|
|
||||||
|
// Where a tile sits for the whole clip, when in its swaps it turns over, the
|
||||||
|
// seed its cuts come from, and the draw that settles how often it swaps.
|
||||||
|
struct Cell {
|
||||||
|
int x;
|
||||||
|
int y;
|
||||||
|
int w;
|
||||||
|
int h;
|
||||||
|
double phase;
|
||||||
|
std::uint64_t seed;
|
||||||
|
double share;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image TileSwapEffect::compose(const std::vector<Image>& sources, int width, int height,
|
||||||
|
const ParamValues& values, const Moment& at) const {
|
||||||
|
const Settings settings(values);
|
||||||
|
const bool scatter = settings.layout == "scatter";
|
||||||
|
Image canvas(width, height);
|
||||||
|
|
||||||
|
// What stays put for the whole clip: the cells, and for loose tiles the
|
||||||
|
// backdrop they lie on.
|
||||||
|
Rng steady = at.steady();
|
||||||
|
std::vector<Cell> cells;
|
||||||
|
auto addCell = [&](int x, int y, int w, int h) {
|
||||||
|
cells.push_back(Cell{x, y, w, h, 0.0, 0, 0.0});
|
||||||
|
};
|
||||||
|
if (scatter) {
|
||||||
|
const Image& src = sources[steady.below(sources.size() - 1)];
|
||||||
|
const int sx = static_cast<int>(steady.inRange(0, std::max(0, src.width() - width)));
|
||||||
|
const int sy = static_cast<int>(steady.inRange(0, std::max(0, src.height() - height)));
|
||||||
|
fillFromSource(canvas, src, sx, sy);
|
||||||
|
|
||||||
|
cells.reserve(static_cast<std::size_t>(settings.count));
|
||||||
|
for (long i = 0; i < settings.count; ++i) {
|
||||||
|
int w = settings.tileWidth;
|
||||||
|
int h = settings.tileHeight;
|
||||||
|
if (settings.jitter > 0) {
|
||||||
|
w += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
||||||
|
h += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
||||||
|
}
|
||||||
|
const int x = static_cast<int>(steady.inRange(0, width - 1));
|
||||||
|
const int y = static_cast<int>(steady.inRange(0, height - 1));
|
||||||
|
addCell(x, y, std::max(1, std::min(w, width - x)), std::max(1, std::min(h, height - y)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const bool brick = settings.layout == "brick";
|
||||||
|
const std::vector<int> rows = planSpans(steady, settings.tileHeight, settings.jitter, height);
|
||||||
|
std::vector<int> columns;
|
||||||
|
if (!brick) columns = planSpans(steady, settings.tileWidth, settings.jitter, width);
|
||||||
|
int y = 0;
|
||||||
|
for (const int rowHeight : rows) {
|
||||||
|
if (brick) columns = planSpans(steady, settings.tileWidth, settings.jitter, width);
|
||||||
|
int x = 0;
|
||||||
|
for (const int cellWidth : columns) {
|
||||||
|
addCell(x, y, cellWidth, rowHeight);
|
||||||
|
x += cellWidth;
|
||||||
|
}
|
||||||
|
y += rowHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Cell& cell : cells) {
|
||||||
|
cell.phase = unit(steady);
|
||||||
|
cell.seed = steady.next();
|
||||||
|
cell.share = unit(steady);
|
||||||
|
}
|
||||||
|
|
||||||
|
// How often each tile swaps in one pass of the clip, `each` on average. A
|
||||||
|
// tile has to come back to the cut it started with, so one that swaps at
|
||||||
|
// all swaps at least twice; below that a share of the tiles swap twice and
|
||||||
|
// the rest hold still, so a low rate thins out the tiles that move instead
|
||||||
|
// of moving every one of them. Above it, each tile rounds `each` up or
|
||||||
|
// down by its own draw.
|
||||||
|
const double length = at.length();
|
||||||
|
const double each = settings.rate * length;
|
||||||
|
auto swapsOf = [&](const Cell& cell) -> long {
|
||||||
|
if (each <= 0.0) return 0;
|
||||||
|
if (each < 2.0) return cell.share < each / 2.0 ? 2 : 0;
|
||||||
|
const double whole = std::floor(each);
|
||||||
|
return static_cast<long>(whole) + (cell.share < each - whole ? 1 : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// What a tile shows depends only on the tile and how many times it has
|
||||||
|
// swapped, counted round the clip so that the frame after the last shows
|
||||||
|
// what the first does; so it holds still between swaps and any frame can
|
||||||
|
// be drawn on its own.
|
||||||
|
std::vector<Tile> plan;
|
||||||
|
plan.reserve(cells.size());
|
||||||
|
for (std::size_t i = 0; i < cells.size(); ++i) {
|
||||||
|
const Cell& cell = cells[i];
|
||||||
|
const long swaps = swapsOf(cell);
|
||||||
|
std::uint64_t turn = 0;
|
||||||
|
if (swaps > 0) {
|
||||||
|
const auto passed =
|
||||||
|
static_cast<long>(std::floor(at.seconds() * swaps / length + cell.phase));
|
||||||
|
turn = static_cast<std::uint64_t>(passed % swaps);
|
||||||
|
}
|
||||||
|
Rng cut(cell.seed + turn * 0x9e3779b97f4a7c15ULL);
|
||||||
|
const std::size_t sourceIndex =
|
||||||
|
settings.keepOrder ? (i + turn) % sources.size() : cut.below(sources.size() - 1);
|
||||||
|
const Image& src = sources[sourceIndex];
|
||||||
|
const int sx = static_cast<int>(cut.inRange(0, std::max(0, src.width() - cell.w)));
|
||||||
|
const int sy = static_cast<int>(cut.inRange(0, std::max(0, src.height() - cell.h)));
|
||||||
|
plan.push_back(Tile{cell.x, cell.y, cell.w, cell.h, static_cast<int>(sourceIndex), sx, sy});
|
||||||
|
}
|
||||||
|
|
||||||
|
paintTiles(canvas, sources, plan, scatter);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// The tiles effect in motion: the canvas is laid with tiles cut from the
|
||||||
|
// source images, and each tile is swapped for another cut, at a moment of its
|
||||||
|
// own, so the mosaic keeps changing piece by piece.
|
||||||
|
//
|
||||||
|
// The video effect built from the sources rather than transforming its input:
|
||||||
|
// it answers needsSources() and writes compose(). The layout is drawn from
|
||||||
|
// at.steady() and stays put; what a tile shows depends only on the tile and
|
||||||
|
// how many times it has swapped, so it holds still between its swaps.
|
||||||
|
class TileSwapEffect : public VideoEffect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "tileswap"; }
|
||||||
|
std::string title() const override { return "Tile swap"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "Lays tiles cut from the source images over the canvas, as Tiles does, and swaps "
|
||||||
|
"each one for another cut at a moment of its own.";
|
||||||
|
}
|
||||||
|
const std::vector<ParamSpec>& params() const override;
|
||||||
|
bool needsSources() const override { return true; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
Image compose(const std::vector<Image>& sources, int width, int height,
|
||||||
|
const ParamValues& values, const Moment& at) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#include "effects/tracking.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <numbers>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& TrackingEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeInt("tracking_wobble", "Wobble", 4, 0, 256,
|
||||||
|
"How far in pixels the colour planes sway apart and back."),
|
||||||
|
ParamSpec::makeDouble("tracking_wobble_speed", "Wobble speed", 0.5, 0.0, 10.0,
|
||||||
|
"Sways per second, rounded so that the clip loops without a jump.",
|
||||||
|
0.1, 1),
|
||||||
|
ParamSpec::makeInt("tracking_jitter", "Jitter", 2, 0, 256,
|
||||||
|
"Furthest in pixels a band shakes, anew on every frame."),
|
||||||
|
ParamSpec::makeInt("tracking_band_height", "Band height", 24, 1, 2048,
|
||||||
|
"Thickness in pixels of the bands that shake on their own."),
|
||||||
|
ParamSpec::makeInt("tracking_bar", "Tracking bar", 16, 0, 512,
|
||||||
|
"How far in pixels the rolling bar tears the rows sideways and the "
|
||||||
|
"colour planes apart; 0 for none."),
|
||||||
|
ParamSpec::makeDouble("tracking_bar_time", "Bar pass", 4.0, 0.5, 60.0,
|
||||||
|
"Seconds the tracking bar takes from top to bottom, rounded so "
|
||||||
|
"that the clip loops without a jump.",
|
||||||
|
0.5, 1),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Settings {
|
||||||
|
int wobble;
|
||||||
|
double wobbleSpeed;
|
||||||
|
int jitter;
|
||||||
|
int band;
|
||||||
|
int bar;
|
||||||
|
double barTime;
|
||||||
|
|
||||||
|
explicit Settings(const ParamValues& values)
|
||||||
|
: wobble(static_cast<int>(std::max(0L, paramInt(values, "tracking_wobble", 4)))),
|
||||||
|
wobbleSpeed(std::max(0.0, paramDouble(values, "tracking_wobble_speed", 0.5))),
|
||||||
|
jitter(static_cast<int>(std::max(0L, paramInt(values, "tracking_jitter", 2)))),
|
||||||
|
band(static_cast<int>(std::max(1L, paramInt(values, "tracking_band_height", 24)))),
|
||||||
|
bar(static_cast<int>(std::max(0L, paramInt(values, "tracking_bar", 16)))),
|
||||||
|
barTime(std::max(0.5, paramDouble(values, "tracking_bar_time", 4.0))) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
|
||||||
|
|
||||||
|
// How far each of the red, green and blue planes is moved on one row.
|
||||||
|
struct RowShift {
|
||||||
|
std::array<int, 3> dx{};
|
||||||
|
std::array<int, 3> dy{};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image TrackingEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||||
|
const Settings settings(values);
|
||||||
|
const int width = input.width();
|
||||||
|
const int height = input.height();
|
||||||
|
constexpr double tau = 2.0 * std::numbers::pi;
|
||||||
|
|
||||||
|
// What stays put for the whole clip: where each plane's sway starts, and
|
||||||
|
// the direction it sways in.
|
||||||
|
Rng steady = at.steady();
|
||||||
|
std::array<double, 3> phase{};
|
||||||
|
std::array<double, 3> angle{};
|
||||||
|
for (std::size_t c = 0; c < 3; ++c) {
|
||||||
|
phase[c] = unit(steady) * tau;
|
||||||
|
angle[c] = unit(steady) * tau;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where this frame is in time: the sway of each plane, and the bar -- an
|
||||||
|
// eighth of the picture, soft at both edges, entering above the top and
|
||||||
|
// leaving below the bottom.
|
||||||
|
std::array<double, 3> swayX{};
|
||||||
|
std::array<double, 3> swayY{};
|
||||||
|
if (settings.wobble > 0 && settings.wobbleSpeed > 0.0) {
|
||||||
|
const double turn = at.cycle(settings.wobbleSpeed) * tau;
|
||||||
|
for (std::size_t c = 0; c < 3; ++c) {
|
||||||
|
const double sway = settings.wobble * std::sin(turn + phase[c]);
|
||||||
|
swayX[c] = sway * std::cos(angle[c]);
|
||||||
|
swayY[c] = sway * std::sin(angle[c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const double barHeight = std::max(8.0, height / 8.0);
|
||||||
|
const double barTop = -barHeight + at.cycle(1.0 / settings.barTime) * (height + barHeight);
|
||||||
|
|
||||||
|
// What changes on every frame: each band's shake, mostly sideways, as a
|
||||||
|
// picture losing its horizontal hold. All of it drawn, band by band,
|
||||||
|
// before a pixel is touched.
|
||||||
|
Rng fresh = at.fresh();
|
||||||
|
std::vector<RowShift> rows(static_cast<std::size_t>(height));
|
||||||
|
std::vector<int> tear(static_cast<std::size_t>(height), 0);
|
||||||
|
for (int top = 0; top < height; top += settings.band) {
|
||||||
|
std::array<double, 3> jitterX{};
|
||||||
|
std::array<double, 3> jitterY{};
|
||||||
|
if (settings.jitter > 0) {
|
||||||
|
for (std::size_t c = 0; c < 3; ++c) {
|
||||||
|
jitterX[c] = static_cast<double>(fresh.inRange(-settings.jitter, settings.jitter));
|
||||||
|
jitterY[c] = static_cast<double>(fresh.inRange(-settings.jitter, settings.jitter)) / 3.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const int end = std::min(height, top + settings.band);
|
||||||
|
for (int y = top; y < end; ++y) {
|
||||||
|
// The bar pulls red one way and blue the other, and drags the
|
||||||
|
// whole row half as far.
|
||||||
|
double pull = 0.0;
|
||||||
|
if (settings.bar > 0 && y >= barTop && y < barTop + barHeight) {
|
||||||
|
pull = settings.bar * std::sin(std::numbers::pi * (y - barTop) / barHeight);
|
||||||
|
}
|
||||||
|
constexpr std::array<double, 3> apart = {1.0, 0.0, -1.0};
|
||||||
|
auto& row = rows[static_cast<std::size_t>(y)];
|
||||||
|
for (std::size_t c = 0; c < 3; ++c) {
|
||||||
|
row.dx[c] = static_cast<int>(std::lround(swayX[c] + jitterX[c] + pull * apart[c]));
|
||||||
|
row.dy[c] = static_cast<int>(std::lround(swayY[c] + jitterY[c]));
|
||||||
|
}
|
||||||
|
tear[static_cast<std::size_t>(y)] = static_cast<int>(std::lround(pull / 2.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Image frame(width, height);
|
||||||
|
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
|
std::uint8_t* out = frame.row(static_cast<int>(y));
|
||||||
|
for (std::size_t c = 0; c < 3; ++c) {
|
||||||
|
const int dx = rows[y].dx[c] + tear[y];
|
||||||
|
const int sy = std::clamp(static_cast<int>(y) - rows[y].dy[c], 0, height - 1);
|
||||||
|
const std::uint8_t* in = input.row(sy);
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
const int sx = std::clamp(x - dx, 0, width - 1);
|
||||||
|
out[static_cast<std::size_t>(x) * 3 + c] = in[static_cast<std::size_t>(sx) * 3 + c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// A worn tape losing its hold: the red, green and blue planes sway apart and
|
||||||
|
// back, bands of the picture shake sideways from frame to frame, and a
|
||||||
|
// tracking bar rolls down the picture tearing the rows it passes.
|
||||||
|
//
|
||||||
|
// The first video effect, and the one to copy for the next: its params are
|
||||||
|
// declared like any effect's, and animate() reads them, takes what stays put
|
||||||
|
// from at.steady(), what changes every frame from at.fresh(), and time from
|
||||||
|
// at.cycle() -- then draws the frame in one pass over the pixels.
|
||||||
|
class TrackingEffect : public VideoEffect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "tracking"; }
|
||||||
|
std::string title() const override { return "VHS tracking"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "A worn tape losing its hold: the colour planes sway and shake, and a tracking bar "
|
||||||
|
"rolls down the picture.";
|
||||||
|
}
|
||||||
|
const std::vector<ParamSpec>& params() const override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#include "effects/tunnel.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "effects/sampling.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& TunnelEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeInt("tunnel_count", "Echoes", 8, 2, 32,
|
||||||
|
"How many echoes are nested inside the picture at once."),
|
||||||
|
ParamSpec::makeDouble("tunnel_scale", "Scale", 0.8, 0.3, 0.95,
|
||||||
|
"Size of each echo against the one around it.", 0.01),
|
||||||
|
ParamSpec::makeDouble("tunnel_rotate", "Rotation", 6.0, -180.0, 180.0,
|
||||||
|
"Turn in degrees of each echo against the one around it.", 0.5, 1),
|
||||||
|
ParamSpec::makeInt("tunnel_drift", "Drift", 0, 0, 4096,
|
||||||
|
"How far in pixels each echo sits off the centre of the one around "
|
||||||
|
"it, all in one direction of the seed's own."),
|
||||||
|
ParamSpec::makeDouble("tunnel_fade", "Opacity", 0.8, 0.0, 1.0,
|
||||||
|
"How strongly each echo covers what lies beneath it."),
|
||||||
|
ParamSpec::makeDouble("tunnel_speed", "Speed", 0.5, 0.0, 10.0,
|
||||||
|
"Echoes passed per second, rounded so that the clip loops without "
|
||||||
|
"a jump; 0 holds still.",
|
||||||
|
0.1, 1),
|
||||||
|
ParamSpec::makeChoice("tunnel_direction", "Direction", "in", {"in", "out"},
|
||||||
|
"in flies into the tunnel, the echoes growing towards you; out "
|
||||||
|
"backs away from it, the echoes shrinking into the distance."),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Settings {
|
||||||
|
int count;
|
||||||
|
double scale;
|
||||||
|
double rotate;
|
||||||
|
int drift;
|
||||||
|
double fade;
|
||||||
|
double speed;
|
||||||
|
bool out;
|
||||||
|
|
||||||
|
explicit Settings(const ParamValues& values)
|
||||||
|
: count(static_cast<int>(std::clamp(paramInt(values, "tunnel_count", 8), 2L, 32L))),
|
||||||
|
scale(std::clamp(paramDouble(values, "tunnel_scale", 0.8), 0.3, 0.95)),
|
||||||
|
rotate(std::clamp(paramDouble(values, "tunnel_rotate", 6.0), -180.0, 180.0)),
|
||||||
|
drift(static_cast<int>(std::max(0L, paramInt(values, "tunnel_drift", 0)))),
|
||||||
|
fade(std::clamp(paramDouble(values, "tunnel_fade", 0.8), 0.0, 1.0)),
|
||||||
|
speed(std::max(0.0, paramDouble(values, "tunnel_speed", 0.5))),
|
||||||
|
out(paramChoice(values, "tunnel_direction", "in") == "out") {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// One echo as it lies in this frame: how a pixel of the canvas finds the
|
||||||
|
// pixel of the input it shows, and how strongly it covers what is beneath.
|
||||||
|
struct Echo {
|
||||||
|
double sine;
|
||||||
|
double cosine;
|
||||||
|
double shrink; // 1 / size
|
||||||
|
double dx;
|
||||||
|
double dy;
|
||||||
|
int mix; // out of 256
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image TunnelEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||||
|
const Settings settings(values);
|
||||||
|
const int width = input.width();
|
||||||
|
const int height = input.height();
|
||||||
|
|
||||||
|
// What stays put for the whole clip: the direction the echoes drift in.
|
||||||
|
Rng steady = at.steady();
|
||||||
|
double driftSine = 0.0;
|
||||||
|
double driftCosine = 1.0;
|
||||||
|
sinCosDegrees(unitDouble(steady.next()) * 360.0, driftSine, driftCosine);
|
||||||
|
|
||||||
|
// Where this frame is in time: how far each echo has moved on from its
|
||||||
|
// place at the start of the step. Echo k sits at depth k + 1 - t flying
|
||||||
|
// in, k + t backing out; at depth 0 it is the picture itself, so it can
|
||||||
|
// melt into it, and a new one fades in at the far end as the old leaves.
|
||||||
|
const double t = settings.speed > 0.0 ? at.cycle(settings.speed) : 0.0;
|
||||||
|
std::vector<Echo> echoes;
|
||||||
|
echoes.reserve(static_cast<std::size_t>(settings.count));
|
||||||
|
for (int k = 0; k < settings.count; ++k) {
|
||||||
|
const double depth = settings.out ? k + t : k + 1.0 - t;
|
||||||
|
const double weight = settings.fade * std::clamp(depth, 0.0, 1.0) *
|
||||||
|
std::clamp(settings.count - depth, 0.0, 1.0);
|
||||||
|
const int mix = static_cast<int>(weight * 256.0 + 0.5);
|
||||||
|
if (mix <= 0) continue;
|
||||||
|
Echo echo{};
|
||||||
|
sinCosDegrees(settings.rotate * depth, echo.sine, echo.cosine);
|
||||||
|
echo.shrink = 1.0 / std::pow(settings.scale, depth);
|
||||||
|
echo.dx = settings.drift * depth * driftCosine;
|
||||||
|
echo.dy = settings.drift * depth * driftSine;
|
||||||
|
echo.mix = mix;
|
||||||
|
echoes.push_back(echo);
|
||||||
|
}
|
||||||
|
if (echoes.empty()) return input;
|
||||||
|
|
||||||
|
// The echoes are laid largest first, so the deeper ones nest inside.
|
||||||
|
const double cx = (width - 1) * 0.5;
|
||||||
|
const double cy = (height - 1) * 0.5;
|
||||||
|
Image frame(width, height);
|
||||||
|
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t row = begin; row < end; ++row) {
|
||||||
|
const std::uint8_t* base = input.row(static_cast<int>(row));
|
||||||
|
std::uint8_t* out = frame.row(static_cast<int>(row));
|
||||||
|
for (int col = 0; col < width; ++col) {
|
||||||
|
const std::size_t px = static_cast<std::size_t>(col) * 3;
|
||||||
|
int colour[3] = {base[px], base[px + 1], base[px + 2]};
|
||||||
|
for (const Echo& echo : echoes) {
|
||||||
|
const double rx = col - cx - echo.dx;
|
||||||
|
const double ry = static_cast<double>(row) - cy - echo.dy;
|
||||||
|
const double sx = cx + (rx * echo.cosine + ry * echo.sine) * echo.shrink;
|
||||||
|
const double sy = cy + (-rx * echo.sine + ry * echo.cosine) * echo.shrink;
|
||||||
|
if (sx < 0.0 || sy < 0.0 || sx > width - 1 || sy > height - 1) continue;
|
||||||
|
std::uint8_t echoed[3];
|
||||||
|
sampleBilinear(input, sx, sy, echoed);
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
colour[c] += (echoed[c] - colour[c]) * echo.mix / 256;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
out[px + static_cast<std::size_t>(c)] = static_cast<std::uint8_t>(colour[c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// The feedback effect in motion: the picture nested inside itself, each copy
|
||||||
|
// smaller, turned and drifted further than the one around it, and the whole
|
||||||
|
// stack flying inwards (or outwards) without end.
|
||||||
|
//
|
||||||
|
// A copy sits at a depth, and its size, turn and drift follow from that depth
|
||||||
|
// alone. Every frame moves each copy on by part of one step, so after a whole
|
||||||
|
// cycle each copy lies exactly where its neighbour started: a new one fades
|
||||||
|
// in at the far end while the nearest fades out into the picture itself, and
|
||||||
|
// the clip loops without a jump.
|
||||||
|
class TunnelEffect : public VideoEffect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "tunnel"; }
|
||||||
|
std::string title() const override { return "Echo tunnel"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "Nests the picture inside itself, as Feedback does, and flies through the echoes "
|
||||||
|
"without end.";
|
||||||
|
}
|
||||||
|
const std::vector<ParamSpec>& params() const override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
#include "core/video.hpp"
|
||||||
|
#include "effects/source_fill.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
double Moment::length() const noexcept {
|
||||||
|
return static_cast<double>(std::max(1, frame_.count)) / std::max(1, frame_.fps);
|
||||||
|
}
|
||||||
|
|
||||||
|
double Moment::loopRate(double perSecond) const noexcept {
|
||||||
|
const double clip = length();
|
||||||
|
return std::max(1.0, std::round(perSecond * clip)) / clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
double Moment::cycle(double perSecond) const noexcept {
|
||||||
|
return std::fmod(seconds() * loopRate(perSecond), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Rng Moment::fresh() const noexcept {
|
||||||
|
return Rng(seed_ ^ (0x9e3779b97f4a7c15ULL * static_cast<std::uint64_t>(frame_.index + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Image VideoEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
|
const ParamValues& values) const {
|
||||||
|
return renderFrame(sources, width, height, rng, values, FrameTime{0, kDefaultFps, kDefaultFps});
|
||||||
|
}
|
||||||
|
|
||||||
|
Image VideoEffect::renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
|
const ParamValues& values, const FrameTime& frame) const {
|
||||||
|
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||||
|
const FrameTime at = frame.video() ? frame : FrameTime{0, kDefaultFps, kDefaultFps};
|
||||||
|
|
||||||
|
// An effect built from the sources takes no crop, only the one seed
|
||||||
|
// everything it draws comes from -- the same value on every frame.
|
||||||
|
if (needsSources()) {
|
||||||
|
if (sources.empty()) throw EffectError("No source images.");
|
||||||
|
const std::uint64_t seed = rng.next();
|
||||||
|
return compose(sources, width, height, values, Moment(at, seed));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both of these take the same values from the shared rng on every frame:
|
||||||
|
// the crop a leading stage starts from, and the one seed everything the
|
||||||
|
// effect draws comes from.
|
||||||
|
Image input = canvasFromSources(sources, width, height, rng);
|
||||||
|
const std::uint64_t seed = rng.next();
|
||||||
|
|
||||||
|
return animate(std::move(input), values, Moment(at, seed));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A video effect writes one of these two; the other is never called.
|
||||||
|
Image VideoEffect::animate(Image, const ParamValues&, const Moment&) const {
|
||||||
|
throw std::logic_error(name() + " is a transform but writes no animate()");
|
||||||
|
}
|
||||||
|
|
||||||
|
Image VideoEffect::compose(const std::vector<Image>&, int, int, const ParamValues&,
|
||||||
|
const Moment&) const {
|
||||||
|
throw std::logic_error(name() + " needs sources but writes no compose()");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// Where one frame sits in its clip, and the randomness it may draw on, as a
|
||||||
|
// video effect is handed it. Time is always read through here: the rates it
|
||||||
|
// hands out are rounded to fit the clip, so whatever an effect does with
|
||||||
|
// them, the last frame leads straight back into the first and a clip loops
|
||||||
|
// without a jump.
|
||||||
|
class Moment {
|
||||||
|
public:
|
||||||
|
Moment(const FrameTime& frame, std::uint64_t seed) noexcept : frame_(frame), seed_(seed) {}
|
||||||
|
|
||||||
|
int index() const noexcept { return frame_.index; }
|
||||||
|
int frames() const noexcept { return frame_.count; }
|
||||||
|
int fps() const noexcept { return frame_.fps; }
|
||||||
|
double seconds() const noexcept { return frame_.seconds(); } // into the clip
|
||||||
|
double length() const noexcept; // of the whole clip
|
||||||
|
|
||||||
|
// The rate nearest `perSecond` at which a whole number of cycles fits the
|
||||||
|
// clip, and never fewer than one.
|
||||||
|
double loopRate(double perSecond) const noexcept;
|
||||||
|
|
||||||
|
// How far through a cycle that happens `perSecond` times a second this
|
||||||
|
// frame is, from 0 at its start towards 1 at its end: the one number most
|
||||||
|
// motion needs -- sin(2π · cycle) sways, cycle · height rolls down.
|
||||||
|
double cycle(double perSecond) const noexcept;
|
||||||
|
|
||||||
|
// Draws that come out the same on every frame of the clip: where a sway
|
||||||
|
// starts, which way it goes -- what makes the motion this seed's own.
|
||||||
|
Rng steady() const noexcept { return Rng(seed_); }
|
||||||
|
|
||||||
|
// Draws that are new on every frame: shake, noise, flicker. The same
|
||||||
|
// frame of the same seed still draws the same values, so frames can be
|
||||||
|
// rendered alone and in any order.
|
||||||
|
Rng fresh() const noexcept;
|
||||||
|
|
||||||
|
private:
|
||||||
|
FrameTime frame_;
|
||||||
|
std::uint64_t seed_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The base of every video effect, and the one place that keeps the rules a
|
||||||
|
// moving stage has to follow, so an effect built on it is only its look:
|
||||||
|
//
|
||||||
|
// - It takes one value from the shared rng -- the same on every frame -- and
|
||||||
|
// hands the effect generators of its own through the Moment. The stages
|
||||||
|
// after it therefore see the rng in one state throughout and hold still
|
||||||
|
// while it moves.
|
||||||
|
// - It is one of two kinds. A transform (the default) writes animate(): it
|
||||||
|
// needs no source images, and leading a chain it starts from a crop of
|
||||||
|
// one, as the other transforms do. One that builds its frames out of the
|
||||||
|
// sources answers needsSources() and writes compose() instead: it is
|
||||||
|
// handed the loaded photographs when it leads a chain and the stage before
|
||||||
|
// it otherwise, as the image effects are, and takes no crop.
|
||||||
|
// - It never runs in a still project (the chain skips it). Asked for a still
|
||||||
|
// directly, it gives the first frame of a one-second clip.
|
||||||
|
//
|
||||||
|
// A new video effect subclasses this, declares its params like any effect,
|
||||||
|
// and writes animate() or compose(); see TrackingEffect for a transform and
|
||||||
|
// TileSwapEffect for one built from the sources.
|
||||||
|
class VideoEffect : public Effect {
|
||||||
|
public:
|
||||||
|
bool video() const final { return true; }
|
||||||
|
bool needsSources() const override { return false; }
|
||||||
|
|
||||||
|
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
|
const ParamValues& values) const final;
|
||||||
|
Image renderFrame(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
|
const ParamValues& values, const FrameTime& frame) const final;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// One frame of the clip: `input` is the picture this stage is handed,
|
||||||
|
// already at the canvas size, and what comes back is the same size.
|
||||||
|
// Every random value comes from `at`, never from anywhere else. A
|
||||||
|
// transform writes this.
|
||||||
|
virtual Image animate(Image input, const ParamValues& values, const Moment& at) const;
|
||||||
|
|
||||||
|
// One frame built out of `sources` (never empty) at the canvas size, for
|
||||||
|
// an effect that answers needsSources(); the same rule on randomness.
|
||||||
|
virtual Image compose(const std::vector<Image>& sources, int width, int height,
|
||||||
|
const ParamValues& values, const Moment& at) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
+4
-101
@@ -5,6 +5,7 @@
|
|||||||
#include <vector>
|
#include <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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ int runGui(const Options& initial, int argc, char** argv) {
|
|||||||
|
|
||||||
auto app = Gtk::Application::create("org.effector.Effector",
|
auto app = Gtk::Application::create("org.effector.Effector",
|
||||||
Gio::Application::Flags::NON_UNIQUE);
|
Gio::Application::Flags::NON_UNIQUE);
|
||||||
|
app->signal_startup().connect([] {
|
||||||
|
// An installed Effector finds its icon in the hicolor theme; one run
|
||||||
|
// straight from the build tree falls back to the copy in the sources.
|
||||||
|
const char* icon = "org.effector.Effector";
|
||||||
|
auto theme = Gtk::IconTheme::get_for_display(Gdk::Display::get_default());
|
||||||
|
if (!theme->has_icon(icon)) theme->add_search_path(EFFECTOR_SOURCE_ICON_DIR);
|
||||||
|
Gtk::Window::set_default_icon_name(icon);
|
||||||
|
});
|
||||||
return app->make_window_and_run<EffectorWindow>(0, nullptr, initial);
|
return app->make_window_and_run<EffectorWindow>(0, nullptr, initial);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+959
-46
File diff suppressed because it is too large
Load Diff
+123
-1
@@ -10,6 +10,7 @@
|
|||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "cli/options.hpp"
|
#include "cli/options.hpp"
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
#include "core/image.hpp"
|
#include "core/image.hpp"
|
||||||
#include "core/project.hpp"
|
#include "core/project.hpp"
|
||||||
#include "core/style.hpp"
|
#include "core/style.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -68,7 +70,7 @@ private:
|
|||||||
void onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
void onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||||
const Glib::RefPtr<Gtk::FileDialog>& dialog);
|
const Glib::RefPtr<Gtk::FileDialog>& dialog);
|
||||||
void addPaths(const std::vector<std::string>& paths); // decodes in the background
|
void addPaths(const std::vector<std::string>& paths); // decodes in the background
|
||||||
void onLoadProgress(); // dispatcher: one file finished decoding
|
void onLoadProgress(); // dispatcher: one file sized or decoded
|
||||||
void onLoadFinished(); // dispatcher: the batch is done or was cancelled
|
void onLoadFinished(); // dispatcher: the batch is done or was cancelled
|
||||||
void showLoadWindow();
|
void showLoadWindow();
|
||||||
void closeLoadWindow();
|
void closeLoadWindow();
|
||||||
@@ -87,8 +89,38 @@ private:
|
|||||||
void syncCanvasWidgets();
|
void syncCanvasWidgets();
|
||||||
void removeSource(Gtk::ListBoxRow* row);
|
void removeSource(Gtk::ListBoxRow* row);
|
||||||
|
|
||||||
|
// Video: a switch that makes the project a clip, and its length, rate and
|
||||||
|
// preset. A preset owns the canvas format as well as the timing; changing
|
||||||
|
// any of them by hand drops it back to "Custom".
|
||||||
|
Gtk::Widget& buildVideoSection();
|
||||||
|
VideoSettings videoSettings() const;
|
||||||
|
void setVideoSettings(const VideoSettings& video); // quietly, for a project
|
||||||
|
void onVideoToggled();
|
||||||
|
void onVideoPresetSelected();
|
||||||
|
void onVideoTimingChanged();
|
||||||
|
void matchVideoPreset(); // "Custom" once the settings are no longer the preset's
|
||||||
|
bool isVideo() const { return videoSwitch_.get_active(); }
|
||||||
|
|
||||||
|
// The preview of a video is the whole clip, rendered first and then looped.
|
||||||
|
void onClipProgress(); // dispatcher: frames arrived from the worker
|
||||||
|
void showIncomingClip(); // the clip in flight takes the screen, gaps held
|
||||||
|
void showClipFrame(int index);
|
||||||
|
void startPlayback();
|
||||||
|
void stopPlayback();
|
||||||
|
void setPlaying(bool playing);
|
||||||
|
void clearClip();
|
||||||
|
|
||||||
|
// Writing a video file: rendered again at full size, on a thread of its
|
||||||
|
// own, into ffmpeg, with a progress window that can cancel it.
|
||||||
|
void startExport(const std::string& path);
|
||||||
|
void onExportProgress();
|
||||||
|
void onExportFinished();
|
||||||
|
void cancelExport();
|
||||||
|
void closeExportWindow();
|
||||||
|
|
||||||
Gtk::Widget& buildChainSection();
|
Gtk::Widget& buildChainSection();
|
||||||
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
||||||
|
void updateVideoEffectItems(); // offers the video effects in a video only
|
||||||
void appendStage(const EffectPtr& effect);
|
void appendStage(const EffectPtr& effect);
|
||||||
void removeStage(int index);
|
void removeStage(int index);
|
||||||
void moveStage(int from, int to); // `to` is the index it ends up at
|
void moveStage(int from, int to); // `to` is the index it ends up at
|
||||||
@@ -129,6 +161,26 @@ private:
|
|||||||
void writeProject(const std::string& path);
|
void writeProject(const std::string& path);
|
||||||
void updateTitle();
|
void updateTitle();
|
||||||
|
|
||||||
|
// Undo and redo. The history is a list of whole snapshots of the project,
|
||||||
|
// taken a moment after the edits stop, so a spin held down or a word typed
|
||||||
|
// is one step rather than one per tick. The sources ride along as the
|
||||||
|
// immutable list they already are, so stepping back never decodes again.
|
||||||
|
struct HistoryState {
|
||||||
|
std::string json; // what the step is compared by
|
||||||
|
Project project;
|
||||||
|
std::shared_ptr<const std::vector<Image>> sources;
|
||||||
|
std::vector<std::string> refusedSources;
|
||||||
|
int selectedStage = 0;
|
||||||
|
};
|
||||||
|
HistoryState captureState() const;
|
||||||
|
void restoreState(const HistoryState& state);
|
||||||
|
void noteChange(); // coalesced: restarts the short history timer
|
||||||
|
void commitHistory(); // the current project becomes a step, if it changed
|
||||||
|
void resetHistory(); // a new or opened project starts with none
|
||||||
|
void undo();
|
||||||
|
void redo();
|
||||||
|
void updateHistoryActions();
|
||||||
|
|
||||||
void onParamChanged();
|
void onParamChanged();
|
||||||
// The chooser behind a Path param. The chosen file goes straight into the
|
// The chooser behind a Path param. The chosen file goes straight into the
|
||||||
// stage's values rather than into the widget, which may well have been
|
// stage's values rather than into the widget, which may well have been
|
||||||
@@ -174,22 +226,43 @@ private:
|
|||||||
Image result_;
|
Image result_;
|
||||||
std::uint64_t seed_ = 0;
|
std::uint64_t seed_ = 0;
|
||||||
std::string projectPath_; // empty until the project has been saved once
|
std::string projectPath_; // empty until the project has been saved once
|
||||||
|
// The last image or video saved from this project, which the next Save
|
||||||
|
// offers again with its number counted on; empty until one is saved.
|
||||||
|
std::string lastExport_;
|
||||||
Glib::RefPtr<Gio::Menu> recentMenu_;
|
Glib::RefPtr<Gio::Menu> recentMenu_;
|
||||||
sigc::connection recentChanged_; // the recent list outlives the window
|
sigc::connection recentChanged_; // the recent list outlives the window
|
||||||
bool suspendRender_ = true;
|
bool suspendRender_ = true;
|
||||||
|
|
||||||
|
std::vector<HistoryState> undo_;
|
||||||
|
std::vector<HistoryState> redo_;
|
||||||
|
HistoryState committed_; // the project as the last step left it
|
||||||
|
sigc::connection historyCommit_;
|
||||||
|
bool suspendHistory_ = false; // set while a step is being put back
|
||||||
|
// The sources of a project being opened are not a step of their own: the
|
||||||
|
// history starts over once they are in.
|
||||||
|
bool historyAfterLoad_ = false;
|
||||||
|
Glib::RefPtr<Gio::SimpleAction> undoAction_;
|
||||||
|
Glib::RefPtr<Gio::SimpleAction> redoAction_;
|
||||||
|
|
||||||
// Source loading. Decoding a batch of large photographs takes seconds, so
|
// Source loading. Decoding a batch of large photographs takes seconds, so
|
||||||
// it runs off the main thread and reports into a small progress window.
|
// it runs off the main thread and reports into a small progress window.
|
||||||
std::thread loader_;
|
std::thread loader_;
|
||||||
Glib::Dispatcher loadProgress_;
|
Glib::Dispatcher loadProgress_;
|
||||||
Glib::Dispatcher loadFinished_;
|
Glib::Dispatcher loadFinished_;
|
||||||
std::atomic<bool> loadCancel_{false};
|
std::atomic<bool> loadCancel_{false};
|
||||||
|
std::atomic<std::size_t> loadSized_{0}; // headers read (sizing the batch)
|
||||||
std::atomic<std::size_t> loadDone_{0};
|
std::atomic<std::size_t> loadDone_{0};
|
||||||
std::size_t loadTotal_ = 0;
|
std::size_t loadTotal_ = 0;
|
||||||
std::mutex loadMutex_;
|
std::mutex loadMutex_;
|
||||||
std::string loadCurrent_;
|
std::string loadCurrent_;
|
||||||
std::vector<Image> loadedImages_;
|
std::vector<Image> loadedImages_;
|
||||||
std::vector<LoadFailure> loadFailures_;
|
std::vector<LoadFailure> loadFailures_;
|
||||||
|
std::string loadRefused_; // why the batch would not fit in memory, if it would not
|
||||||
|
std::vector<std::string> loadRefusedPaths_;
|
||||||
|
// Sources refused as too large to hold. They are not shown or rendered,
|
||||||
|
// but a project saved now keeps them, so a refusal never costs the
|
||||||
|
// project its images.
|
||||||
|
std::vector<std::string> refusedSources_;
|
||||||
std::vector<std::string> loadQueue_; // chosen while another batch was loading
|
std::vector<std::string> loadQueue_; // chosen while another batch was loading
|
||||||
bool loading_ = false;
|
bool loading_ = false;
|
||||||
bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project
|
bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project
|
||||||
@@ -205,6 +278,40 @@ private:
|
|||||||
Image pendingImage_;
|
Image pendingImage_;
|
||||||
std::string pendingError_;
|
std::string pendingError_;
|
||||||
double pendingMillis_ = 0.0;
|
double pendingMillis_ = 0.0;
|
||||||
|
// Set to stop a clip render early once its settings are out of date.
|
||||||
|
std::atomic<bool> renderCancel_{false};
|
||||||
|
bool renderingVideo_ = false; // what the render in flight is making
|
||||||
|
|
||||||
|
// The clip being previewed: small frames, ready to show, and the frames
|
||||||
|
// of the clip being rendered, by index, which take its place once its
|
||||||
|
// draft frames are in (showIncomingClip).
|
||||||
|
Glib::Dispatcher clipProgress_;
|
||||||
|
std::vector<std::pair<int, Image>> pendingFrames_; // by index, under resultMutex_
|
||||||
|
std::vector<Glib::RefPtr<Gdk::Texture>> clip_;
|
||||||
|
std::vector<Glib::RefPtr<Gdk::Texture>> incomingClip_;
|
||||||
|
int incomingTotal_ = 0;
|
||||||
|
int incomingReceived_ = 0;
|
||||||
|
int incomingFps_ = kDefaultFps;
|
||||||
|
// The clip on screen is the one being rendered, its draft frames all in.
|
||||||
|
bool draftShown_ = false;
|
||||||
|
int clipFps_ = kDefaultFps;
|
||||||
|
int playFrame_ = 0;
|
||||||
|
bool playing_ = true;
|
||||||
|
sigc::connection playTimer_;
|
||||||
|
bool suspendScrub_ = false; // set while playback moves the scale itself
|
||||||
|
|
||||||
|
// Video export
|
||||||
|
std::thread exporter_;
|
||||||
|
Glib::Dispatcher exportProgress_;
|
||||||
|
Glib::Dispatcher exportDone_;
|
||||||
|
std::atomic<bool> exportCancel_{false};
|
||||||
|
std::atomic<int> exportFrames_{0};
|
||||||
|
int exportTotal_ = 0;
|
||||||
|
bool exporting_ = false;
|
||||||
|
std::string exportPath_;
|
||||||
|
std::string exportError_; // written by the exporter before exportDone_
|
||||||
|
std::unique_ptr<Gtk::Window> exportWindow_;
|
||||||
|
Gtk::ProgressBar* exportBar_ = nullptr; // nulled with the window
|
||||||
|
|
||||||
// Widgets
|
// Widgets
|
||||||
Gtk::Label banner_;
|
Gtk::Label banner_;
|
||||||
@@ -224,6 +331,13 @@ private:
|
|||||||
Gtk::SpinButton heightSpin_;
|
Gtk::SpinButton heightSpin_;
|
||||||
Gtk::SpinButton dpiSpin_;
|
Gtk::SpinButton dpiSpin_;
|
||||||
Gtk::Label* dpiLabel_ = nullptr; // hidden along with the dpi spin
|
Gtk::Label* dpiLabel_ = nullptr; // hidden along with the dpi spin
|
||||||
|
Gtk::Switch videoSwitch_;
|
||||||
|
Gtk::Box videoDetails_; // hidden while the project is a still
|
||||||
|
Gtk::DropDown videoPresetDrop_;
|
||||||
|
Gtk::SpinButton durationSpin_;
|
||||||
|
Gtk::SpinButton fpsSpin_;
|
||||||
|
Gtk::DropDown videoQualityDrop_; // videoQualities()[index]; only the export hears of it
|
||||||
|
bool suspendVideoSync_ = false; // set while code, not the user, fills them
|
||||||
// Set while a format fills the spins, so their own handler does not queue
|
// Set while a format fills the spins, so their own handler does not queue
|
||||||
// a second render on top of the one the format change asks for.
|
// a second render on top of the one the format change asks for.
|
||||||
bool suspendCanvasSync_ = false;
|
bool suspendCanvasSync_ = false;
|
||||||
@@ -237,6 +351,10 @@ private:
|
|||||||
Gtk::Entry seedEntry_;
|
Gtk::Entry seedEntry_;
|
||||||
Gtk::ListBox chainList_;
|
Gtk::ListBox chainList_;
|
||||||
Gtk::MenuButton addEffectButton_;
|
Gtk::MenuButton addEffectButton_;
|
||||||
|
// The entries of the video effects in its menu, and the line saying why
|
||||||
|
// they cannot be picked in a still; both owned by the menu.
|
||||||
|
std::vector<Gtk::Button*> videoEffectItems_;
|
||||||
|
Gtk::Label* videoEffectsHint_ = nullptr;
|
||||||
Gtk::MenuButton menuButton_;
|
Gtk::MenuButton menuButton_;
|
||||||
Gtk::Button recreateButton_{"Recreate"};
|
Gtk::Button recreateButton_{"Recreate"};
|
||||||
Gtk::Button saveButton_{"Save As…"};
|
Gtk::Button saveButton_{"Save As…"};
|
||||||
@@ -245,6 +363,10 @@ private:
|
|||||||
Gtk::Label placeholder_;
|
Gtk::Label placeholder_;
|
||||||
Gtk::Spinner spinner_;
|
Gtk::Spinner spinner_;
|
||||||
Gtk::Label status_;
|
Gtk::Label status_;
|
||||||
|
Gtk::Box playbackBar_; // under the preview of a video only
|
||||||
|
Gtk::Button playButton_;
|
||||||
|
Gtk::Scale playbackScale_;
|
||||||
|
Gtk::Label playbackTime_;
|
||||||
|
|
||||||
struct ParamWidget {
|
struct ParamWidget {
|
||||||
ParamSpec spec;
|
ParamSpec spec;
|
||||||
|
|||||||
+65
-3
@@ -13,12 +13,56 @@
|
|||||||
#include "core/image.hpp"
|
#include "core/image.hpp"
|
||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
#include "core/version.hpp"
|
#include "core/version.hpp"
|
||||||
#include "gui/app.hpp"
|
#include "gui/app.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
int renderVideo(const effector::Options& options, const std::vector<effector::ChainStage>& stages,
|
||||||
|
const std::vector<effector::Image>& sources, std::uint64_t seed) {
|
||||||
|
const int count = options.video.frameCount();
|
||||||
|
const bool showProgress = isatty(fileno(stderr)) != 0;
|
||||||
|
|
||||||
|
const auto start = std::chrono::steady_clock::now();
|
||||||
|
effector::VideoWriter writer(options.output, options.width, options.height, options.video.fps,
|
||||||
|
options.video.quality);
|
||||||
|
effector::renderFrames(stages, sources, options.width, options.height, seed, {}, options.video,
|
||||||
|
[&](int index, effector::Image&& frame) {
|
||||||
|
writer.write(frame);
|
||||||
|
if (showProgress) {
|
||||||
|
std::cerr << "\r\033[Kframe " << index + 1 << "/" << count
|
||||||
|
<< std::flush;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (showProgress) std::cerr << "\r\033[K" << std::flush;
|
||||||
|
writer.finish();
|
||||||
|
const auto seconds =
|
||||||
|
std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
|
||||||
|
|
||||||
|
std::cout << options.output << " " << options.width << "x" << options.height << " "
|
||||||
|
<< count << " frames at " << options.video.fps << " fps effect="
|
||||||
|
<< effector::chainSlug(stages, true) << " seed=" << seed << " render="
|
||||||
|
<< std::fixed << std::setprecision(1) << seconds << " s\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
int renderHeadless(const effector::Options& options) {
|
int renderHeadless(const effector::Options& options) {
|
||||||
|
// Checked before anything is decoded: a mismatch between what was asked
|
||||||
|
// for and the file named is a typo, not something to find out later.
|
||||||
|
const bool videoFile = effector::isVideoPath(options.output);
|
||||||
|
if (options.video.enabled && !videoFile) {
|
||||||
|
std::cerr << "effector: a video is written as .mp4, .mov, .mkv, .webm or .gif, not "
|
||||||
|
<< options.output << "\n";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (!options.video.enabled && videoFile) {
|
||||||
|
std::cerr << "effector: " << options.output
|
||||||
|
<< " is a video; give its length with --duration or --video-preset\n";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
auto stages = effector::buildChain(options);
|
auto stages = effector::buildChain(options);
|
||||||
// A headless run always renders something, so with no -e the default
|
// A headless run always renders something, so with no -e the default
|
||||||
// effect stands in. The interface has no such need: it opens on an empty
|
// effect stands in. The interface has no such need: it opens on an empty
|
||||||
@@ -28,6 +72,17 @@ int renderHeadless(const effector::Options& options) {
|
|||||||
stages.push_back({effect, effect->defaults()});
|
stages.push_back({effect, effect->defaults()});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A video effect has nothing to do in a still; the render skips it, and
|
||||||
|
// says so here rather than leave it looking forgotten.
|
||||||
|
if (!options.video.enabled) {
|
||||||
|
for (const auto& stage : stages) {
|
||||||
|
if (!stage.enabled || !stage.effect->video()) continue;
|
||||||
|
std::cerr << "effector: skipping " << stage.effect->name()
|
||||||
|
<< ": a video effect, and this is a still (give --duration or "
|
||||||
|
"--video-preset for a clip)\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<effector::LoadFailure> failures;
|
std::vector<effector::LoadFailure> failures;
|
||||||
|
|
||||||
// Decoding a batch of photographs takes seconds; on a terminal keep a
|
// Decoding a batch of photographs takes seconds; on a terminal keep a
|
||||||
@@ -37,6 +92,11 @@ int renderHeadless(const effector::Options& options) {
|
|||||||
std::mutex progressMutex;
|
std::mutex progressMutex;
|
||||||
const bool showProgress = isatty(fileno(stderr)) != 0 && options.images.size() > 1;
|
const bool showProgress = isatty(fileno(stderr)) != 0 && options.images.size() > 1;
|
||||||
if (showProgress) {
|
if (showProgress) {
|
||||||
|
progress.onSized = [&](std::size_t done, std::size_t total, const std::string& path) {
|
||||||
|
std::lock_guard<std::mutex> lock(progressMutex);
|
||||||
|
std::cerr << "\r\033[Ksizing " << done << "/" << total << " "
|
||||||
|
<< std::filesystem::path(path).filename().string() << std::flush;
|
||||||
|
};
|
||||||
progress.onFile = [&](std::size_t done, std::size_t total, const std::string& path) {
|
progress.onFile = [&](std::size_t done, std::size_t total, const std::string& path) {
|
||||||
std::lock_guard<std::mutex> lock(progressMutex);
|
std::lock_guard<std::mutex> lock(progressMutex);
|
||||||
std::cerr << "\r\033[Kreading " << done << "/" << total << " "
|
std::cerr << "\r\033[Kreading " << done << "/" << total << " "
|
||||||
@@ -48,12 +108,13 @@ int renderHeadless(const effector::Options& options) {
|
|||||||
for (const auto& failure : failures) {
|
for (const auto& failure : failures) {
|
||||||
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
|
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
|
||||||
}
|
}
|
||||||
if (sources.empty() && effector::chainNeedsSources(stages)) {
|
if (sources.empty() && effector::chainNeedsSources(stages, options.video.enabled)) {
|
||||||
std::cerr << "effector: no usable source images\n";
|
std::cerr << "effector: no usable source images\n";
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed());
|
const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed());
|
||||||
|
if (options.video.enabled) return renderVideo(options, stages, sources, seed);
|
||||||
effector::Rng rng(seed);
|
effector::Rng rng(seed);
|
||||||
|
|
||||||
const auto start = std::chrono::steady_clock::now();
|
const auto start = std::chrono::steady_clock::now();
|
||||||
@@ -64,7 +125,7 @@ int renderHeadless(const effector::Options& options) {
|
|||||||
image.save(options.output);
|
image.save(options.output);
|
||||||
|
|
||||||
std::cout << options.output << " " << options.width << "x" << options.height
|
std::cout << options.output << " " << options.width << "x" << options.height
|
||||||
<< " effect=" << effector::chainSlug(stages) << " seed=" << seed << " render="
|
<< " effect=" << effector::chainSlug(stages, false) << " seed=" << seed << " render="
|
||||||
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
|
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -85,7 +146,8 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
if (options.listEffects) {
|
if (options.listEffects) {
|
||||||
for (const auto& effect : effector::effects()) {
|
for (const auto& effect : effector::effects()) {
|
||||||
std::cout << effect->name() << "\t" << effect->title() << " - "
|
std::cout << effect->name() << "\t" << effect->title()
|
||||||
|
<< (effect->video() ? " (video)" : "") << " - "
|
||||||
<< effect->description() << "\n";
|
<< effect->description() << "\n";
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
+725
-9
@@ -4,8 +4,10 @@
|
|||||||
#include <array>
|
#include <array>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <clocale>
|
#include <clocale>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
@@ -20,18 +22,22 @@
|
|||||||
#include "cli/options.hpp"
|
#include "cli/options.hpp"
|
||||||
#include "core/canvas.hpp"
|
#include "core/canvas.hpp"
|
||||||
#include "core/chain.hpp"
|
#include "core/chain.hpp"
|
||||||
|
#include "core/memory.hpp"
|
||||||
#include "core/project.hpp"
|
#include "core/project.hpp"
|
||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
#include "core/style.hpp"
|
#include "core/style.hpp"
|
||||||
#include "core/version.hpp"
|
#include "core/version.hpp"
|
||||||
|
#include "core/video.hpp"
|
||||||
#include "effects/blur.hpp"
|
#include "effects/blur.hpp"
|
||||||
|
#include "effects/channelsplit.hpp"
|
||||||
#include "effects/grain.hpp"
|
#include "effects/grain.hpp"
|
||||||
#include "effects/monochrome.hpp"
|
#include "effects/monochrome.hpp"
|
||||||
#include "effects/overlay.hpp"
|
#include "effects/overlay.hpp"
|
||||||
#include "effects/scanlines.hpp"
|
#include "effects/scanlines.hpp"
|
||||||
#include "effects/text.hpp"
|
#include "effects/text.hpp"
|
||||||
#include "effects/tiles.hpp"
|
#include "effects/tiles.hpp"
|
||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -1065,7 +1071,8 @@ int main() {
|
|||||||
check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a),
|
check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a),
|
||||||
renderChain({scanlines, stage}, sources, 300, 200, b)),
|
renderChain({scanlines, stage}, sources, 300, 200, b)),
|
||||||
name + " cascades reproducibly");
|
name + " cascades reproducibly");
|
||||||
check(!chainNeedsSources({stage, scanlines}), "a chain led by " + name + " needs no sources");
|
check(!chainNeedsSources({stage, scanlines}, false),
|
||||||
|
"a chain led by " + name + " needs no sources");
|
||||||
|
|
||||||
// A canvas one pixel wide or high must not trip any of them up.
|
// A canvas one pixel wide or high must not trip any of them up.
|
||||||
check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 &&
|
check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 &&
|
||||||
@@ -1192,8 +1199,8 @@ int main() {
|
|||||||
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
|
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
|
||||||
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
|
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
|
||||||
|
|
||||||
check(chainTitle({scanlines, blur}) == "Scanlines \u2192 Blur", "the chain has a readable title");
|
check(chainTitle({scanlines, blur}, false) == "Scanlines \u2192 Blur", "the chain has a readable title");
|
||||||
check(chainSlug({scanlines, blur}) == "scanlines-blur", "the chain has a file name slug");
|
check(chainSlug({scanlines, blur}, false) == "scanlines-blur", "the chain has a file name slug");
|
||||||
|
|
||||||
bool threw = false;
|
bool threw = false;
|
||||||
try {
|
try {
|
||||||
@@ -1208,10 +1215,10 @@ int main() {
|
|||||||
// them, blur only softens what it is handed and runs without any.
|
// them, blur only softens what it is handed and runs without any.
|
||||||
check(effectByName("scanlines")->needsSources(), "scanlines needs source images");
|
check(effectByName("scanlines")->needsSources(), "scanlines needs source images");
|
||||||
check(!effectByName("blur")->needsSources(), "blur needs no source images");
|
check(!effectByName("blur")->needsSources(), "blur needs no source images");
|
||||||
check(chainNeedsSources({scanlines, blur}), "a chain led by scanlines needs sources");
|
check(chainNeedsSources({scanlines, blur}, false), "a chain led by scanlines needs sources");
|
||||||
check(!chainNeedsSources({blur, scanlines}),
|
check(!chainNeedsSources({blur, scanlines}, false),
|
||||||
"a chain led by blur needs none, later stages see the stage before them");
|
"a chain led by blur needs none, later stages see the stage before them");
|
||||||
check(!chainNeedsSources({}), "an empty chain needs no sources");
|
check(!chainNeedsSources({}, false), "an empty chain needs no sources");
|
||||||
|
|
||||||
Rng bare(5);
|
Rng bare(5);
|
||||||
const auto sourceless = renderChain({blur}, {}, 200, 150, bare);
|
const auto sourceless = renderChain({blur}, {}, 200, 150, bare);
|
||||||
@@ -1238,12 +1245,12 @@ int main() {
|
|||||||
Rng skipping(5);
|
Rng skipping(5);
|
||||||
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping);
|
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping);
|
||||||
check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it");
|
check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it");
|
||||||
check(chainTitle({scanlines, blurOff}) == "Scanlines", "the title names only what ran");
|
check(chainTitle({scanlines, blurOff}, false) == "Scanlines", "the title names only what ran");
|
||||||
check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug");
|
check(chainSlug({scanlines, blurOff}, false) == "scanlines", "and so does the slug");
|
||||||
|
|
||||||
// Whoever leads the chain is handed the sources, and that is the first
|
// Whoever leads the chain is handed the sources, and that is the first
|
||||||
// stage still switched on.
|
// stage still switched on.
|
||||||
check(!chainNeedsSources({scanlinesOff, blur}),
|
check(!chainNeedsSources({scanlinesOff, blur}, false),
|
||||||
"a disabled leading stage leaves the question to the stage behind it");
|
"a disabled leading stage leaves the question to the stage behind it");
|
||||||
Rng leading(5);
|
Rng leading(5);
|
||||||
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
|
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
|
||||||
@@ -1276,6 +1283,21 @@ int main() {
|
|||||||
{
|
{
|
||||||
const auto dir = std::filesystem::temp_directory_path() / "effector_test_project";
|
const auto dir = std::filesystem::temp_directory_path() / "effector_test_project";
|
||||||
std::filesystem::create_directories(dir);
|
std::filesystem::create_directories(dir);
|
||||||
|
{
|
||||||
|
const auto base = (dir / "city.png").string();
|
||||||
|
std::filesystem::remove(dir / "city-1.png");
|
||||||
|
std::filesystem::remove(dir / "city-2.png");
|
||||||
|
check(nextExportPath(base) == (dir / "city-1.png").string(),
|
||||||
|
"the next export after a plain name counts from 1");
|
||||||
|
check(nextExportPath((dir / "city-1.png").string()) == (dir / "city-2.png").string(),
|
||||||
|
"the next export counts on from the last number");
|
||||||
|
std::ofstream((dir / "city-1.png").string()) << "x";
|
||||||
|
check(nextExportPath(base) == (dir / "city-2.png").string(),
|
||||||
|
"the next export skips a name already on disk");
|
||||||
|
std::filesystem::remove(dir / "city-1.png");
|
||||||
|
check(nextExportPath((dir / "a-b.mp4").string()) == (dir / "a-b-1.mp4").string(),
|
||||||
|
"a dash before letters is not a count");
|
||||||
|
}
|
||||||
const auto source = dir / "source.png";
|
const auto source = dir / "source.png";
|
||||||
gradient(64, 48, 7).save(source.string());
|
gradient(64, 48, 7).save(source.string());
|
||||||
|
|
||||||
@@ -1688,9 +1710,16 @@ int main() {
|
|||||||
paths.push_back((dir / "missing.png").string());
|
paths.push_back((dir / "missing.png").string());
|
||||||
|
|
||||||
std::atomic<std::size_t> calls{0};
|
std::atomic<std::size_t> calls{0};
|
||||||
|
std::atomic<std::size_t> sizedCalls{0};
|
||||||
|
std::atomic<bool> sizedAfterDecode{false};
|
||||||
std::size_t lastTotal = 0;
|
std::size_t lastTotal = 0;
|
||||||
std::mutex mutex;
|
std::mutex mutex;
|
||||||
LoadProgress progress;
|
LoadProgress progress;
|
||||||
|
progress.onSized = [&](std::size_t done, std::size_t total, const std::string&) {
|
||||||
|
++sizedCalls;
|
||||||
|
if (calls.load() > 0) sizedAfterDecode = true;
|
||||||
|
check(done >= 1 && done <= total, "sizing step is within the batch");
|
||||||
|
};
|
||||||
progress.onFile = [&](std::size_t done, std::size_t total, const std::string&) {
|
progress.onFile = [&](std::size_t done, std::size_t total, const std::string&) {
|
||||||
std::lock_guard<std::mutex> lock(mutex);
|
std::lock_guard<std::mutex> lock(mutex);
|
||||||
++calls;
|
++calls;
|
||||||
@@ -1703,6 +1732,8 @@ int main() {
|
|||||||
check(images.size() == 6, "every readable image is returned");
|
check(images.size() == 6, "every readable image is returned");
|
||||||
check(failuresOut.size() == 1, "the unreadable one is reported");
|
check(failuresOut.size() == 1, "the unreadable one is reported");
|
||||||
check(calls.load() == paths.size(), "progress fires once per file");
|
check(calls.load() == paths.size(), "progress fires once per file");
|
||||||
|
check(sizedCalls.load() == paths.size() && !sizedAfterDecode.load(),
|
||||||
|
"every header is sized, all before the first decode");
|
||||||
check(lastTotal == paths.size(), "progress reports the batch size");
|
check(lastTotal == paths.size(), "progress reports the batch size");
|
||||||
check(images.front().path() == paths.front(), "input order is preserved");
|
check(images.front().path() == paths.front(), "input order is preserved");
|
||||||
|
|
||||||
@@ -1716,6 +1747,691 @@ int main() {
|
|||||||
std::filesystem::remove_all(dir);
|
std::filesystem::remove_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::cout << "memory\n";
|
||||||
|
{
|
||||||
|
check(availableMemory() > 0, "the free memory can be read");
|
||||||
|
check(formatBytes(std::size_t{5} << 29) == "2.5 GB" && formatBytes(std::size_t{380} << 20) == "380 MB",
|
||||||
|
"sizes are written in GB and MB");
|
||||||
|
|
||||||
|
const auto dir = std::filesystem::temp_directory_path() / "effector_test_memory";
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
std::vector<std::string> paths;
|
||||||
|
for (int i = 0; i < 3; ++i) {
|
||||||
|
const auto path = dir / ("src" + std::to_string(i) + ".png");
|
||||||
|
gradient(400, 300, i + 1).save(path.string());
|
||||||
|
paths.push_back(path.string());
|
||||||
|
}
|
||||||
|
check(imageFilePixels(paths[0]) == 400u * 300 && imageFilePixels((dir / "missing.png").string()) == 0,
|
||||||
|
"an image is sized from its header");
|
||||||
|
|
||||||
|
// Three 400 x 300 images hold 1 MB decoded; with 1 MB free they do not fit.
|
||||||
|
limitAvailableMemory(std::size_t{1} << 20);
|
||||||
|
std::atomic<int> decoded{0};
|
||||||
|
LoadProgress counting;
|
||||||
|
counting.onFile = [&](std::size_t, std::size_t, const std::string&) { ++decoded; };
|
||||||
|
std::vector<LoadFailure> failures;
|
||||||
|
bool refused = false;
|
||||||
|
std::string message;
|
||||||
|
try {
|
||||||
|
loadImages(paths, failures, &counting);
|
||||||
|
} catch (const OutOfMemoryError& error) {
|
||||||
|
refused = true;
|
||||||
|
message = error.what();
|
||||||
|
}
|
||||||
|
check(refused, "a batch too large for the free memory is refused");
|
||||||
|
check(decoded.load() == 0, "before a single file is decoded");
|
||||||
|
check(message.find("Loading these 3 images needs about") == 0 && message.find("with 1 MB free") != std::string::npos,
|
||||||
|
"and the message says how much is needed and how much is free");
|
||||||
|
|
||||||
|
const std::vector<ChainStage> scanlines = {{effectByName("scanlines"), {}}};
|
||||||
|
const std::vector<Image> small = {gradient(64, 64, 7)};
|
||||||
|
bool renderRefused = false;
|
||||||
|
try {
|
||||||
|
Rng rng(1);
|
||||||
|
renderChain(scanlines, small, 4000, 4000, rng);
|
||||||
|
} catch (const OutOfMemoryError&) {
|
||||||
|
renderRefused = true;
|
||||||
|
}
|
||||||
|
check(renderRefused, "a render too large for the free memory is refused");
|
||||||
|
bool videoRefused = false;
|
||||||
|
try {
|
||||||
|
VideoSettings clip;
|
||||||
|
clip.enabled = true;
|
||||||
|
renderFrames(scanlines, small, 4000, 4000, 1, {}, clip, [](int, Image&&) { return true; });
|
||||||
|
} catch (const OutOfMemoryError&) {
|
||||||
|
videoRefused = true;
|
||||||
|
}
|
||||||
|
check(videoRefused, "and so is a clip");
|
||||||
|
Rng smallRng(1);
|
||||||
|
check(renderChain(scanlines, small, 100, 100, smallRng).width() == 100,
|
||||||
|
"a render that fits still runs");
|
||||||
|
|
||||||
|
limitAvailableMemory(0);
|
||||||
|
failures.clear();
|
||||||
|
check(loadImages(paths, failures).size() == 3, "with the memory there the batch loads");
|
||||||
|
|
||||||
|
std::filesystem::remove_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cout << "video\n";
|
||||||
|
{
|
||||||
|
using effector::ChainStage;
|
||||||
|
using effector::FrameTime;
|
||||||
|
using effector::VideoSettings;
|
||||||
|
|
||||||
|
// Every preset names a canvas format this build offers, and its
|
||||||
|
// numbers are ones the project reader and the CLI accept.
|
||||||
|
int sound = 0;
|
||||||
|
for (const auto& preset : effector::videoPresets()) {
|
||||||
|
if (effector::canvasFormatByKey(preset.canvasFormat) != nullptr &&
|
||||||
|
preset.seconds >= effector::kMinDuration && preset.seconds <= effector::kMaxDuration &&
|
||||||
|
preset.fps >= effector::kMinFps && preset.fps <= effector::kMaxFps &&
|
||||||
|
effector::videoPresetByKey(preset.key) == &preset) {
|
||||||
|
++sound;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(sound == static_cast<int>(effector::videoPresets().size()),
|
||||||
|
"every video preset names a known canvas and a valid length and rate");
|
||||||
|
const auto* story = effector::videoPresetByKey("instagram-story");
|
||||||
|
check(story != nullptr && story->canvasFormat == "instagram-story" && story->seconds == 15.0,
|
||||||
|
"the Instagram story preset is a 15 s vertical clip");
|
||||||
|
VideoSettings clip;
|
||||||
|
clip.duration = 15.0;
|
||||||
|
clip.fps = 30;
|
||||||
|
check(clip.frameCount() == 450, "15 s at 30 fps is 450 frames");
|
||||||
|
clip.duration = 0.01;
|
||||||
|
check(clip.frameCount() == 3, "a clip below the shortest length is clamped to it");
|
||||||
|
check(effector::formatSeconds(7.5) == "7.5 s" && effector::formatSeconds(15) == "15 s",
|
||||||
|
"lengths read as 7.5 s and 15 s");
|
||||||
|
check(effector::isVideoPath("a/b.MP4") && effector::isVideoPath("x.webm") &&
|
||||||
|
effector::isVideoPath("x.gif") && !effector::isVideoPath("x.png") &&
|
||||||
|
!effector::isVideoPath("mp4"),
|
||||||
|
"a video is recognised by its extension");
|
||||||
|
|
||||||
|
const auto split = effector::effectByName("channelsplit");
|
||||||
|
const auto tracking = effector::effectByName("tracking");
|
||||||
|
const std::vector<effector::Image> sources = {gradient(96, 64, 40)};
|
||||||
|
|
||||||
|
check(tracking->video() && !split->video(),
|
||||||
|
"VHS tracking is a video effect, channel split an image one");
|
||||||
|
bool imagesFirst = true;
|
||||||
|
bool seenVideo = false;
|
||||||
|
for (const auto& effect : effects()) {
|
||||||
|
imagesFirst = imagesFirst && (effect->video() || !seenVideo);
|
||||||
|
seenVideo = seenVideo || effect->video();
|
||||||
|
}
|
||||||
|
check(imagesFirst, "the registry lists the image effects before the video effects");
|
||||||
|
|
||||||
|
auto wild = tracking->defaults();
|
||||||
|
wild["tracking_wobble"] = 40L;
|
||||||
|
wild["tracking_jitter"] = 30L;
|
||||||
|
wild["tracking_bar"] = 60L;
|
||||||
|
auto frozen = tracking->defaults();
|
||||||
|
frozen["tracking_wobble"] = 0L;
|
||||||
|
frozen["tracking_jitter"] = 0L;
|
||||||
|
frozen["tracking_bar"] = 0L;
|
||||||
|
|
||||||
|
// A still skips a video effect whole, as it skips a stage switched
|
||||||
|
// off: the picture is the one the chain made without it.
|
||||||
|
ChainStage scanlinesStage{effectByName("scanlines"), {}};
|
||||||
|
ChainStage trackingStage{tracking, wild};
|
||||||
|
effector::Rng withRng(5);
|
||||||
|
effector::Rng withoutRng(5);
|
||||||
|
check(sameBytes(renderChain({scanlinesStage, trackingStage}, sources, 96, 64, withRng),
|
||||||
|
renderChain({scanlinesStage}, sources, 96, 64, withoutRng)),
|
||||||
|
"a still skips a video effect as though it were not there");
|
||||||
|
check(chainTitle({scanlinesStage, trackingStage}, false) == "Scanlines" &&
|
||||||
|
chainTitle({scanlinesStage, trackingStage}, true) == "Scanlines \u2192 VHS tracking",
|
||||||
|
"and names it only in a video");
|
||||||
|
check(chainNeedsSources({trackingStage, scanlinesStage}, false) &&
|
||||||
|
!chainNeedsSources({trackingStage, scanlinesStage}, true),
|
||||||
|
"led by a video effect, a still starts from the stage after it");
|
||||||
|
bool onlyVideo = false;
|
||||||
|
try {
|
||||||
|
effector::Rng rng(1);
|
||||||
|
renderChain({trackingStage}, sources, 96, 64, rng);
|
||||||
|
} catch (const EffectError&) {
|
||||||
|
onlyVideo = true;
|
||||||
|
}
|
||||||
|
check(onlyVideo, "a still of video effects alone is refused");
|
||||||
|
|
||||||
|
// A video effect leaves the shared rng in the same state on every
|
||||||
|
// frame, so what comes after it holds still.
|
||||||
|
effector::Rng frameA(9);
|
||||||
|
effector::Rng frameB(9);
|
||||||
|
tracking->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
|
||||||
|
tracking->renderFrame(sources, 96, 64, frameB, wild, FrameTime{17, 30, 30});
|
||||||
|
check(frameA.next() == frameB.next(), "every frame leaves the shared rng in one state");
|
||||||
|
|
||||||
|
// Time is rounded to fit the clip, so the frame after the last is the
|
||||||
|
// first again, and a clip loops without a jump.
|
||||||
|
const effector::Moment start(FrameTime{0, 30, 30}, 1);
|
||||||
|
check(start.length() == 1.0 && start.loopRate(0.4) == 1.0 && start.loopRate(2.2) == 2.0,
|
||||||
|
"a rate is rounded to a whole number of cycles in the clip");
|
||||||
|
check(effector::Moment(FrameTime{15, 30, 30}, 1).cycle(1.0) == 0.5,
|
||||||
|
"half-way through the clip is half-way through its one cycle");
|
||||||
|
auto smooth = wild;
|
||||||
|
smooth["tracking_jitter"] = 0L;
|
||||||
|
const std::vector<effector::Image> input = {gradient(96, 64, 70)};
|
||||||
|
effector::Rng loopA(4);
|
||||||
|
effector::Rng loopB(4);
|
||||||
|
check(sameBytes(tracking->renderFrame(input, 96, 64, loopA, smooth, FrameTime{0, 30, 30}),
|
||||||
|
tracking->renderFrame(input, 96, 64, loopB, smooth, FrameTime{30, 30, 30})),
|
||||||
|
"without the shake, the clip loops without a jump");
|
||||||
|
effector::Rng stillFrame(4);
|
||||||
|
check(sameBytes(tracking->renderFrame(input, 96, 64, stillFrame, frozen, FrameTime{7, 30, 30}),
|
||||||
|
input[0]),
|
||||||
|
"with every motion at 0 it leaves its input alone");
|
||||||
|
|
||||||
|
auto stage = [](const std::string& name, effector::ParamValues values = {}) {
|
||||||
|
ChainStage built;
|
||||||
|
built.effect = effector::effectByName(name);
|
||||||
|
built.values = values.empty() ? built.effect->defaults() : std::move(values);
|
||||||
|
return built;
|
||||||
|
};
|
||||||
|
std::vector<ChainStage> chain = {stage("tiles"), stage("tracking", wild), stage("grain")};
|
||||||
|
VideoSettings video;
|
||||||
|
video.enabled = true;
|
||||||
|
video.duration = 1.0;
|
||||||
|
video.fps = 8;
|
||||||
|
|
||||||
|
std::vector<effector::Image> first;
|
||||||
|
const int handed = effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&& frame) {
|
||||||
|
first.push_back(std::move(frame));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
check(handed == 8 && first.size() == 8, "a clip hands out every one of its frames");
|
||||||
|
std::vector<effector::Image> second;
|
||||||
|
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&& frame) {
|
||||||
|
second.push_back(std::move(frame));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
bool repeatable = second.size() == first.size();
|
||||||
|
for (std::size_t i = 0; repeatable && i < first.size(); ++i) {
|
||||||
|
repeatable = sameBytes(first[i], second[i]);
|
||||||
|
}
|
||||||
|
check(repeatable, "a clip renders byte for byte again from its seed");
|
||||||
|
|
||||||
|
// The still stages before the moving one are rendered once and
|
||||||
|
// reused; that shortcut must give exactly what a frame on its own is.
|
||||||
|
bool exact = true;
|
||||||
|
for (const int index : {0, 3, 7}) {
|
||||||
|
effector::Rng rng(21);
|
||||||
|
const auto alone = effector::renderChain(chain, sources, 80, 48, rng, {},
|
||||||
|
FrameTime{index, 8, 8});
|
||||||
|
exact = exact && sameBytes(alone, first[static_cast<std::size_t>(index)]);
|
||||||
|
}
|
||||||
|
check(exact, "a clip's frame is exactly that frame rendered on its own");
|
||||||
|
check(!sameBytes(first[0], first[3]) && !sameBytes(first[3], first[7]),
|
||||||
|
"frames of a moving chain differ");
|
||||||
|
|
||||||
|
std::vector<ChainStage> steady = {stage("tiles"), stage("channelsplit"), stage("grain")};
|
||||||
|
std::vector<effector::Image> held;
|
||||||
|
effector::renderFrames(steady, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&& frame) {
|
||||||
|
held.push_back(std::move(frame));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
bool same = held.size() == 8;
|
||||||
|
for (const auto& frame : held) same = same && sameBytes(frame, held[0]);
|
||||||
|
effector::Rng stillRng(21);
|
||||||
|
check(same && sameBytes(held[0], effector::renderChain(steady, sources, 80, 48, stillRng)),
|
||||||
|
"a chain of image effects renders its still on every frame");
|
||||||
|
|
||||||
|
int seen = 0;
|
||||||
|
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&&) { return ++seen < 3; });
|
||||||
|
check(seen == 3, "returning false stops a clip early");
|
||||||
|
|
||||||
|
// A preview renders a draft of every third frame first; the frames
|
||||||
|
// are the same whichever order they come in.
|
||||||
|
const auto order = effector::draftFirstOrder(8, 3);
|
||||||
|
check(order == std::vector<int>({0, 3, 6, 1, 2, 4, 5, 7}),
|
||||||
|
"a draft order takes every third frame first, then the rest");
|
||||||
|
std::vector<effector::Image> drafted(8);
|
||||||
|
const int draftHanded = effector::renderFrames(
|
||||||
|
chain, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int index, effector::Image&& frame) {
|
||||||
|
drafted[static_cast<std::size_t>(index)] = std::move(frame);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
order);
|
||||||
|
bool sameClip = draftHanded == 8;
|
||||||
|
for (std::size_t i = 0; sameClip && i < first.size(); ++i) {
|
||||||
|
sameClip = sameBytes(first[i], drafted[i]);
|
||||||
|
}
|
||||||
|
check(sameClip, "a clip rendered draft first holds exactly the same frames");
|
||||||
|
|
||||||
|
chain[1].enabled = false;
|
||||||
|
std::vector<effector::Image> skipped;
|
||||||
|
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&& frame) {
|
||||||
|
skipped.push_back(std::move(frame));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
check(skipped.size() == 8 && sameBytes(skipped[0], skipped[7]),
|
||||||
|
"with the moving stage switched off the clip holds still");
|
||||||
|
|
||||||
|
const auto big = gradient(300, 120, 10);
|
||||||
|
const auto small = big.scaledToFit(100);
|
||||||
|
check(small.width() == 100 && small.height() == 40, "a preview frame keeps its shape");
|
||||||
|
check(big.scaledToFit(400).width() == 300, "a frame already small enough is left alone");
|
||||||
|
|
||||||
|
// Tile swap builds its frames out of the sources rather than
|
||||||
|
// transforming a crop of one.
|
||||||
|
{
|
||||||
|
const auto swap = effectByName("tileswap");
|
||||||
|
check(swap->video() && swap->needsSources(),
|
||||||
|
"tile swap is a video effect built from the sources");
|
||||||
|
ChainStage swapStage = stage("tileswap", {{"tileswap_width", 16L},
|
||||||
|
{"tileswap_height", 12L},
|
||||||
|
{"tileswap_rate", 2.0}});
|
||||||
|
ChainStage scanStage = stage("scanlines");
|
||||||
|
check(chainNeedsSources({swapStage}, true), "and a clip led by it needs sources");
|
||||||
|
effector::Rng withSwap(3);
|
||||||
|
effector::Rng withoutSwap(3);
|
||||||
|
check(sameBytes(renderChain({swapStage, scanStage}, sources, 80, 48, withSwap),
|
||||||
|
renderChain({scanStage}, sources, 80, 48, withoutSwap)),
|
||||||
|
"a still skips tile swap as though it were not there");
|
||||||
|
bool refused = false;
|
||||||
|
try {
|
||||||
|
effector::Rng rng(1);
|
||||||
|
renderChain({swapStage}, {}, 80, 48, rng, {}, FrameTime{0, 8, 8});
|
||||||
|
} catch (const EffectError&) {
|
||||||
|
refused = true;
|
||||||
|
}
|
||||||
|
check(refused, "tile swap with no sources is refused");
|
||||||
|
|
||||||
|
const std::vector<effector::Image> photos = {solid(60, 40, 255, 0, 0),
|
||||||
|
solid(50, 30, 0, 0, 255)};
|
||||||
|
auto frameOf = [&](const ChainStage& at, int index, int count = 8) {
|
||||||
|
effector::Rng rng(11);
|
||||||
|
return renderChain({at}, photos, 80, 48, rng, {}, FrameTime{index, count, 8});
|
||||||
|
};
|
||||||
|
effector::Rng swapA(9);
|
||||||
|
effector::Rng swapB(9);
|
||||||
|
swap->renderFrame(photos, 80, 48, swapA, swapStage.values, FrameTime{3, 8, 8});
|
||||||
|
swap->renderFrame(photos, 80, 48, swapB, swapStage.values, FrameTime{5, 8, 8});
|
||||||
|
check(swapA.next() == swapB.next(), "tile swap leaves the shared rng in one state");
|
||||||
|
|
||||||
|
auto onlyPhotos = [](const effector::Image& image) {
|
||||||
|
for (int y = 0; y < image.height(); ++y) {
|
||||||
|
const std::uint8_t* row = image.row(y);
|
||||||
|
for (int x = 0; x < image.width(); ++x) {
|
||||||
|
const std::uint8_t* p = row + static_cast<std::size_t>(x) * 3;
|
||||||
|
const bool red = p[0] == 255 && p[1] == 0 && p[2] == 0;
|
||||||
|
const bool blue = p[0] == 0 && p[1] == 0 && p[2] == 255;
|
||||||
|
if (!red && !blue) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
check(onlyPhotos(frameOf(swapStage, 0)) && onlyPhotos(frameOf(swapStage, 5)),
|
||||||
|
"its tiles are cut from the photographs");
|
||||||
|
bool swapped = false;
|
||||||
|
for (int index = 1; index < 8; ++index) {
|
||||||
|
swapped = swapped || !sameBytes(frameOf(swapStage, 0), frameOf(swapStage, index));
|
||||||
|
}
|
||||||
|
check(swapped, "its tiles change over the clip");
|
||||||
|
check(sameBytes(frameOf(swapStage, 0), frameOf(swapStage, 8)),
|
||||||
|
"and the frame after the last is the first again");
|
||||||
|
ChainStage frozenSwap = swapStage;
|
||||||
|
frozenSwap.values["tileswap_rate"] = 0.0;
|
||||||
|
check(sameBytes(frameOf(frozenSwap, 0), frameOf(frozenSwap, 5)),
|
||||||
|
"at a rate of 0 it holds still");
|
||||||
|
// How many pixels ever show something other than frame 0.
|
||||||
|
auto moving = [&](const ChainStage& at) {
|
||||||
|
const auto first = frameOf(at, 0);
|
||||||
|
std::vector<bool> moved(static_cast<std::size_t>(80 * 48), false);
|
||||||
|
for (int index = 1; index < 8; ++index) {
|
||||||
|
const auto frame = frameOf(at, index);
|
||||||
|
for (int y = 0; y < 48; ++y) {
|
||||||
|
for (int x = 0; x < 80; ++x) {
|
||||||
|
if (std::memcmp(frame.row(y) + x * 3, first.row(y) + x * 3, 3) != 0) {
|
||||||
|
moved[static_cast<std::size_t>(y * 80 + x)] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::count(moved.begin(), moved.end(), true);
|
||||||
|
};
|
||||||
|
ChainStage slowSwap = swapStage;
|
||||||
|
slowSwap.values["tileswap_rate"] = 0.3;
|
||||||
|
const auto slowMoving = moving(slowSwap);
|
||||||
|
check(slowMoving > 0 && slowMoving < moving(swapStage),
|
||||||
|
"a rate below two swaps a clip moves only some of the tiles");
|
||||||
|
|
||||||
|
const auto& rate = swap->params().back();
|
||||||
|
check(rate.onSlider && rate.curve > 1.0, "its swap rate is a curved slider");
|
||||||
|
check(rate.sliderValue(0.0) == 0.0 && rate.sliderValue(1.0) == 5.0 &&
|
||||||
|
rate.sliderValue(0.5) < 2.5,
|
||||||
|
"which spans the whole range and gives the low end more travel");
|
||||||
|
check(std::abs(rate.sliderValue(rate.sliderPosition(0.37)) - 0.37) < 1e-9 &&
|
||||||
|
rate.sliderValue(0.123456) * 100.0 ==
|
||||||
|
std::round(rate.sliderValue(0.123456) * 100.0),
|
||||||
|
"a value finds its place on the slider, and a place rounds to the step");
|
||||||
|
ChainStage scattered = swapStage;
|
||||||
|
scattered.values["tileswap_layout"] = std::string("scatter");
|
||||||
|
ChainStage bricks = swapStage;
|
||||||
|
bricks.values["tileswap_layout"] = std::string("brick");
|
||||||
|
bricks.values["tileswap_keep_order"] = true;
|
||||||
|
check(onlyPhotos(frameOf(scattered, 2)) && onlyPhotos(frameOf(bricks, 6)),
|
||||||
|
"scattered and brick layouts are cut from the photographs too");
|
||||||
|
|
||||||
|
const std::vector<ChainStage> swapChain = {swapStage, stage("grain")};
|
||||||
|
std::vector<effector::Image> clip;
|
||||||
|
effector::renderFrames(swapChain, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&& frame) {
|
||||||
|
clip.push_back(std::move(frame));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
bool alone = clip.size() == 8;
|
||||||
|
for (const int index : {0, 4, 7}) {
|
||||||
|
effector::Rng rng(21);
|
||||||
|
alone = alone && sameBytes(effector::renderChain(swapChain, sources, 80, 48, rng,
|
||||||
|
{}, FrameTime{index, 8, 8}),
|
||||||
|
clip[static_cast<std::size_t>(index)]);
|
||||||
|
}
|
||||||
|
check(alone, "a tile swap clip's frame is exactly that frame rendered on its own");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip roll builds its frames out of the sources too, and rolls the
|
||||||
|
// picture in each strip through its image.
|
||||||
|
{
|
||||||
|
const auto roll = effectByName("striproll");
|
||||||
|
check(roll->video() && roll->needsSources(),
|
||||||
|
"strip roll is a video effect built from the sources");
|
||||||
|
ChainStage rollStage = stage("striproll", {{"roll_height", 6L}, {"roll_speed", 40L}});
|
||||||
|
ChainStage scanStage = stage("scanlines");
|
||||||
|
effector::Rng withRoll(3);
|
||||||
|
effector::Rng withoutRoll(3);
|
||||||
|
check(sameBytes(renderChain({rollStage, scanStage}, sources, 80, 48, withRoll),
|
||||||
|
renderChain({scanStage}, sources, 80, 48, withoutRoll)),
|
||||||
|
"a still skips strip roll as though it were not there");
|
||||||
|
bool refused = false;
|
||||||
|
try {
|
||||||
|
effector::Rng rng(1);
|
||||||
|
renderChain({rollStage}, {}, 80, 48, rng, {}, FrameTime{0, 8, 8});
|
||||||
|
} catch (const EffectError&) {
|
||||||
|
refused = true;
|
||||||
|
}
|
||||||
|
check(refused, "strip roll with no sources is refused");
|
||||||
|
|
||||||
|
const std::vector<effector::Image> photos = {gradient(60, 40, 20), gradient(50, 30, 150)};
|
||||||
|
auto frameOf = [&](const ChainStage& at, int index) {
|
||||||
|
effector::Rng rng(11);
|
||||||
|
return renderChain({at}, photos, 80, 48, rng, {}, FrameTime{index, 8, 8});
|
||||||
|
};
|
||||||
|
effector::Rng rollA(9);
|
||||||
|
effector::Rng rollB(9);
|
||||||
|
roll->renderFrame(photos, 80, 48, rollA, rollStage.values, FrameTime{3, 8, 8});
|
||||||
|
roll->renderFrame(photos, 80, 48, rollB, rollStage.values, FrameTime{5, 8, 8});
|
||||||
|
check(rollA.next() == rollB.next(), "strip roll leaves the shared rng in one state");
|
||||||
|
|
||||||
|
// Every row of a strip is a row of one photograph, turned round.
|
||||||
|
auto rowsOfPhotos = [&](const effector::Image& image) {
|
||||||
|
for (int y = 0; y < image.height(); ++y) {
|
||||||
|
bool found = false;
|
||||||
|
for (const auto& photo : photos) {
|
||||||
|
for (int py = 0; !found && py < photo.height(); ++py) {
|
||||||
|
for (int shift = 0; !found && shift < photo.width(); ++shift) {
|
||||||
|
bool same = true;
|
||||||
|
for (int x = 0; same && x < image.width(); ++x) {
|
||||||
|
const int px = (x + shift) % photo.width();
|
||||||
|
same = std::equal(image.row(y) + x * 3, image.row(y) + x * 3 + 3,
|
||||||
|
photo.row(py) + px * 3);
|
||||||
|
}
|
||||||
|
found = same;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
check(rowsOfPhotos(frameOf(rollStage, 0)) && rowsOfPhotos(frameOf(rollStage, 5)),
|
||||||
|
"its strips are rows of the photographs");
|
||||||
|
bool rolled = false;
|
||||||
|
for (int index = 1; index < 8; ++index) {
|
||||||
|
rolled = rolled || !sameBytes(frameOf(rollStage, 0), frameOf(rollStage, index));
|
||||||
|
}
|
||||||
|
check(rolled, "its strips roll over the clip");
|
||||||
|
check(sameBytes(frameOf(rollStage, 0), frameOf(rollStage, 8)),
|
||||||
|
"and the frame after the last is the first again");
|
||||||
|
ChainStage sideways = rollStage;
|
||||||
|
sideways.values["roll_axis"] = std::string("horizontal");
|
||||||
|
sideways.values["roll_direction"] = std::string("random");
|
||||||
|
sideways.values["roll_keep_order"] = true;
|
||||||
|
check(rowsOfPhotos(frameOf(sideways, 3)) &&
|
||||||
|
!sameBytes(frameOf(sideways, 0), frameOf(sideways, 3)) &&
|
||||||
|
sameBytes(frameOf(sideways, 0), frameOf(sideways, 8)),
|
||||||
|
"sideways, its strips slide along their rows and loop");
|
||||||
|
ChainStage frozenRoll = rollStage;
|
||||||
|
frozenRoll.values["roll_speed"] = 0L;
|
||||||
|
check(sameBytes(frameOf(frozenRoll, 0), frameOf(frozenRoll, 5)),
|
||||||
|
"at a speed of 0 it holds still");
|
||||||
|
|
||||||
|
const std::vector<ChainStage> rollChain = {rollStage, stage("grain")};
|
||||||
|
std::vector<effector::Image> clip;
|
||||||
|
effector::renderFrames(rollChain, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&& frame) {
|
||||||
|
clip.push_back(std::move(frame));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
bool alone = clip.size() == 8;
|
||||||
|
for (const int index : {0, 4, 7}) {
|
||||||
|
effector::Rng rng(21);
|
||||||
|
alone = alone && sameBytes(effector::renderChain(rollChain, sources, 80, 48, rng,
|
||||||
|
{}, FrameTime{index, 8, 8}),
|
||||||
|
clip[static_cast<std::size_t>(index)]);
|
||||||
|
}
|
||||||
|
check(alone, "a strip roll clip's frame is exactly that frame rendered on its own");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The video transforms made from image effects. Each gets a setting
|
||||||
|
// that moves plenty, one without its per-frame noise (which, drawn
|
||||||
|
// anew on every frame, is the one thing that does not loop), and one
|
||||||
|
// that switches it off.
|
||||||
|
struct Moving {
|
||||||
|
std::string name;
|
||||||
|
effector::ParamValues lively;
|
||||||
|
effector::ParamValues smooth;
|
||||||
|
effector::ParamValues off;
|
||||||
|
};
|
||||||
|
const std::vector<Moving> moving = {
|
||||||
|
{"tunnel", {{"tunnel_drift", 6L}, {"tunnel_fade", 1.0}}, {}, {{"tunnel_fade", 0.0}}},
|
||||||
|
{"flow", {{"flow_mode", std::string("swirl")}}, {}, {{"flow_strength", 0L}}},
|
||||||
|
{"sweep", {{"sweep_reverse", true}, {"sweep_max_run", 20L}}, {},
|
||||||
|
{{"sweep_line_chance", 0.0}}},
|
||||||
|
{"filmgate", {{"gate_weave", 6L}, {"gate_dust", 20L}, {"gate_scratches", 3L}},
|
||||||
|
{{"gate_weave", 6L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L},
|
||||||
|
{"gate_scratches", 0L}},
|
||||||
|
{{"gate_weave", 0L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L},
|
||||||
|
{"gate_scratches", 0L}}},
|
||||||
|
{"datamosh", {{"mosh_rate", 0.4}, {"mosh_block", 8L}, {"mosh_bleed", true}}, {},
|
||||||
|
{{"mosh_rate", 0.0}}},
|
||||||
|
{"focuspull", {{"focus_radius", 5L}}, {}, {{"focus_radius", 0L}}},
|
||||||
|
{"focuspull", {{"focus_mode", std::string("band")}, {"focus_speed", 1.0}}, {},
|
||||||
|
{{"focus_mode", std::string("band")}, {"focus_radius", 0L}}},
|
||||||
|
};
|
||||||
|
for (const auto& [name, lively, smooth, off] : moving) {
|
||||||
|
const auto effect = effectByName(name);
|
||||||
|
auto with = [&](const effector::ParamValues& changes) {
|
||||||
|
ChainStage built = stage(name);
|
||||||
|
for (const auto& [key, value] : changes) built.values[key] = value;
|
||||||
|
return built;
|
||||||
|
};
|
||||||
|
auto frameOf = [&](const ChainStage& at, int index) {
|
||||||
|
effector::Rng rng(6);
|
||||||
|
return renderChain({at}, input, 96, 64, rng, {}, FrameTime{index, 8, 8});
|
||||||
|
};
|
||||||
|
const ChainStage wildStage = with(lively);
|
||||||
|
check(effect->video() && !effect->needsSources(),
|
||||||
|
name + " is a video effect that needs no sources");
|
||||||
|
|
||||||
|
ChainStage scanStage = stage("scanlines");
|
||||||
|
effector::Rng withIt(3);
|
||||||
|
effector::Rng withoutIt(3);
|
||||||
|
check(sameBytes(renderChain({scanStage, wildStage}, sources, 80, 48, withIt),
|
||||||
|
renderChain({scanStage}, sources, 80, 48, withoutIt)),
|
||||||
|
"a still skips " + name + " as though it were not there");
|
||||||
|
|
||||||
|
effector::Rng stateA(9);
|
||||||
|
effector::Rng stateB(9);
|
||||||
|
effect->renderFrame(sources, 80, 48, stateA, wildStage.values, FrameTime{3, 8, 8});
|
||||||
|
effect->renderFrame(sources, 80, 48, stateB, wildStage.values, FrameTime{5, 8, 8});
|
||||||
|
check(stateA.next() == stateB.next(), name + " leaves the shared rng in one state");
|
||||||
|
|
||||||
|
bool changes = false;
|
||||||
|
for (int index = 1; index < 8; ++index) {
|
||||||
|
changes = changes || !sameBytes(frameOf(wildStage, 0), frameOf(wildStage, index));
|
||||||
|
}
|
||||||
|
check(changes, name + " moves over the clip");
|
||||||
|
const ChainStage smoothStage = with(smooth);
|
||||||
|
check(sameBytes(frameOf(smoothStage, 0), frameOf(smoothStage, 8)),
|
||||||
|
name + " loops: the frame after the last is the first again");
|
||||||
|
check(sameBytes(frameOf(with(off), 5), input[0]),
|
||||||
|
name + " switched off leaves its input alone");
|
||||||
|
effector::Rng sliverA(2);
|
||||||
|
effector::Rng sliverB(2);
|
||||||
|
check(renderChain({wildStage}, input, 1, 57, sliverA, {}, FrameTime{3, 8, 8}).height() == 57 &&
|
||||||
|
renderChain({wildStage}, input, 57, 1, sliverB, {}, FrameTime{3, 8, 8}).width() == 57,
|
||||||
|
name + " copes with a sliver of a canvas");
|
||||||
|
|
||||||
|
const std::vector<ChainStage> movingChain = {stage("tiles"), wildStage, stage("grain")};
|
||||||
|
std::vector<effector::Image> clip;
|
||||||
|
effector::renderFrames(movingChain, sources, 80, 48, 21, {}, video,
|
||||||
|
[&](int, effector::Image&& frame) {
|
||||||
|
clip.push_back(std::move(frame));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
bool exactFrames = clip.size() == 8;
|
||||||
|
for (const int index : {0, 5, 7}) {
|
||||||
|
effector::Rng rng(21);
|
||||||
|
exactFrames = exactFrames &&
|
||||||
|
sameBytes(effector::renderChain(movingChain, sources, 80, 48, rng, {},
|
||||||
|
FrameTime{index, 8, 8}),
|
||||||
|
clip[static_cast<std::size_t>(index)]);
|
||||||
|
}
|
||||||
|
check(exactFrames, "a " + name + " clip's frame is exactly that frame rendered on its own");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The project file: a video says so, a still says nothing about time.
|
||||||
|
effector::Project project;
|
||||||
|
project.stages = {stage("tracking", wild)};
|
||||||
|
const auto stillJson = effector::projectToJson(project);
|
||||||
|
check(stillJson.find("\"video\"") == std::string::npos, "a still project writes no video");
|
||||||
|
check(!effector::projectFromJson(stillJson).video.enabled, "and reads back as a still");
|
||||||
|
project.video.enabled = true;
|
||||||
|
project.video.duration = 7.5;
|
||||||
|
project.video.fps = 24;
|
||||||
|
project.video.preset = "instagram-story";
|
||||||
|
const auto back = effector::projectFromJson(effector::projectToJson(project));
|
||||||
|
check(back.video.enabled && back.video.duration == 7.5 && back.video.fps == 24 &&
|
||||||
|
back.video.preset == "instagram-story",
|
||||||
|
"a video project keeps its length, rate and preset");
|
||||||
|
const auto motion = back.stages.at(0).values;
|
||||||
|
check(effector::paramInt(motion, "tracking_jitter", 0) == 30 &&
|
||||||
|
effector::paramInt(motion, "tracking_bar", 0) == 60,
|
||||||
|
"and its stages keep their motion");
|
||||||
|
const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": ";
|
||||||
|
check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(),
|
||||||
|
"an unknown preset reads as custom");
|
||||||
|
check(effector::projectToJson(project).find("\"quality\"") == std::string::npos,
|
||||||
|
"the standard quality is not written");
|
||||||
|
project.video.quality = "small";
|
||||||
|
check(effector::projectFromJson(effector::projectToJson(project)).video.quality == "small",
|
||||||
|
"another quality is kept");
|
||||||
|
check(effector::projectFromJson(base + "2, \"quality\": \"lossless\"}}").video.quality.empty(),
|
||||||
|
"an unknown quality reads as the standard one");
|
||||||
|
check(effector::videoQualityOrDefault("").key == effector::kDefaultVideoQuality &&
|
||||||
|
effector::videoQualityByKey(effector::kDefaultVideoQuality) != nullptr,
|
||||||
|
"an empty quality is the standard one, which the table holds");
|
||||||
|
bool rejected = false;
|
||||||
|
try {
|
||||||
|
effector::projectFromJson(base + "2, \"fps\": 500}}");
|
||||||
|
} catch (const effector::EffectError&) {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
check(rejected, "a frame rate out of range is refused");
|
||||||
|
|
||||||
|
// The command line.
|
||||||
|
const auto preset = parsedOptions({"--video-preset", "instagram-story", "-o", "x.mp4"});
|
||||||
|
check(preset && preset->video.enabled && preset->width == 1080 && preset->height == 1920 &&
|
||||||
|
preset->video.duration == 15.0 && preset->video.fps == 30 &&
|
||||||
|
preset->video.preset == "instagram-story",
|
||||||
|
"--video-preset sets the canvas, length and rate");
|
||||||
|
const auto shorter = parsedOptions({"--duration", "5", "--video-preset", "instagram-story"});
|
||||||
|
check(shorter && shorter->video.duration == 5.0 && shorter->height == 1920 &&
|
||||||
|
shorter->video.preset.empty(),
|
||||||
|
"a length given alongside wins and leaves the preset custom");
|
||||||
|
const auto sized = parsedOptions({"--video-preset", "hd-clip", "-s", "320x240"});
|
||||||
|
check(sized && sized->width == 320 && sized->format.empty(),
|
||||||
|
"a size given alongside wins over the preset's canvas");
|
||||||
|
check(!parsedOptions({"--fps", "0"}) && !parsedOptions({"--duration", "abc"}) &&
|
||||||
|
!parsedOptions({"--duration", "400"}) && !parsedOptions({"--video-preset", "vhs"}),
|
||||||
|
"a bad rate, length or preset is refused");
|
||||||
|
const auto quality = parsedOptions({"--duration", "2", "--video-quality", "high"});
|
||||||
|
check(quality && quality->video.quality == "high", "--video-quality picks the encoding");
|
||||||
|
check(!parsedOptions({"--video-quality", "best"}), "and refuses one it does not know");
|
||||||
|
check(!parsedOptions({"-o", "x.png"})->video.enabled, "without them the output is a still");
|
||||||
|
|
||||||
|
bool wrongName = false;
|
||||||
|
try {
|
||||||
|
effector::VideoWriter writer("clip.png", 16, 16, 30);
|
||||||
|
} catch (const effector::EffectError&) {
|
||||||
|
wrongName = true;
|
||||||
|
}
|
||||||
|
check(wrongName, "a video is not written under an image's name");
|
||||||
|
|
||||||
|
if (effector::ffmpegAvailable()) {
|
||||||
|
const auto dir = std::filesystem::temp_directory_path() / "effector-video-test";
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
const auto file = (dir / "clip.mp4").string();
|
||||||
|
{
|
||||||
|
// Odd sides: the writer pads them for the encoder.
|
||||||
|
effector::VideoWriter writer(file, 17, 9, 12);
|
||||||
|
for (int i = 0; i < 3; ++i) writer.write(gradient(17, 9, 40 * i));
|
||||||
|
writer.finish();
|
||||||
|
}
|
||||||
|
check(std::filesystem::exists(file) && std::filesystem::file_size(file) > 0,
|
||||||
|
"ffmpeg writes an .mp4 of odd size");
|
||||||
|
// Every quality, through every codec.
|
||||||
|
bool everyQuality = true;
|
||||||
|
for (const auto& quality : effector::videoQualities()) {
|
||||||
|
for (const char* extension : {".mp4", ".webm", ".gif"}) {
|
||||||
|
const auto encoded = (dir / (quality.key + extension)).string();
|
||||||
|
effector::VideoWriter writer(encoded, 16, 16, 12, quality.key);
|
||||||
|
for (int i = 0; i < 2; ++i) writer.write(gradient(16, 16, 60 * i));
|
||||||
|
writer.finish();
|
||||||
|
everyQuality = everyQuality && std::filesystem::file_size(encoded) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(everyQuality, "every quality writes .mp4, .webm and .gif");
|
||||||
|
const auto dropped = (dir / "dropped.webm").string();
|
||||||
|
{
|
||||||
|
effector::VideoWriter writer(dropped, 16, 16, 12);
|
||||||
|
writer.write(gradient(16, 16, 10));
|
||||||
|
writer.cancel();
|
||||||
|
}
|
||||||
|
check(!std::filesystem::exists(dropped), "a cancelled video leaves no file behind");
|
||||||
|
bool wrongSize = false;
|
||||||
|
try {
|
||||||
|
effector::VideoWriter writer((dir / "sized.mp4").string(), 16, 16, 12);
|
||||||
|
writer.write(gradient(8, 8, 10));
|
||||||
|
} catch (const effector::EffectError&) {
|
||||||
|
wrongSize = true;
|
||||||
|
}
|
||||||
|
check(wrongSize && !std::filesystem::exists(dir / "sized.mp4"),
|
||||||
|
"a frame of the wrong size is refused and the file dropped");
|
||||||
|
std::filesystem::remove_all(dir);
|
||||||
|
} else {
|
||||||
|
std::cout << " skip writing a video (ffmpeg is not installed)\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
std::cout << "version\n";
|
std::cout << "version\n";
|
||||||
{
|
{
|
||||||
// A release is the version in CMakeLists.txt and its entry at the top
|
// A release is the version in CMakeLists.txt and its entry at the top
|
||||||
|
|||||||
+412
-8
@@ -7,6 +7,7 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <iomanip>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -14,6 +15,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 +110,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 +162,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 +209,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 +231,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 +342,19 @@ void runTests(Gtk::Window& window) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::cout << "many stages\n";
|
std::cout << "many stages\n";
|
||||||
for (int i = 0; i < 4; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
addFromMenu(*add, imageEffects[static_cast<std::size_t>(i) % imageEffects.size()]->title());
|
||||||
|
}
|
||||||
pump();
|
pump();
|
||||||
const int fewSplit = split != nullptr ? split->get_position() : -1;
|
const int fewSplit = split != nullptr ? split->get_position() : -1;
|
||||||
check(split != nullptr && fewSplit > emptySplit,
|
check(split != nullptr && fewSplit > emptySplit,
|
||||||
"the split moves down as the chain grows");
|
"the split moves down as the chain grows");
|
||||||
for (int i = 4; i < 12; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
|
for (int i = 4; i < 12; ++i) {
|
||||||
|
addFromMenu(*add, imageEffects[static_cast<std::size_t>(i) % imageEffects.size()]->title());
|
||||||
|
}
|
||||||
check(rowCount(*chainList) == 13, "the menu still works with a long chain");
|
check(rowCount(*chainList) == 13, "the menu still works with a long chain");
|
||||||
check(addFromMenu(*add, effects.back()->title()) && rowCount(*chainList) == 14,
|
check(addFromMenu(*add, imageEffects.back()->title()) && rowCount(*chainList) == 14,
|
||||||
"including for the last effect in the menu");
|
"including for the last image effect in the menu");
|
||||||
pump();
|
pump();
|
||||||
const int longSplit = split != nullptr ? split->get_position() : -1;
|
const int longSplit = split != nullptr ? split->get_position() : -1;
|
||||||
check(longSplit > fewSplit && listScroller != nullptr &&
|
check(longSplit > fewSplit && listScroller != nullptr &&
|
||||||
@@ -440,6 +482,363 @@ void testRecentProjects(Gtk::Window& window, const std::filesystem::path& dir) {
|
|||||||
"opened projects are listed newest first, a shared name with its folder");
|
"opened projects are listed newest first, a shared name with its folder");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visible labels in the params grid saying `text`.
|
||||||
|
bool showsLabel(Gtk::Widget& root, const std::string& text) {
|
||||||
|
for (auto* label : all<Gtk::Label>(root)) {
|
||||||
|
if (label->get_text() == text && label->is_drawable()) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||||
|
std::cout << "video\n";
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
const auto path = dir / "moving.json";
|
||||||
|
effector::Project project;
|
||||||
|
// Neither needs sources: an image effect, then the video effect.
|
||||||
|
project.stages.push_back({effector::effectByName("channelsplit"), {}});
|
||||||
|
project.stages.push_back({effector::effectByName("tracking"), {}});
|
||||||
|
effector::saveProject(path.string(), project);
|
||||||
|
window.activate_action("win.open-recent",
|
||||||
|
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||||
|
pump(300);
|
||||||
|
|
||||||
|
auto* stack = all<Gtk::Stack>(window).front();
|
||||||
|
auto* toggle = named<Gtk::Switch>(window, "video");
|
||||||
|
auto* preset = named<Gtk::DropDown>(window, "video-preset");
|
||||||
|
auto* duration = named<Gtk::SpinButton>(window, "video-duration");
|
||||||
|
auto* width = named<Gtk::SpinButton>(window, "canvas-width");
|
||||||
|
auto* height = named<Gtk::SpinButton>(window, "canvas-height");
|
||||||
|
auto* playback = named<Gtk::Box>(window, "playback");
|
||||||
|
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
|
||||||
|
auto* add = named<Gtk::MenuButton>(window, "add-effect");
|
||||||
|
check(toggle != nullptr && preset != nullptr && duration != nullptr && width != nullptr &&
|
||||||
|
height != nullptr && playback != nullptr,
|
||||||
|
"the video switch, its settings and the playback bar are there");
|
||||||
|
if (toggle == nullptr || preset == nullptr || duration == nullptr || width == nullptr ||
|
||||||
|
height == nullptr || playback == nullptr || chainList == nullptr || add == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::string tracking = effector::effectByName("tracking")->title();
|
||||||
|
auto* entry = menuEntry(*add->get_popover(), tracking);
|
||||||
|
// The label of a chain row, which says whether the stage runs.
|
||||||
|
auto rowLabel = [&](int index) -> Gtk::Label* {
|
||||||
|
auto* row = chainList->get_row_at_index(index);
|
||||||
|
if (row == nullptr) return nullptr;
|
||||||
|
for (auto* label : all<Gtk::Label>(*row)) {
|
||||||
|
if (label->get_text().substr(0, 3) == std::to_string(index + 1) + ". ") return label;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
};
|
||||||
|
auto* videoRow = chainList->get_row_at_index(1);
|
||||||
|
auto* videoLabel = rowLabel(1);
|
||||||
|
|
||||||
|
check(isAncestor(*stack->get_child_by_name("setup"), *toggle), "the video switch is on Setup");
|
||||||
|
check(!toggle->get_active() && !preset->is_drawable() && !playback->get_visible(),
|
||||||
|
"a still project shows no video settings and no playback");
|
||||||
|
check(videoRow != nullptr && !all<Gtk::Image>(*videoRow).empty() && videoLabel != nullptr &&
|
||||||
|
videoLabel->has_css_class("dim-label") &&
|
||||||
|
videoLabel->get_tooltip_text() == "A video effect: skipped in a still picture",
|
||||||
|
"a video stage is marked as one, and dimmed as skipped in a still");
|
||||||
|
check(!rowLabel(0)->has_css_class("dim-label"), "while the image stage before it runs");
|
||||||
|
check(entry != nullptr && !entry->get_sensitive(), "the video effect cannot be added to a still");
|
||||||
|
|
||||||
|
toggle->set_active(true);
|
||||||
|
pump(200);
|
||||||
|
videoLabel = rowLabel(1);
|
||||||
|
check(videoLabel != nullptr && !videoLabel->has_css_class("dim-label"),
|
||||||
|
"in a video the video stage runs");
|
||||||
|
check(entry != nullptr && entry->get_sensitive(), "and more can be added");
|
||||||
|
chainList->select_row(*chainList->get_row_at_index(1));
|
||||||
|
pump(100);
|
||||||
|
check(showsLabel(window, "Wobble") && showsLabel(window, "Tracking bar"),
|
||||||
|
"its settings are its motion");
|
||||||
|
stack->set_visible_child("setup");
|
||||||
|
pump(100);
|
||||||
|
check(preset->is_drawable() && duration->is_drawable(), "and its preset and length");
|
||||||
|
auto* quality = named<Gtk::DropDown>(window, "video-quality");
|
||||||
|
check(quality != nullptr && quality->is_drawable() &&
|
||||||
|
effector::videoQualities().at(quality->get_selected()).key ==
|
||||||
|
effector::kDefaultVideoQuality,
|
||||||
|
"and its file quality, standard to begin with");
|
||||||
|
|
||||||
|
preset->set_selected(1); // Instagram story, the first preset
|
||||||
|
pump(200);
|
||||||
|
check(width->get_value() == 1080 && height->get_value() == 1920 && duration->get_value() == 15,
|
||||||
|
"Instagram story picks a 1080 × 1920 canvas and 15 s");
|
||||||
|
duration->set_value(0.5);
|
||||||
|
pump(100);
|
||||||
|
check(preset->get_selected() == 0, "changing the length drops the preset to Custom");
|
||||||
|
|
||||||
|
for (int waited = 0; waited < 200 && !playback->get_visible(); ++waited) pump(50);
|
||||||
|
check(playback->get_visible(), "the clip is rendered and plays in the preview");
|
||||||
|
auto* scale = all<Gtk::Scale>(*playback).empty() ? nullptr : all<Gtk::Scale>(*playback).front();
|
||||||
|
const double before = scale != nullptr ? scale->get_value() : -1.0;
|
||||||
|
pump(250);
|
||||||
|
check(scale != nullptr && scale->get_value() != before, "and it moves on by itself");
|
||||||
|
|
||||||
|
window.activate_action("win.save-project");
|
||||||
|
pump(100);
|
||||||
|
const auto saved = effector::loadProject(path.string());
|
||||||
|
check(saved.video.enabled && saved.video.duration == 0.5 && saved.width == 1080,
|
||||||
|
"the project is saved as a video");
|
||||||
|
|
||||||
|
toggle->set_active(false);
|
||||||
|
pump(300);
|
||||||
|
videoLabel = rowLabel(1);
|
||||||
|
check(!playback->get_visible() && videoLabel != nullptr &&
|
||||||
|
videoLabel->has_css_class("dim-label") && entry != nullptr && !entry->get_sensitive(),
|
||||||
|
"switched back to a still, the playback goes and the video stage is skipped again");
|
||||||
|
stack->set_visible_child("chain");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A font param is a font chooser showing the family, and the family picked
|
||||||
|
// there is what the stage renders with and what the project saves.
|
||||||
|
void testFont(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||||
|
std::cout << "font\n";
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
const auto path = dir / "lettering.json";
|
||||||
|
effector::Project project;
|
||||||
|
effector::ChainStage stage{effector::effectByName("text"), {}};
|
||||||
|
stage.values["font_family"] = std::string("Serif");
|
||||||
|
project.stages.push_back(stage);
|
||||||
|
effector::saveProject(path.string(), project);
|
||||||
|
window.activate_action("win.open-recent",
|
||||||
|
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||||
|
pump(300);
|
||||||
|
|
||||||
|
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
|
||||||
|
if (chainList != nullptr && chainList->get_row_at_index(0) != nullptr) {
|
||||||
|
chainList->select_row(*chainList->get_row_at_index(0));
|
||||||
|
}
|
||||||
|
pump(100);
|
||||||
|
std::vector<Gtk::FontDialogButton*> buttons;
|
||||||
|
collect<Gtk::FontDialogButton>(window, [](Gtk::FontDialogButton& b) { return b.is_drawable(); },
|
||||||
|
buttons);
|
||||||
|
check(buttons.size() == 1, "the text stage picks its font with a font chooser");
|
||||||
|
if (buttons.size() != 1) return;
|
||||||
|
auto* button = buttons.front();
|
||||||
|
check(button->get_level() == Gtk::FontLevel::FAMILY,
|
||||||
|
"which chooses only the family, the weight and size being params of their own");
|
||||||
|
check(button->get_font_desc().get_family() == "Serif", "and shows the family saved");
|
||||||
|
|
||||||
|
Pango::FontDescription mono;
|
||||||
|
mono.set_family("Monospace");
|
||||||
|
button->set_font_desc(mono);
|
||||||
|
pump(100);
|
||||||
|
window.activate_action("win.save-project");
|
||||||
|
pump(100);
|
||||||
|
const auto saved = effector::loadProject(path.string());
|
||||||
|
check(saved.stages.size() == 1 &&
|
||||||
|
effector::paramText(saved.stages[0].values, "font_family", "") == "Monospace",
|
||||||
|
"a family chosen there is saved with the project");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testSlider(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||||
|
std::cout << "slider\n";
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
const auto path = dir / "swapping.json";
|
||||||
|
effector::Project project;
|
||||||
|
project.video.enabled = true;
|
||||||
|
effector::ChainStage stage{effector::effectByName("tileswap"), {}};
|
||||||
|
stage.values["tileswap_rate"] = 0.5;
|
||||||
|
project.stages.push_back(stage);
|
||||||
|
effector::saveProject(path.string(), project);
|
||||||
|
window.activate_action("win.open-recent",
|
||||||
|
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||||
|
pump(300);
|
||||||
|
|
||||||
|
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
|
||||||
|
if (chainList != nullptr && chainList->get_row_at_index(0) != nullptr) {
|
||||||
|
chainList->select_row(*chainList->get_row_at_index(0));
|
||||||
|
}
|
||||||
|
pump(100);
|
||||||
|
std::vector<Gtk::Scale*> scales;
|
||||||
|
collect<Gtk::Scale>(window, [](Gtk::Scale& s) { return s.is_drawable(); }, scales);
|
||||||
|
check(scales.size() == 1, "a param marked as a slider is shown as one");
|
||||||
|
if (scales.size() != 1) return;
|
||||||
|
auto* scale = scales.front();
|
||||||
|
const auto& spec = effector::effectByName("tileswap")->params().back();
|
||||||
|
// Written the way the locale writes numbers, as the interface does.
|
||||||
|
const auto mark = [](double at) {
|
||||||
|
return std::string(Glib::ustring::format(std::fixed, std::setprecision(2), at));
|
||||||
|
};
|
||||||
|
check(showsText(*scale, mark(0.0)) && showsText(*scale, mark(5.0)),
|
||||||
|
"with its range marked at both ends");
|
||||||
|
check(spec.sliderValue(scale->get_value()) == 0.5, "and shows the value saved");
|
||||||
|
check(scale->get_value() > 0.5 / 5.0 + 0.3,
|
||||||
|
"a curved slider gives the low end of its range more of its travel");
|
||||||
|
|
||||||
|
scale->set_value(spec.sliderPosition(0.05));
|
||||||
|
pump(100);
|
||||||
|
window.activate_action("win.save-project");
|
||||||
|
pump(100);
|
||||||
|
const auto saved = effector::loadProject(path.string());
|
||||||
|
check(saved.stages.size() == 1 &&
|
||||||
|
std::abs(effector::paramDouble(saved.stages[0].values, "tileswap_rate", 0.0) - 0.05) < 1e-9,
|
||||||
|
"a value slid to is saved with the project");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool actionEnabled(Gtk::Window& window, const std::string& name) {
|
||||||
|
auto action = dynamic_cast<Gtk::ApplicationWindow&>(window).lookup_action(name);
|
||||||
|
return action && action->get_enabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the window catches `keys` for `action` before the focused widget.
|
||||||
|
bool capturesShortcut(Gtk::Window& window, const std::string& keys, const std::string& action) {
|
||||||
|
auto controllers = window.observe_controllers();
|
||||||
|
for (guint i = 0; i < controllers->get_n_items(); ++i) {
|
||||||
|
auto* controller = GTK_EVENT_CONTROLLER(g_list_model_get_item(controllers->gobj(), i));
|
||||||
|
const bool capture = gtk_event_controller_get_propagation_phase(controller) == GTK_PHASE_CAPTURE;
|
||||||
|
if (GTK_IS_SHORTCUT_CONTROLLER(controller) && capture) {
|
||||||
|
auto* list = G_LIST_MODEL(controller);
|
||||||
|
for (guint j = 0; j < g_list_model_get_n_items(list); ++j) {
|
||||||
|
auto* shortcut = GTK_SHORTCUT(g_list_model_get_item(list, j));
|
||||||
|
char* trigger = gtk_shortcut_trigger_to_string(gtk_shortcut_get_trigger(shortcut));
|
||||||
|
auto* named = gtk_shortcut_get_action(shortcut);
|
||||||
|
const bool match = trigger == keys && GTK_IS_NAMED_ACTION(named) &&
|
||||||
|
action == gtk_named_action_get_action_name(GTK_NAMED_ACTION(named));
|
||||||
|
g_free(trigger);
|
||||||
|
g_object_unref(shortcut);
|
||||||
|
if (match) {
|
||||||
|
g_object_unref(controller);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g_object_unref(controller);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void testHistory(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||||
|
std::cout << "undo and redo\n";
|
||||||
|
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
|
||||||
|
auto* add = named<Gtk::MenuButton>(window, "add-effect");
|
||||||
|
if (chainList == nullptr || add == nullptr) return;
|
||||||
|
|
||||||
|
check(capturesShortcut(window, "<Control>z", "win.undo") &&
|
||||||
|
capturesShortcut(window, "<Control>y", "win.redo") &&
|
||||||
|
capturesShortcut(window, "<Shift><Control>z", "win.redo"),
|
||||||
|
"Ctrl+Z undoes, Ctrl+Y and Ctrl+Shift+Z redo, ahead of the focused widget");
|
||||||
|
|
||||||
|
window.activate_action("win.new-project");
|
||||||
|
pump(700);
|
||||||
|
check(!actionEnabled(window, "undo") && !actionEnabled(window, "redo"),
|
||||||
|
"a new project has nothing to undo or redo");
|
||||||
|
|
||||||
|
addFromMenu(*add, "Blur");
|
||||||
|
pump(700);
|
||||||
|
addFromMenu(*add, "Scanlines");
|
||||||
|
pump(700);
|
||||||
|
check(rowCount(*chainList) == 2 && actionEnabled(window, "undo"), "two stages added");
|
||||||
|
|
||||||
|
window.activate_action("win.undo");
|
||||||
|
pump();
|
||||||
|
check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur",
|
||||||
|
"undo takes the last stage off");
|
||||||
|
check(actionEnabled(window, "redo"), "and it can be redone");
|
||||||
|
window.activate_action("win.undo");
|
||||||
|
pump();
|
||||||
|
check(rowCount(*chainList) == 0 && !actionEnabled(window, "undo"), "undo again empties the chain");
|
||||||
|
|
||||||
|
window.activate_action("win.redo");
|
||||||
|
pump();
|
||||||
|
window.activate_action("win.redo");
|
||||||
|
pump();
|
||||||
|
check(rowCount(*chainList) == 2 && rowTitle(*chainList, 1) == "2. Scanlines" &&
|
||||||
|
!actionEnabled(window, "redo"),
|
||||||
|
"redo puts both back");
|
||||||
|
|
||||||
|
// Undone straight away, before the edit has settled into a step.
|
||||||
|
addFromMenu(*add, "Blur");
|
||||||
|
check(actionEnabled(window, "undo"), "an edit can be undone at once");
|
||||||
|
window.activate_action("win.undo");
|
||||||
|
pump();
|
||||||
|
check(rowCount(*chainList) == 2, "and undoing it takes that edit back");
|
||||||
|
|
||||||
|
window.activate_action("win.undo");
|
||||||
|
pump();
|
||||||
|
addFromMenu(*add, "Blur");
|
||||||
|
pump(700);
|
||||||
|
check(!actionEnabled(window, "redo"), "a new edit after an undo drops what could be redone");
|
||||||
|
|
||||||
|
// The images of an opened project arrive after it, from the decoder, and
|
||||||
|
// are part of it rather than a step to undo.
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
const auto source = dir / "source.png";
|
||||||
|
effector::Image(64, 64).save(source.string());
|
||||||
|
effector::Project project;
|
||||||
|
project.sources.push_back(source.string());
|
||||||
|
project.stages.push_back({effector::effectByName("scanlines"), {}});
|
||||||
|
const auto path = dir / "history.json";
|
||||||
|
effector::saveProject(path.string(), project);
|
||||||
|
window.activate_action("win.open-recent",
|
||||||
|
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||||
|
pump(1000);
|
||||||
|
auto* sources = named<Gtk::Box>(window, "sources");
|
||||||
|
auto lists = sources != nullptr ? all<Gtk::ListBox>(*sources) : std::vector<Gtk::ListBox*>{};
|
||||||
|
auto* sourceList = lists.empty() ? nullptr : lists.front();
|
||||||
|
check(sourceList != nullptr && rowCount(*sourceList) == 1 && !actionEnabled(window, "undo"),
|
||||||
|
"opening a project with images leaves nothing to undo");
|
||||||
|
if (sourceList == nullptr || rowCount(*sourceList) != 1) return;
|
||||||
|
|
||||||
|
// Removing an image is undone with the image still in hand.
|
||||||
|
auto* stack = all<Gtk::Stack>(window).front();
|
||||||
|
stack->set_visible_child("setup");
|
||||||
|
pump();
|
||||||
|
const auto removes = all<Gtk::Button>(*sourceList->get_row_at_index(0));
|
||||||
|
if (!removes.empty()) press(*removes.front());
|
||||||
|
pump(700);
|
||||||
|
check(rowCount(*sourceList) == 0, "an image removed");
|
||||||
|
window.activate_action("win.undo");
|
||||||
|
pump();
|
||||||
|
check(rowCount(*sourceList) == 1, "comes back with undo");
|
||||||
|
stack->set_visible_child("chain");
|
||||||
|
|
||||||
|
window.activate_action("win.new-project");
|
||||||
|
pump(700);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project whose images will not fit is refused before any is decoded, and
|
||||||
|
// saving it then must not quietly drop them from the file.
|
||||||
|
void testMemoryGuard(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||||
|
std::cout << "memory guard\n";
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
effector::Project project;
|
||||||
|
for (int i = 0; i < 2; ++i) {
|
||||||
|
const auto source = dir / ("big" + std::to_string(i) + ".png");
|
||||||
|
effector::Image(600, 600).save(source.string());
|
||||||
|
project.sources.push_back(source.string());
|
||||||
|
}
|
||||||
|
project.stages.push_back({effector::effectByName("scanlines"), {}});
|
||||||
|
const auto path = dir / "big.json";
|
||||||
|
effector::saveProject(path.string(), project);
|
||||||
|
|
||||||
|
effector::limitAvailableMemory(std::size_t{1} << 20); // 2.1 MB of images, 1 MB free
|
||||||
|
window.activate_action("win.open-recent",
|
||||||
|
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||||
|
pump(500);
|
||||||
|
auto* sources = named<Gtk::Widget>(window, "sources");
|
||||||
|
auto lists = sources != nullptr ? all<Gtk::ListBox>(*sources) : std::vector<Gtk::ListBox*>{};
|
||||||
|
check(!lists.empty() && rowCount(*lists.front()) == 0, "images too large for the memory are not loaded");
|
||||||
|
bool told = false;
|
||||||
|
for (auto* label : all<Gtk::Label>(window)) {
|
||||||
|
const std::string text = label->get_text();
|
||||||
|
if (label->is_drawable() && text.find("Loading these 2 images needs about") != std::string::npos &&
|
||||||
|
text.find("stay in the project") != std::string::npos) {
|
||||||
|
told = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(told, "and the window says why");
|
||||||
|
|
||||||
|
window.activate_action("win.save-project");
|
||||||
|
pump(100);
|
||||||
|
check(effector::loadProject(path.string()).sources.size() == 2,
|
||||||
|
"saving the project keeps the images it could not load");
|
||||||
|
effector::limitAvailableMemory(0);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
@@ -467,6 +866,11 @@ int main(int argc, char** argv) {
|
|||||||
runTests(*window);
|
runTests(*window);
|
||||||
testHelpWindows(*window);
|
testHelpWindows(*window);
|
||||||
testRecentProjects(*window, dataDir / "projects");
|
testRecentProjects(*window, dataDir / "projects");
|
||||||
|
testVideo(*window, dataDir / "video");
|
||||||
|
testFont(*window, dataDir / "font");
|
||||||
|
testSlider(*window, dataDir / "slider");
|
||||||
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user