Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: tests

on:
pull_request:
push:
branches: [main, 'feat/**']

permissions:
contents: read

jobs:
regression:
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: install ffmpeg and optional processors
run: |
sudo apt-get update
sudo apt-get install -y ffmpeg
python -m pip install -r requirements-collapse.txt -r requirements-inversion.txt ruff
- name: check changed processor code
run: ruff check --select F frame_pipeline.py timestamped_frames.py stateful_effects.py tests/inversion_regression.py
- name: run every suite with optional dependencies present
run: python tests/run_tests.py
72 changes: 71 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ python3 -m pip install -r requirements-collapse.txt
These packages are optional and loaded only when rendering stateful effects.
Ordinary FFmpeg effects and `--list` still work without them.

Causality inversion uses NumPy and PyAV to carry RGB frames with their original
presentation timestamps. Install `requirements-inversion.txt`; its aligned mode
also needs `requirements-collapse.txt` for CPU optical flow. Timestamped export
uses FFmpeg's `-enc_time_base demux` option (tested with FFmpeg 7.1).

## commands

```sh
Expand All @@ -50,6 +55,7 @@ Ordinary FFmpeg effects and `--list` still work without them.
./effex.py sediment+deepfry input/clip.mp4 --set sediment.decay=30 --set sediment.color=warm --set deepfry.strength=60
./effex.py collapse input/clip.mp4 --set intensity=80 --set seed=17
./effex.py collapse input/clip.mp4 --set mode=seam --set seam_strength=80
./effex.py inversion input/clip.mp4 --set lookahead=0.5 --set offset=0.3 --set region=bands
./effex.py datamosh input/a.mp4 --set second=input/b.mp4 --set cut=dice --set survival=50 --set seed=123
./effex.py stretch input/clip.mp4 --dry-run
```
Expand All @@ -64,7 +70,7 @@ Ordinary FFmpeg effects and `--list` still work without them.
- datamosh can join two videos in one codec stream. pick the second clip in the menu or pass `--set second=path`; set the trim, keyframe survival, seed, fill, grit, and audio.
- single-pass previews, render progress, ctrl-c cancellation, and cleanup of partial renders.

datamosh and timescratch use their own multi-pass pipelines, so they cannot be chained with other effects. Temporal sediment uses a bounded 128 MiB history buffer. Stateful effects resample input to the probed frame rate, do not preserve original variable-rate timestamps, and have no preview yet. Multiple stateful processors must be adjacent; FFmpeg filters can go before and after them.
datamosh and timescratch use their own multi-pass pipelines, so they cannot be chained with other effects. Temporal sediment uses a bounded 128 MiB history buffer. Without inversion, stateful chains still normalize input to the probed frame rate. Chains containing inversion use timestamped transport and preserve the video presentation timeline, including variable-rate input and nonzero start times. Stateful previews are not available. Multiple stateful processors must be adjacent; FFmpeg filters can go before and after them.

## channel lobotomy

Expand Down Expand Up @@ -168,6 +174,69 @@ averaged 6.68 ms at 360p and 12.87 ms at 720p. Persistent state was about
0.38 MiB for elastic and 0.53 MiB for seam mode in these cases. These are
measurements of this fixture on this host, not a promise for every input.

## causality inversion

`inversion` is under **TIME & MEMORY**. Selected regions borrow actual pixels
from a later decoded frame. It never predicts or fabricates future content.

| option | range / values | default |
| --- | --- | --- |
| `lookahead` | 0-5 seconds of buffer horizon | 0.5 |
| `offset` | 0-lookahead seconds into the future | 0.5 |
| `strength` | 0-100%, opacity or replacement coverage | 65% |
| `mode` | blend, replace, tear, align | blend |
| `region` | full, bands, blocks, patch | full |
| `mask_speed` | 0-4 mask/tear cycles per second | 0.25 |
| `alignment` | 0-100%, flow gain in align mode | 100% |
| `feather` | 0-100%, region edge softness | 10% |
| `seed` | 0-2147483647; 0 derives masks from source | 0 |

Blend mixes current and future RGB values. Replace uses strength as seeded
patch coverage, so half strength replaces some pixels rather than making all
pixels half-transparent. Tear shifts horizontal bands from the future image.
Align estimates reduced-resolution forward flow and samples the future image
at those coordinates to bring it toward the current scene. All modes can use
the same region masks and feathering; alignment only affects `mode=align`.

The buffer adds up to `lookahead` seconds of video processing delay, plus one
decoded frame used to determine packet duration. Output timestamps do not move.
Live input cannot provide genuine future pixels without that buffering; the
current CLI accepts files, not live streams. A future request uses the first
available frame at or after the target time. Near EOF, or before a detected
scene cut, it clamps to the latest available frame in that segment. The final
frame therefore stays unchanged. Zero strength or zero offset passes through
without keeping a look-ahead queue.

Each inversion queue is capped at 128 frames and 128 MiB of persistent state.
Transient RGB/float masks, alignment maps and encoder buffers are additional;
the cap is not a limit on total process RSS.
Impossible nominal-rate settings fail before decoding with a resize/lower-horizon
hint. An unusually dense VFR burst can fill the queue; its oldest frame then
uses the latest already-buffered future instead of exceeding the cap. Explicit
reset, rewind, frame discontinuity or a new clip clears pending state. Scene
cuts drain the old segment before starting the next, preventing future-scene
pixels from leaking backward across cuts.

The shared renderer keeps original rational video PTS through NUT RGB pipes,
including chains with sediment or collapse in either adjacent order. Unfiltered
AAC/MP3/AC3/EAC3/ALAC/Opus audio is stream-copied to avoid encoder priming shifts.
Other audio codecs or chains with audio filters are encoded to AAC; encoder
delay can apply. Temporal FFmpeg filters such as slowmo still deliberately
change timestamps. Masks/flow use seconds for analysis; exported PTS stays exact.

Tests cover early object appearance, mode differences, EOF/short/static clips,
scene boundaries, resets, bounded queues, caller ownership, VFR/nonzero-start
timestamps, copied audio packets, short pipe writes, cancellation and cleanup.
Run `python3 tests/run_tests.py inversion` for the focused suite.

`python3 tests/benchmark_inversion.py --frames 60 --output benchmarks/inversion.json`
records each mode at 640x360 and 1280x720 in fresh workers. With a 0.5-second
horizon at 30 fps, measured queue state was about 9.89 MiB at 360p and 39.55 MiB
at 720p. At 720p, processor-only averages including EOF drain were 11.93 ms
blend, 15.10 ms replace, 24.32 ms tear and 23.10 ms align. These exclude
FFmpeg decode/encode and are not end-to-end real-time claims. The JSON records
peak process RSS separately from bounded processor state.

## files and tests

`input/` holds source clips. `output/` holds renders. Git ignores files in both folders except `.gitkeep`, so clips and renders stay local. Both paths can be changed with `--input-dir` / `--output-dir` or `EFFEX_INPUT_DIR` / `EFFEX_OUTPUT_DIR`.
Expand All @@ -178,6 +247,7 @@ run the test suite with:
python3 tests/run_tests.py
python3 tests/run_tests.py psychedelic # pixel behavior, options and real renders
python3 tests/run_tests.py collapse seams # deformation, carving and shared pipeline
python3 tests/run_tests.py inversion # future pixels, original PTS and EOF fallback
```

tests use generated clips and temporary folders; they do not overwrite files in `input/` or `output/`.
20 changes: 16 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ purpose: pick one item per session, finish it end-to-end (code + test + docs),
then check it off with a done note saying what actually landed.
format: `[ ]` open, `[x]` done, done notes indented under the item.
state: input/ holds source videos, output/ holds renders and previews, effex.py
is the tool (v0.12.0, 23 effects, chains of two or more), tests/ has nine
is the tool (v0.12.0, 24 effects, chains of two or more), tests/ has ten
suites. verify before closing an item with:

python3 tests/run_tests.py # cli + datamosh + menu + layout + install + stateful + psychedelic + collapse + seams
python3 tests/run_tests.py # cli + datamosh + menu + layout + install + stateful + psychedelic + collapse + seams + inversion
python3 tests/make_clip.py # drop a lavfi test clip into input/
./install.sh # effex onto ~/.local/bin

Expand Down Expand Up @@ -350,7 +350,7 @@ decision.

### stateful glitch effects

Six effects from the requested design prompt. Temporal Sediment and Elastic Collapse are implemented; the remaining four are checklist items, not implementations.
Six effects from the requested design prompt. Temporal Sediment, Elastic Collapse and Causality Inversion are implemented; the remaining three are checklist items, not implementations.

Shared rules for the stateful effects:
- Trace how `effex.py` scans/probes clips, builds `Plan`s, invokes custom runners, and encodes output. The core stays FFmpeg-filter based; Temporal Sediment adds a narrow per-frame adapter for stateful effects.
Expand Down Expand Up @@ -404,11 +404,23 @@ Shared rules for the stateful effects:
- Reuse a shared flow field if the pipeline can safely provide one; otherwise estimate dense flow from previous/current grayscale frames at a configurable analysis resolution, starting with Farneback flow if OpenCV is accepted. Smooth and clamp vectors, apply a tunable gain, and remap the current frame. Verify the displacement sign with a synthetic moving shape before tuning the look.
- Options: flow gain, nonlinear gain, motion threshold, analysis resolution, smoothing, max displacement, optional trail amount/decay, seed for randomized modulation.
- Handle the first frame without history. Test direction/sign, stronger displacement at higher gain, relative stability on static footage, valid shape/dtype, and CPU cost.
- [ ] E2.49 - causality inversion (prompt effect 20): let selected regions borrow pixels from later video frames.
- [x] E2.49 - causality inversion (prompt effect 20): let selected regions borrow pixels from later video frames.
- Use a bounded look-ahead buffer of frames and timestamps. Start with future-frame blend and replacement; add region tearing or motion alignment as separate modes. Preserve output timestamps and dimensions.
- Document the look-ahead delay. Live input cannot provide genuine future frames without buffering or prediction. Near clip end, reduce the blend or use the latest available frame.
- Options: look-ahead seconds, blend/replacement strength, offset, region/mask mode, mask speed, motion alignment, edge feather, seed.
- Test that a moving synthetic object appears early by the configured offset, blend and replacement differ, unavailable future frames take the documented fallback, and memory/timestamps remain bounded and valid.
_Done: `inversion` adds bounded look-ahead with separate blend, replacement,
band-tearing and CPU-flow-aligned modes. Region masks, speed, feathering,
strength, horizon/offset, alignment and seed are configurable. Queues cap at
128 frames/128 MiB, reject impossible nominal settings and clamp dense VFR
bursts/EOF/cuts to available future content. Streaming stages retain rational
PTS and dimensions through optional PyAV/NUT transport inside the existing
FFmpeg render lifecycle; compatible unfiltered audio is copied. Tests verify
early moving objects, distinct modes, ownership, resets/cuts, budget bounds,
EOF, exact VFR/nonzero-start PTS, audio packet timing, stage ordering, short
writes, failure/cancel cleanup and the menu. Separate 360p/720p benchmarks for
all four modes are saved in benchmarks/inversion.json. README documents
buffering latency, live-input limits, fallback and optional dependencies._

---

Expand Down
143 changes: 143 additions & 0 deletions benchmarks/inversion.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
{
"recorded_utc": "2026-10-24T10:56:21.541926+00:00",
"platform": "Linux-7.1.13+parrot7-amd64-x86_64-with-glibc2.41",
"scope": "processor only, fresh worker per mode/size; includes EOF drain, excludes decode/encode",
"results": [
{
"mode": "blend",
"width": 640,
"height": 360,
"frames": 60,
"mean_ms_including_eof": 3.0951608495039786,
"push_p95_ms": 5.521897197468206,
"eof_drain_ms": 30.323145001602825,
"peak_buffer_mib": 9.890716552734375,
"peak_process_rss_mib": 74.19921875,
"lookahead_seconds": 0.5,
"fps": 30,
"opencv_threads": 1,
"python": "3.14.0",
"numpy": "2.5.4",
"opencv": "5.0.0"
},
{
"mode": "blend",
"width": 1280,
"height": 720,
"frames": 60,
"mean_ms_including_eof": 11.926763082859301,
"push_p95_ms": 12.431164700319641,
"eof_drain_ms": 163.31714000261854,
"peak_buffer_mib": 39.553802490234375,
"peak_process_rss_mib": 144.90234375,
"lookahead_seconds": 0.5,
"fps": 30,
"opencv_threads": 1,
"python": "3.14.0",
"numpy": "2.5.4",
"opencv": "5.0.0"
},
{
"mode": "replace",
"width": 640,
"height": 360,
"frames": 60,
"mean_ms_including_eof": 3.4171822838591956,
"push_p95_ms": 4.974954102362972,
"eof_drain_ms": 40.226283999800216,
"peak_buffer_mib": 9.891693115234375,
"peak_process_rss_mib": 78.8984375,
"lookahead_seconds": 0.5,
"fps": 30,
"opencv_threads": 1,
"python": "3.14.0",
"numpy": "2.5.4",
"opencv": "5.0.0"
},
{
"mode": "replace",
"width": 1280,
"height": 720,
"frames": 60,
"mean_ms_including_eof": 15.104414433032312,
"push_p95_ms": 18.992216050901334,
"eof_drain_ms": 198.32324899471132,
"peak_buffer_mib": 39.554779052734375,
"peak_process_rss_mib": 149.8671875,
"lookahead_seconds": 0.5,
"fps": 30,
"opencv_threads": 1,
"python": "3.14.0",
"numpy": "2.5.4",
"opencv": "5.0.0"
},
{
"mode": "tear",
"width": 640,
"height": 360,
"frames": 60,
"mean_ms_including_eof": 6.345966033162161,
"push_p95_ms": 9.825213200383587,
"eof_drain_ms": 78.20343400089769,
"peak_buffer_mib": 9.890716552734375,
"peak_process_rss_mib": 74.515625,
"lookahead_seconds": 0.5,
"fps": 30,
"opencv_threads": 1,
"python": "3.14.0",
"numpy": "2.5.4",
"opencv": "5.0.0"
},
{
"mode": "tear",
"width": 1280,
"height": 720,
"frames": 60,
"mean_ms_including_eof": 24.321544300498015,
"push_p95_ms": 29.347579599198067,
"eof_drain_ms": 317.7273420005804,
"peak_buffer_mib": 39.553802490234375,
"peak_process_rss_mib": 147.859375,
"lookahead_seconds": 0.5,
"fps": 30,
"opencv_threads": 1,
"python": "3.14.0",
"numpy": "2.5.4",
"opencv": "5.0.0"
},
{
"mode": "align",
"width": 640,
"height": 360,
"frames": 60,
"mean_ms_including_eof": 8.36438881669892,
"push_p95_ms": 13.55484554587747,
"eof_drain_ms": 111.04231100034667,
"peak_buffer_mib": 9.890716552734375,
"peak_process_rss_mib": 80.078125,
"lookahead_seconds": 0.5,
"fps": 30,
"opencv_threads": 1,
"python": "3.14.0",
"numpy": "2.5.4",
"opencv": "5.0.0"
},
{
"mode": "align",
"width": 1280,
"height": 720,
"frames": 60,
"mean_ms_including_eof": 23.098397633293644,
"push_p95_ms": 23.494914998445893,
"eof_drain_ms": 321.40718099981314,
"peak_buffer_mib": 39.553802490234375,
"peak_process_rss_mib": 154.203125,
"lookahead_seconds": 0.5,
"fps": 30,
"opencv_threads": 1,
"python": "3.14.0",
"numpy": "2.5.4",
"opencv": "5.0.0"
}
]
}
Loading
Loading