diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b966fb5..b2b905d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,6 +23,47 @@ jobs: 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 + run: ruff check --select F frame_pipeline.py timestamped_frames.py stateful_effects.py tests/inversion_regression.py tests/platform_support.py tests/install_prompt.py tests/logging_regression.py - name: run every suite with optional dependencies present run: python tests/run_tests.py + + windows: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: make sure ffmpeg and ffprobe are on PATH + shell: pwsh + run: | + if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { + choco install ffmpeg -y --no-progress + } + ffmpeg -version | Select-Object -First 1 + ffprobe -version | Select-Object -First 1 + - name: platform + installer tables + shell: pwsh + run: | + python tests/platform_support.py + if ($LASTEXITCODE -ne 0) { exit 1 } + - name: diagnostics log + shell: pwsh + run: | + python tests/logging_regression.py + if ($LASTEXITCODE -ne 0) { exit 1 } + - name: cli regression + shell: pwsh + run: | + python tests/cli_regression.py + if ($LASTEXITCODE -ne 0) { exit 1 } + - name: requirement check on windows + shell: pwsh + run: | + python -c "import diagnostics, os; p = diagnostics.log_dir(); print(p); assert 'effex' in str(p).lower() and os.environ['LOCALAPPDATA'].lower() in str(p).lower(), p" + if ($LASTEXITCODE -ne 0) { exit 1 } + python effex.py --check --json + if ($LASTEXITCODE -ne 0) { exit 1 } + python effex.py --list + if ($LASTEXITCODE -ne 0) { exit 1 } diff --git a/README.md b/README.md index 7ceb0d2..5cb2720 100644 --- a/README.md +++ b/README.md @@ -19,29 +19,74 @@ estimated finish. to put `effex` on your PATH: ```sh -./install.sh +./install.sh # linux, macos ``` -needs Python 3.8+ and FFmpeg. `ffprobe` adds clip details and is required for two-video datamosh and stateful effects. The built-in filters use Python's standard library; temporal sediment also needs NumPy: +```powershell +powershell -ExecutionPolicy Bypass -File install.ps1 # windows +``` + +on windows run `effex.cmd` (or `py effex.py`) - `.py` files are not executable +there. the shim prefers `.venv\Scripts\python.exe` and falls back to `py -3`. + +## requirements + +`effex` checks these on every launch and shows the result in the main menu: + +| requirement | what it is for | installed with | +| --- | --- | --- | +| `ffmpeg` | renders every effect | the package manager below | +| `ffprobe` | clip details, two-video datamosh, stateful effects | ships in the ffmpeg package | +| python 3.8+ | runs effex | python.org | +| `numpy` (optional) | sediment, collapse, inversion | `requirements-stateful.txt` | +| `opencv` (optional) | elastic collapse seam detail | `requirements-collapse.txt` | +| `PyAV` (optional) | inversion timestamps | `requirements-inversion.txt` | + +when a required binary is missing the menu asks `install ffmpeg + ffprobe now?` +and runs the command for the platform it detected: + +| os | manager | command | +| --- | --- | --- | +| windows 10/11 | winget | `winget install -e --id Gyan.FFmpeg --accept-package-agreements --accept-source-agreements` | +| windows | chocolatey / scoop | `choco install -y ffmpeg` / `scoop install ffmpeg` | +| macos | homebrew | `brew install ffmpeg` | +| debian, ubuntu, parrot, mint | apt | `sudo apt-get install -y ffmpeg` | +| fedora, rhel | dnf | `sudo dnf install -y ffmpeg` | +| arch, manjaro | pacman | `sudo pacman -S --noconfirm ffmpeg` | +| opensuse | zypper | `sudo zypper install -y ffmpeg` | +| alpine | apk | `sudo apk add ffmpeg` | + +answer n and the menu keeps the missing count visible; the `requirements` row +(or `c`) asks again. `./effex.py --check` prints the same panel for scripts and +exits 1 when something required is missing, `--check --json` reports it as json +(`ok`, `platform`, `installer`, `hint`, and one object per requirement). + +the optional modules are never installed by that prompt - they are pip packages, +loaded only when rendering stateful effects. Ordinary FFmpeg effects and +`--list` still work without them: ```sh python3 -m pip install -r requirements-stateful.txt +python3 -m pip install -r requirements-collapse.txt ``` -Elastic collapse additionally uses headless OpenCV for optimized CPU remapping, -resizing, blur and gradient analysis. Install it in your Python environment: +Causality inversion needs PyAV on top of NumPy; 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). + +For optional effects on systems with externally managed Python, use a local +environment rather than installing into the system interpreter: ```sh -python3 -m pip install -r requirements-collapse.txt +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements-collapse.txt -r requirements-inversion.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). +on windows the interpreter is `.venv\Scripts\python.exe`. Plain `./effex.py`, +`effex.cmd` and installed `effex` launches use the local venv when present. An +already-active virtual environment is respected; `python -S` keeps its explicit +no-site behavior. Failed renders keep the underlying error on the completion +screen instead of showing only "no output file was written". ## commands @@ -53,24 +98,256 @@ uses FFmpeg's `-enc_time_base demux` option (tested with FFmpeg 7.1). ./effex.py lobotomy+neon input/clip.mp4 --set lobotomy.kill=g --set neon.period=2 ./effex.py sediment input/clip.mp4 --set intensity=70 --set layers=3 --set excavation=10 --set seed=123 ./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 block_size=5 --set mosh=70 +./effex.py collapse input/clip.mp4 +./effex.py collapse input/clip.mp4 --set pixelate=on --set block_size=5 --set mosh=70 ./effex.py collapse input/clip.mp4 --set mode=elastic --set seed=17 ./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 datamosh input/a.mp4 --with input/b.mp4 +./effex.py archaeology input/clip.mp4 --set metric=texture --set boundary=soft ./effex.py stretch input/clip.mp4 --dry-run ``` `--set key=value` changes an effect option. use `--size 1920x1080` for a shape, `--output path.mp4` for an exact path, and `--force` to overwrite. without `--force`, effex adds `_2`, `_3`, and so on instead of replacing a render. +## when something breaks + +effex keeps a diagnostics log with every warning and error, plus the ffmpeg +stderr that caused a failed render and the traceback of anything that crashed. +a run that works writes nothing, so the file only holds the interesting bits. + +| os | log file | +| --- | --- | +| linux | `$XDG_STATE_HOME/effex/logs/effex.log` (`~/.local/state/effex/logs`) | +| macos | `~/Library/Logs/effex/effex.log` | +| windows | `%LOCALAPPDATA%\effex\logs\effex.log` | + +`EFFEX_LOG_DIR` points it somewhere else. the file rotates at 512 KiB and keeps +five old copies (`effex.log.1` ... `.5`), so it never grows without bound. + +```sh +./effex.py --logs # print the path and the last 40 lines +./effex.py --report # one file: environment header + the log tail +./effex.py --report issue.log # same, at a path you choose +``` + +every log entry is one line (`2026-10-28 16:17:57 error ffmpeg failed (exit 183)`) +with any detail indented under it, so `grep error` and issue searches work. the +header of each run records the command, working directory, os, python, and the +ffmpeg/ffprobe paths and versions - that is usually what a report needs. a failed +render also shows the log path on the completion screen, where `l` opens the log +folder. + +to report a bug: run `effex --report`, attach the file it writes to +. skim it first - it contains +your paths and file names. + ## what it does -- effects include stretch, pixelize, deepfry, vhs/crt, dropout, trails, datamosh, timescratch, and temporal sediment. +- effects include stretch, pixelize, deepfry, vhs/crt, dropout, trails, datamosh, timescratch, temporal sediment, causality inversion, and pixel archaeology. - stack single-pass effects with `+`, such as `stretch+deepfry`; order matters. - temporal sediment layers the current image over a bounded 1-3 layer history. Tune deposit, decay, motion sensitivity, excavation chance, color memory, and seed; it works with single-pass filters before or after it. -- 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. +- datamosh defaults to a heavy codec-bloom preset. pick the second clip in the menu or pass `--with path` to mash two videos into one stream. +- blockswap trades fixed rectangles between seeded positions, can teleport those layouts on a timer and can run a light codec datamosh over the whole clip; see the blockswap section. - 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. 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. +datamosh, timescratch and blockswap 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. + +## blockswap + +`blockswap` under **CODEC & BLOCKS** swaps rectangular regions of the frame +between seeded positions. The blocks do not slide: each layout holds until it +teleports. + +```sh +./effex.py blockswap input/clip.mp4 +./effex.py blockswap input/clip.mp4 --set blocks=5 --set size=15 --set teleport=2 +./effex.py blockswap input/clip.mp4 --set teleport=1 --set mosh=25 +./effex.py blockswap input/clip.mp4 --set seed=17 --set mosh=70 --set mosh_grit=20 +``` + +| option | range | default | meaning | +| --- | --- | --- | --- | +| `size` | 5-60% | 20% | block width and height as a share of the frame | +| `blocks` | 1-8 | 3 | swapped block pairs (each pair relocates two regions) | +| `teleport` | 0-30 s | 3 | seconds per layout; 0 keeps one layout for the whole clip | +| `mosh` | 0-100% | 0 | light codec datamosh over the whole clip; 0 disables | +| `mosh_grit` | 1-31 | 12 | crunch of the intermediate mpeg4 encode | +| `mosh_hold` | 2-600 | 12 | keyframe interval in the intermediate encode | +| `seed` | 0-2147483647 | 0 | 0 derives a repeatable layout from the clip | + +Each teleport window gets its own seeded layout, emitted as a `swaprect` filter +with an `enable='between(t,...)'` guard, so several layouts coexist in one +command. Windows are capped (`48 / blocks`) so a long clip cannot explode into +hundreds of filters. Blocks inside one window are kept from overlapping. + +`mosh` turns blockswap into a two-pass codec render: the swaprect chain is +encoded to mpeg4 with `mosh_grit`/`mosh_hold`, recovery keyframes are removed by +survival `100 - mosh`, motion data is bloomed over `mosh/12` frame slots, and the +result is remuxed with the source audio. Teleports land in that stream as hard +prediction breaks, so the blocks themselves smear - which is the point. Higher +`mosh` keeps fewer keyframes and blooms more. Because it edits the bitstream, +blockswap cannot be chained with other effects and has no preview; with +`mosh=0` it is still encoded in a single pass (same filters, normal libx264). + +## heavy datamosh + +`datamosh` lives under **CODEC & BLOCKS**. Heavy is now the default: remove +recovery keyframes, crush the MPEG-4 encode, and repeatedly apply short runs of +P-frame motion/residual data to stale pixels. This is codec-level blooming, +not a pixel overlay or a repeated decoded still frame. + +```sh +./effex.py datamosh input/a.mp4 --with input/b.mp4 +./effex.py datamosh input/a.mp4 --with input/b.mp4 --set preset=heavy --set bloom=24 --set bloom_window=1 +./effex.py datamosh input/a.mp4 --set preset=standard +./effex.py datamosh input/a.mp4 --with input/b.mp4 --set second_start=2 --set second_length=3 --set audio=mix +``` + +| setting | heavy (default) | standard | +| --- | --- | --- | +| `cut` | heavy: remove recovery keyframes | heavy | +| `fill` | chaos: seeded random motion frame | next motion frame | +| `hold` | 12-frame keyframe interval | 150 frames | +| `grit` | 18, visibly crushed | 2, cleaner | +| `bloom` | reuse motion across 12 frame slots | 1, disabled | +| `bloom_window` | cycle 2 captured motion frames per burst | 1 | + +`bloom` ranges from 1-60 slots, and `bloom_window` from 1-12 motion frames +(capped to the burst length). Longer bursts and smaller windows sustain the +same motion for longer. Individual `--set` knobs override the chosen preset +regardless of argument order. `--set preset=...` is the **mosh** preset; +`--preset` remains the unrelated x264 encoder option. + +In the menu, **second video** is the first row and **mosh preset** is the second. +Changing the preset loads its codec knobs; they remain editable afterward. +The options scroll on short terminals. `--set second=path` still works, while +two positional files still mean two separate batch renders. Use `--with` for +one combined video. + +The second clip is appended, resized/padded and frame-rate-normalized to match +the first. Its boundary keyframe is removed when compatible, so its motion +inherits the first clip's stale image instead of making a clean cut. The result +is sequential, not a simultaneous opacity blend. `audio=auto` joins audio with +silence for missing tracks; `first`, `second`, `mix` and `none` remain available. + +Bloom replaces existing frame slots rather than inserting frames, preserving +the normalized timeline and audio length. Reused VOPs take the destination +timestamp; incompatible clock-header lengths keep the original slot so internal +slice markers cannot shift. A recovery keyframe can survive that safety fallback. +Very short/static inputs with little prediction data may show less melting. +The intermediate compressed stream is edited in memory, so memory use scales +with clip size. No extra packages or custom FFmpeg build are required. + +Technique references: [tomato's bloom/pulse modes](https://github.com/GlitchTools/tomato), +[tiberiuiancu/datamoshing's P-frame reuse](https://github.com/tiberiuiancu/datamoshing), +and [python-moshion's two-source splice](https://github.com/rjmoggach/python-moshion). +Effex implements these techniques independently; no repository code is vendored. + +## pixel archaeology + +`archaeology` (under **TIME & MEMORY**) borrows a cutout from a *future* frame, +holds it in the present picture, then melts it back into the video. Nothing is +synthesised: every borrowed pixel is a copy of a frame that has not been shown +yet, so the picture briefly shows something that has not happened. + +The mental model is a sky of drifting clouds. Somewhere in the frame a region of +cloud sits that looks like the same kind of cloud a fraction of a second later, +but the cloud has moved. Archaeology cuts that later cloud out, pastes it into +the present frame under a soft, grown mask, lets it drift with its own motion, +then dissolves it - blotch by blotch, with a flow-warped smudge - until the live +frame is back. + +```sh +./effex.py archaeology input/clip.mp4 +./effex.py archaeology input/clip.mp4 --set gap=30 --set size=24 --set pick=dissimilar +./effex.py archaeology input/clip.mp4 --set mode=slide --set hold=10 --set melt=24 +./effex.py neon+archaeology+deepfry input/clip.mp4 --set archaeology.gap=12 +./effex.py archaeology input/clip.mp4 --set seam=none --set feather=0 --set shape=rect +``` + +| option | range / values | default | +| --- | --- | --- | +| `lookahead` | 0.1-2.0 s of frames buffered ahead (also the output delay) | 0.6 | +| `gap` | 1-60 frames ahead the cutout is taken from | 18 | +| `cutouts` | 1-3 simultaneous cutouts | 1 | +| `spawn_every` | 4-120 frames between spawns | 24 | +| `size` | 5-40% of the frame the cutout aims to cover | 18% | +| `pick` | coherent, dissimilar, random | coherent | +| `min_motion` | 0-30%, ignore slower regions | 2% | +| `max_motion` | 1-100%, ignore faster regions | 25% | +| `similarity` | 0-100%, how close a surface must look to count as a match | 60% | +| `shape` | blob, rect | blob | +| `seam` | poisson, none | poisson | +| `feather` | 0-100%, edge softness (scaled to the blob, not the frame) | 35% | +| `hold` | 1-30 frames the cutout stays sharp | 6 | +| `melt` | 2-60 frames it takes to dissolve | 14 | +| `mode` | smudge, slide | smudge | +| `retention` | 0-95%, how much smeared detail each melt frame keeps | 70% | +| `drift` | 0-100%, how far the cutout follows its own motion | 100% | +| `seed` | 0-2147483647, 0 derives repeatable timing from the clip | 0 | + +Selection is what makes or breaks the look, so it is worth knowing what the +knobs do. Every spawn tiles the frame at analysis scale (longest side <= 160 px), +rejects flat tiles, and measures two things per tile: how similar the surface is +to the same spot a `gap` frames later, and how much the pixels actually differ +there. A cutout is only eligible when the surface is similar (`similarity`) *and* +the future really differs at that spot - borrowing from a region that looks +identical ahead produces an invisible cutout, which is the old failure mode. +`coherent` then prefers the strongest visible difference inside the motion window +(`min_motion`..`max_motion`), `dissimilar` takes the least matching surface under +a cap, and `random` is the unweighted baseline. + +The chosen seed tile grows into one blob by region growing on descriptor +distance, relaxing the budget in passes and finally dilating so the area tracks +`size`; morphology rounds it and a blob-scaled blur feathers the border. With +`seam=poisson` and headless OpenCV the sharp hold frames are merged with +`cv2.seamlessClone` (`NORMAL_CLONE`), otherwise a feathered alpha blend is used - +`seam=none` gives exact future pixels with a hard-ish edge, which is what the +provenance tests assert. + +`mode=smudge` keeps an accumulator per cutout, `acc = warp(acc, flow) * retention ++ future * (1 - retention)`, so melting detail is warped along the region's own +motion and composited through the decaying mask; opacity eases out as the +blotches open, otherwise the tail of the melt eats the live picture. `mode=slide` +skips the accumulator and dissolves the mask with a low-frequency noise +threshold, which is cheaper and reads as a clean dissolve rather than a smear. +After `hold + melt` frames the cutout is gone and the emitted frame is the live +frame, bit-identical. + +The future frames are real: the processor buffers `ceil(lookahead * fps) + 1` +frames (at most 128, and never more than the 128 MiB budget) and emits every +packet with its original index, timestamp and duration. A spawn is skipped when +no real future frame exists at the gap distance (near the end of the clip), and +the remainder is drained at EOF. Look-ahead is the only latency in the effect and +it is dropped from the output timeline, not stretched. State resets on explicit +or detected scene cuts, timestamp rewind and frame-index discontinuity, and is +bounded independently of clip length. + +This effect is CPU-bound and needs NumPy (`requirements-stateful.txt`); poisson +seams and DIS optical flow need headless OpenCV (`requirements-collapse.txt`), +and without it the flow falls back to numpy block matching with a runtime note. +It has no stateful preview; it does keep original rational timestamps because it +uses the timestamped transport, so chaining with `inversion` preserves VFR input. +Benchmark the processor alone with: + +```sh +python3 tests/benchmark_futurecut.py --frames 90 --output benchmarks/futurecut.json +``` + +Processor-only timings on this Linux x86_64 host (fresh worker per case, 90 +frames, excludes FFmpeg decode/encode and is not an end-to-end claim): + +| mode | 640x360 mean / p95 | 1280x720 mean / p95 | 1920x1080 mean / p95 | peak state | +| --- | --- | --- | --- | --- | +| smudge | 6.02 / 15.75 ms | 22.54 / 80.75 ms | 36.45 / 69.50 ms | 122.6 MiB | +| slide | 4.18 / 10.75 ms | 18.04 / 76.08 ms | 27.27 / 49.47 ms | 116.7 MiB | + +The p95 spikes are spawn frames (region growing plus the first smudge frame). +Peak state is the buffered look-ahead plus one accumulator per live cutout, and +the buffer is what limits resolution: the 128 MiB budget fits 18 frames at +1080p and 8 at 1440p, so a long `lookahead` is clamped there with a runtime +warning instead of growing without bound. End to end, a 2560x1440 clip renders +at roughly 160 ms/frame including decode and encode. ## channel lobotomy @@ -119,10 +396,11 @@ the original canvas dimensions. High-gradient regions resist shrinking. Analysis is capped at 192 pixels on the longest side and updates at most five times per second; the displacement field is smoothed between updates. `mode=elastic` keeps the earlier seeded, spatially nonuniform elastic warp. -Both modes finish with a reduced-resolution pixel grid enlarged using nearest -neighbors. Moving regions can borrow a limited blend of the previous frame's -blocks for a codec-mosh-like trail; the effect does not modify codec packets or -perform full datamoshing. +Pixelation is a separate switch and is **off by default**: the render is the +smooth warped frame at source resolution. Set `pixelate=on` to finish with a +reduced-resolution grid enlarged using nearest neighbors, and optionally let +moving regions borrow a limited blend of the previous frame's blocks for a +codec-mosh-like trail. The effect never modifies codec packets. | option | range / values | default | | --- | --- | --- | @@ -137,8 +415,9 @@ perform full datamoshing. | `speed` | 0-4 elastic cycles per second | 0.35 | | `coherence` | 0-100%, elapsed-time field smoothing | 80% | | `seed` | 0-2147483647; elastic mode only, 0 derives phases from the source | 0 | -| `block_size` | 1-16 reduced-grid cells; larger means chunkier pixels | 4 | -| `mosh` | 0-100%, motion-triggered previous-block carry (up to 50% mix) | 60% | +| `pixelate` | on, off - finish with chunky pixel blocks | off | +| `block_size` | 1-16 reduced-grid cells when `pixelate=on`; larger means chunkier | 4 | +| `mosh` | 0-100%, previous-block carry when `pixelate=on` (up to 50% mix) | 60% | In seam mode, shrink is capped at 50% per axis. The target reduction is `50% × intensity × seam_strength × horizontal/vertical control`; `preserve` @@ -148,41 +427,47 @@ at source dimensions. `smoothness` and `coherence` steady the reduced-resolution seam map; motion, speed, and random phases apply only to elastic mode. Zero intensity is an exact passthrough; two zero axis scales disable both modes. +`block_size` and `mosh` do nothing while `pixelate=off`; the smooth remap is the +default look. Zero seam strength disables seam mode. Seam geometry starts at identity and -reaches its configured scale at clip end; the pixel-mosh texture applies from -the first active frame. -`block_size=1` with `mosh=0` disables the pixel-mosh texture. +reaches its configured scale at clip end. State resets on a new input/shape, explicit reset, rewind, frame discontinuity, explicit scene cut, or a large RGB scene change. Persistent analysis state is bounded independently of clip length; full-frame remap buffers are transient. Chains such as `neon+collapse+sediment+deepfry` reuse the shared decoder, encoder, audio handling, progress, cancellation and partial-file cleanup. -Benchmark both modes separately (requires the optional dependencies): +Benchmark both modes, with and without pixelation (requires the optional +dependencies): ```sh -python3 tests/benchmark_collapse.py --frames 120 --output benchmarks/collapse.json +python3 tests/benchmark_collapse.py --frames 60 --output benchmarks/collapse.json +python3 tests/benchmark_collapse.py --frames 60 --pixelate on --output benchmarks/collapse-pixelated.json ``` The report measures processor frame time and peak process RSS at 640x360 and 1280x720 in fresh workers. It excludes FFmpeg decode/encode and is not an end-to-end playback guarantee. Seam mode also reports carving-update time. -Recorded on this Linux x86_64 host with one OpenCV CPU thread, 120 frames per +Recorded on this Linux x86_64 host with one OpenCV CPU thread, 60 frames per case, Python 3.13.5, NumPy 2.2.4 and OpenCV 4.12.0 (raw data in -`benchmarks/collapse.json`): - -| mode | frame size | mean ms/frame | p95 ms/frame | peak process RSS | -| --- | --- | --- | --- | --- | -| elastic | 640x360 | 5.44 | 6.25 | 64.4 MiB | -| elastic | 1280x720 | 13.62 | 15.43 | 110.5 MiB | -| seam | 640x360 | 22.62 | 117.82 | 63.5 MiB | -| seam | 1280x720 | 32.13 | 127.87 | 108.1 MiB | - -Seam averages include frames reusing a cached map; carving-update frames alone -averaged 116.51 ms at 360p and 127.58 ms at 720p. Persistent state was about -0.38 MiB for elastic and 0.54 MiB for seam mode in these cases. These are -measurements of this fixture on this host, not a promise for every input. +`benchmarks/collapse.json` and `benchmarks/collapse-pixelated.json`): + +| mode | pixelate | frame size | mean ms/frame | p95 ms/frame | peak process RSS | +| --- | --- | --- | --- | --- | --- | +| elastic | off | 640x360 | 4.41 | 4.55 | 64.5 MiB | +| elastic | off | 1280x720 | 10.51 | 10.74 | 109.8 MiB | +| seam | off | 640x360 | 16.71 | 89.15 | 63.6 MiB | +| seam | off | 1280x720 | 26.34 | 114.34 | 110.2 MiB | +| elastic | on | 640x360 | 4.53 | 4.63 | 64.9 MiB | +| elastic | on | 1280x720 | 11.90 | 12.15 | 110.1 MiB | +| seam | on | 640x360 | 22.30 | 122.10 | 63.4 MiB | +| seam | on | 1280x720 | 28.22 | 114.30 | 110.2 MiB | + +Seam averages include frames reusing a cached map, so p95 is dominated by the +carving update. Skipping pixelation removes the extra downsample/upsample pass +(about 5 ms mean in seam mode here) and is the default. These are measurements +of this fixture on this host, not a promise for every input. ## causality inversion @@ -220,8 +505,14 @@ 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 +High-resolution/high-rate inputs automatically shorten the effective look-ahead +and offset to fit that budget, rather than refusing to render. The nominal limit +is `(buffer capacity - 1) / fps`; requested settings are preserved. The completion +screen, plain output and JSON `warnings` report any reduction. At 2560x1440/30 fps, +the default 0.5-second request becomes about 0.367 seconds. Resize before inversion +to retain a longer horizon. A frame larger than the entire budget still requires +resizing; a one-frame capacity becomes passthrough with a warning. +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 @@ -256,8 +547,10 @@ run the test suite with: ```sh python3 tests/run_tests.py python3 tests/run_tests.py psychedelic # pixel behavior, options and real renders +python3 tests/run_tests.py datamosh mosh-stream # two sources, presets and codec clocks 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 +python3 tests/run_tests.py archaeology archaeology-cli # future-cut provenance and real renders ``` tests use generated clips and temporary folders; they do not overwrite files in `input/` or `output/`. diff --git a/ROADMAP.md b/ROADMAP.md index c3fb3e0..e4abd17 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,12 +2,14 @@ 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. +format: `[ ]` open, `[~]` half done (ships but the look is not right yet), +`[x]` done, notes indented under the item. state: input/ holds source videos, output/ holds renders and previews, effex.py -is the tool (v0.12.0, 24 effects, chains of two or more), tests/ has ten +is the tool (v0.14.0, 25 effects, chains of two or more), tests/ has thirteen suites. verify before closing an item with: - python3 tests/run_tests.py # cli + datamosh + menu + layout + install + stateful + psychedelic + collapse + seams + inversion + python3 tests/run_tests.py # cli + datamosh + mosh-stream + menu + layout + install + stateful + psychedelic + collapse + seams + inversion + archaeology + archaeology-cli + python3 tests/benchmark_futurecut.py --frames 90 --output benchmarks/futurecut.json python3 tests/make_clip.py # drop a lavfi test clip into input/ ./install.sh # effex onto ~/.local/bin @@ -246,17 +248,43 @@ expressions, so they are drop-in once the effect model supports the bits they ne validate the second source and segment bounds before encoding; ffprobe-backed tests verify duration, frame size, cross-source motion, audio and keyframe survival._ + _Heavy preset: defaults to grit 18, 12-frame GOP, chaos fill and 12-slot + prediction blooms cycling two motion frames. Standard restores the previous + knobs. Bloom replaces slots, retaining destination VOP clocks; incompatible + header lengths retain the original slot to protect byte-aligned slices. + `--with` joins two sources without changing positional batch semantics. + The menu loads either preset and scrolls codec options on short terminals. + Tests cover actual decoded pixel changes, full frame counts, clock rollovers, + fractional frame rates, audio and both CLI/menu paths. Research below._ - [x] E2.26 - circuit bending kit _Done 2026-09-24: channelswap (colorchannelmixer channel rewrites plus - optional chroma bleed), blockswap (swaprect blocks traveling on sin - curves) and dropout (bendr-style random frame drops with freeze-holds, - audio untouched). two real ffmpeg-7 gotchas surfaced: lutrgb has no + optional chroma bleed), blockswap (swaprect blocks trading places), + and dropout (bendr-style random frame drops with freeze-holds, audio + untouched). two real ffmpeg-7 gotchas surfaced: lutrgb has no cross-channel variables (r=b is undefined - colorchannelmixer is the swap filter), and swaprect's expression context knows w/h but not iw/ih. behavioral proofs: a pure red frame comes out pure blue after a rb swap, - and frames at two timestamps differ on a static clip, so the blocks - really travel. the bendr / video-glitch-array vocabulary from section 6 - fed this._ + and the relocated blocks are visible against the source frame. the bendr / + video-glitch-array vocabulary from section 6 fed this._ + _Changed 2026-10-25 by request: blockswap blocks no longer travel on sin + curves. Positions are chosen once from a seed, each pair swaps a fixed + rectangle for the whole clip, `blocks` selects how many pairs (1-8) and + `seed` makes the layout reproducible. Pairs are kept from overlapping + unless the frame is too crowded. The old `speed` knob is gone; `blocks` + replaced it. A static-frame test proves the output does not drift beyond + codec noise, and a regex check proves a `blocks=N` render emits N swaprects + with no time expression._ + _Extended 2026-10-25: `teleport` (0-30s, default 3) emits one seeded layout + per window with `enable='between(t,...)'`/`gte(t,...)`, capped at 48/blocks + windows; `mosh` (0-100%, default 0) adds a light codec pass - mpeg4 at + `mosh_grit`/`mosh_hold`, dice keyframe survival of `100 - mosh`, motion bloom + of `mosh/12` slots, then a remux with the source audio. Teleports become real + prediction breaks, so the blocks themselves smear. blockswap is therefore a + multi-pass effect (no chaining, no preview) but still single-pass libx264 when + `mosh=0`. dry-run now prints the resolved filter chain and plan notes for + every multi-pass effect. Tests cover window counts, per-window layouts, the + codec output, frame/audio preservation and the mosh-vs-plain pixel delta; + the menu test covers the new rows and the hidden preview key._ - [x] E2.27 - crunch (bitrate + resolution down) _Done 2026-09-24: resolution choice (keep/75/50/25%) and a bitrate cap (100-5000 kbit/s). forced a small engine flag: Plan.abr drops -crf for @@ -393,11 +421,211 @@ Shared rules for the stateful effects: preservation, optional dependencies and TUI/CLI integration. Optional headless OpenCV remains isolated in requirements-collapse.txt. Existing stateful VFR normalization, adjacent-processor and preview limits remain explicit._ -- [ ] E2.47 - pixel archaeology (prompt effect 09): transplant patches from earlier frames using content matching. - - Keep a bounded reservoir of old frames or patches. Cache inexpensive descriptors such as color mean/variance, gradient histograms, or downsampled pixels; sample candidates instead of searching every patch in every frame. - - Match current regions to historical patches by color, texture, or edge compatibility. Include a controlled mismatch mode. Support hard copy and soft/feathered boundaries; every replacement must come from actual video history, not random rectangles. - - Options: history capacity/seconds, patch size, candidates per frame, analysis interval, match metric/threshold, mismatch strength, replacement chance, boundary mode, displacement, seed. - - Test patch provenance, distinct matching modes, zero-intensity passthrough, deterministic selection, and bounded history memory. + _Pixelation split out 2026-10-25: `pixelate` is a bool and defaults to off, so + the shipped look is the smooth content-aware warp at source resolution. + `block_size`/`mosh` only act when `pixelate=on`. CLI and menu both toggle it + (`--set pixelate=on`, enter on the row); default-off output is proven + non-blocky by a run-length check, and block knobs are proven inert while off. + Benchmarks record both settings._ +- [~] E2.47 - pixel archaeology: transplant real cutouts from other moments in the clip into the present frame, then mosh them back in. + - Original prompt: keep a bounded reservoir of old frames/patches, cache cheap descriptors, sample candidates instead of exhaustive search, match by color/texture/edge, support mismatch and hard/soft boundaries, every replacement from real video. + - Options: history capacity/seconds, patch size, candidates, interval, metric/threshold, mismatch, chance, boundary, displacement, seed. + _Partial 2026-10-25: `archaeology` ships a bounded, time-expiring reservoir of + earlier source tiles with cached color/texture/edge descriptors, sampled + candidates, mismatch chance, hard/soft edges, resolution-independent auto + tiles, deterministic seeds, bounded memory and full CLI/menu/chain wiring + (19 processor + 3 media-cli + 9 menu + 24 layout checks, benchmark in + benchmarks/archaeology.json). Provenance is exact: hard tiles are byte-equal + copies of stored source patches._ + _Why it is only half done: the output is a uniform tile grid of unrelated + scraps, so it reads as random pixel noise, not as art. There is no coherent + subject, no temporal story and no blend: a tile appears and is simply gone next + frame. Nothing is borrowed from the future, and nothing is moshed. The user's + reference is a sky of clouds where a cutout of a *future* cloud drifts into the + present frame and then melts back into the picture. Everything below replaces + the tile-scatter behaviour; treat the current look as a placeholder, not the + goal._ + _Phase A shipped 2026-10-26 and replaced the tile version outright: + `future_cut.py` (`FutureCut`, timestamped push/flush) is now the only + archaeology processor, `pixel_archaeology.py` and its tests are deleted, and + the effect key, menu entry and chain rules are unchanged. It buffers + `lookahead` seconds (clamped to the 128 MiB budget - 18 frames at 1080p, 11 of + look-ahead at 1080p60 with three cutouts - and surfaced as a runtime warning), picks a tile where the future surface is similar *and* the + pixels actually differ (identical-ahead regions give invisible cutouts - the + old failure mode), grows it into one blurred blob, composites with + `cv2.seamlessClone` during hold, and melts it with a flow-warped smudge + accumulator or a coarse-noise dissolve, then hands back the live frame + bit-identically. `18` processor tests (provenance from frame `+gap`, one + coherent blob, area tracks `size`, no-identical-pick, residue, determinism, + resets/scene cuts, budget clamp, no-cv2 fallback, metadata) plus + `tests/archaeology_integration.py` (registry, CLI render, dry-run notes, bad + choices, chains with FFmpeg effects and with inversion, VFR PTS preservation). + Benchmarks in `benchmarks/futurecut.json`. Still `[~]`: the look has not been + accepted by the user, `mode=codec` (a real bitstream mosh-out instead of the + smudge accumulator) is deferred until chains can own multi-pass pipelines + (E3.16), and E2.48 anti-motion compensation is unbuilt._ + +#### E2.47a - future-cut compositor (the look: a coherent slab from the future) + +Target shot, stated as the acceptance image: a slow sky of clouds, 720p+. At +frame N a 10-25% region of the sky is replaced by a cloud that is genuinely from +frame N+K (0.2-1.0s later), offset a little and continuing the same drift. For +roughly 6-12 frames it slides with the scene as one coherent shape. Then it +smears along its own motion and dissolves in blotches back into the live sky, so +no rectangle edge and no cut line is ever visible for more than a frame or two. +The subject (horizon, buildings, a person) is never cut or displaced. + +- Reuse the buffer, do not duplicate it: factor the bounded look-ahead in + `stateful_effects.CausalityInversion` into a shared `FutureBuffer` (same + 128 frames / 128 MiB budget, same rational-PTS queue, same scene-cut drain). + `inversion` and this effect both consume it, so one timestamped transport path + stays in charge. Frame gap in frames, converted from seconds via probed fps. +- Analysis resolution: longest side <= 256 px for flow and scoring, full + resolution only for the final cutout pixels. OpenCV DIS optical flow + (`cv2.DISOpticalFlow_create(cv2.DISOPTICAL_FLOW_PRESET_MEDIUM)`) present frame + -> future frame; Farneback fallback; without OpenCV, degrade to descriptor + search only and say so in the plan notes (quality drop, no silent failure). +- Region proposal (the part that makes it look intentional, not noisy): + 1. score overlapping tiles (32-96 px at analysis scale) on three signals: + descriptor similarity between the present tile and the future tile warped + back by flow, flow magnitude within `min_motion..max_motion`, and warp + residual (does the future tile actually continue this texture?). + 2. reject flat tiles: gradient energy below a floor (blank sky corner, wall) + never becomes a cutout. + 3. grow the best seed tile into one blob by region growing on descriptor + distance, then morphology open/close, then blur the mask for a soft edge. + Keep exactly one dominant blob per spawn, sized 5-25% of the frame; a + second blob only when `cutouts=2+`. + 4. `pick=coherent` (default) prefers similar-but-shifted content; + `pick=dissimilar` prefers the largest descriptor distance under a cap for a + harder, wrong-content look; `pick=random` is the debug/baseline mode. +- Compositing: mask-shaped cutout (never an axis-aligned rectangle), feathered + 8-20 px, optional Poisson blend via `cv2.seamlessClone(..., NORMAL_CLONE)` + when `seam=poisson`; `MIXED_CLONE` only for the mismatch look. The cutout + keeps an anchor tracked by flow each frame so it drifts with the scene instead + of sticking to screen space. +- Lifecycle per spawn: `spawn_every` frames, at most `cutouts` alive, each with + `age`, `hold` (sharp frames) and `melt` (dissolve frames). Spawning is seeded, + so the same clip and seed give the same render. + +#### E2.47b - mosh-out: dissolving the cutout back into the video + +The cutout must not fade like an opacity slider; it has to look like prediction +smear. Three modes, `mode=smudge` as default: + +- `slide`: paste live future pixels for `hold` frames, then a spatially + irregular alpha ramp (threshold noise, not a linear fade) back to the live + frame. Cheapest, most controllable, least "codec". +- `smudge` (default): keep a feedback accumulator per cutout. Each frame + `acc = warp(acc, local flow) * retention + live_future * (1 - retention)`, + then composite `acc` through the decaying mask. Content streaks along its own + motion and dissolves in blotches, which is what the eye reads as motion-vector + smearing, without touching the bitstream. +- `codec` (multi-pass, like datamosh): render the clip normally, then re-encode + the spawn..melt window with the existing mpeg4 bloom pass (`strip_ivops` with + `bloom`), so decoder prediction really does drag the pasted region forward, + then concat the untouched head/tail. This is the honest datamosh version; it + is allowed to be a separate multi-pass effect (cannot chain), and the roadmap + E3.16 shape is the prerequisite for doing it inside a chain. +- Dissolve shape: mask erosion from the edges inward combined with a noise + threshold so it goes in blotches, plus a 1-2 px flow-aligned smear on the + boundary. Kill the mask completely by `melt` frames, always. +- Never leave residue: after `hold+melt` frames the accumulated mask contribution + must be exactly zero, so a still frame later in the clip is bit-identical to + the un-effected one. + +#### E2.47c - options (planned names, ranges, defaults) + +| key | range / values | default | meaning | +| --- | --- | --- | --- | +| `lookahead` | 0.1-2.0 s | 0.6 | how far into the future the cutout may come from | +| `gap` | 1-60 frames | 18 | preferred distance to the donor frame | +| `cutouts` | 1-3 | 1 | simultaneous live cutouts | +| `spawn_every` | 4-120 frames | 24 | how often a new cutout is born | +| `size` | 5-40% | 18% | cutout area as a share of the frame | +| `pick` | coherent, dissimilar, random | coherent | region choice policy | +| `min_motion` | 0-30% of frame | 2% | ignore near-static regions | +| `max_motion` | 1-100% of frame | 25% | ignore content that teleports | +| `similarity` | 0-100% | 60% | descriptor acceptance threshold | +| `shape` | blob, rect | blob | mask growth or a plain rectangle | +| `seam` | none, poisson | poisson | boundary blend | +| `feather` | 0-100% | 35% | mask edge softness | +| `hold` | 1-30 frames | 6 | sharp frames before the mosh starts | +| `melt` | 2-60 frames | 14 | dissolve length | +| `mode` | slide, smudge, codec | smudge | how it leaves | +| `retention` | 0-95% | 70% | smudge feedback carry | +| `drift` | 0-100% | 100% | how strongly the cutout follows flow | +| `seed` | 0-2147483647 | 0 | deterministic spawn/choice | + +#### E2.47d - tests and acceptance gates + +- Synthetic fixtures, generated in NumPy so they are deterministic: (1) two + textures translating at different speeds (the cloud case), (2) a static + gradient with one moving object, (3) a hard scene cut mid-clip, (4) a clip + shorter than `lookahead+gap` frames. +- Provenance: every pixel inside the final mask at spawn time must be traceable + to a donor frame at or after `gap` frames ahead (compare against the buffered + source, tolerance 0 for `seam=none`). +- Coherence, the current failure: count connected components of changed pixels + per frame (must be 1-3, not hundreds) and require the largest component to + cover >= 60% of changed area. This is the specific regression that stops it + from being pixel noise again. +- Edge quality: mean gradient discontinuity across the mask boundary must stay + under a threshold vs the same boundary in the source, for `feather>0`. +- Lifecycle: mask contribution is zero after `hold+melt`; a frame 2s later is + unchanged; two renders with the same seed match bit-for-bit; a different seed + differs. +- Transport: chained with `inversion`, video PTS/frame count survive exactly + (ffprobe, not duration only); audio is preserved or deliberately absent. +- Resets: scene cut, rewind, index discontinuity, EOF (near the end there is no + future: fall back to the last buffered frame, then stop spawning). +- Manual gate before closing: 720p contact sheet with before/spawn/hold/mid-melt/ + after frames, plus a 6s side-by-side against the source. If it still reads as + noise, it is not done. +- Budget: processor-only <= 15 ms/frame at 720p target; benchmark script + `tests/benchmark_futurecut.py` in fresh workers, reporting analysis cost + separately from compositing. + +#### E2.47e - would training a model with PyTorch help? decision: no training, optional pretrained inference + +Short answer: do not train anything. Train only if a problem cannot be solved +with correspondence, and this is a correspondence problem, not a classification +problem. + +- Why training is the wrong tool here: the effect needs "which region of *this* + clip, K frames later, continues *this* texture", which is per-clip and + self-supervised by the clip itself. There is no dataset of "present frame -> + desirable future cutout"; labelling one is exactly the artistic decision the + effect is making. A trained model would learn one clip family's look and would + need GPU training, checkpoints and a much heavier dependency tree than the + repo's NumPy/OpenCV optional-dependency policy allows. +- What actually solves it, in order: DIS/Farneback flow for correspondence, + descriptor matching for "same kind of surface", region growing for a coherent + blob, Poisson blending for the seam. All deterministic, CPU, and debuggable - + important because the failure mode we just shipped (tile noise) is a *selection* + bug, and selection is inspectable when it is classical. +- Where pretrained models genuinely help, inference only, no training: + - `metric=learned`: DINOv2 patch features (small ViT, exported to ONNX) give + far better "same texture, different phase" matching than RGB mean/std, which + matters for clouds, water and foliage whose local colour is nearly constant. + Ship behind `requirements-learned.txt` (onnxruntime only), degrade to + `metric=color` when absent. + - `shape=semantic`: SAM 2 (or a light ADE20K segmenter) to constrain cutouts to + a class mask - sky, water, skin, fabric - so a cutout can never paste half a + building into the sky. SAM 2 is promptable and video-aware + (github.com/facebookresearch/sam2); it is also the heaviest dependency in + this roadmap, so it belongs in an opt-in phase, never the default path. +- Phasing: A) classical flow + descriptors + smudge (no ML, the default shipping + path); B) optional ONNX DINOv2 descriptor; C) optional SAM 2 semantic masks. + Re-evaluate after A looks right; if A already reads as intended, stop there. +- If the look still fails after A, the next lever is not training but better + temporal reasoning: multi-frame flow voting (use 3 donor frames, keep the + region whose motion is most consistent) and 2-3 candidate regions per spawn + scored by a hand-written "looks like a cloud" term (gradient entropy, no + straight edges, similar local statistics). + +### stateful glitch effects (continued) + - [ ] E2.48 - anti-motion compensation (prompt effect 15): estimate motion, then amplify it instead of stabilizing it. - 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. @@ -410,7 +638,7 @@ Shared rules for the stateful effects: _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 + 128 frames/128 MiB, adapt nominal look-ahead to fit 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 @@ -419,6 +647,11 @@ Shared rules for the stateful effects: 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._ + _High-resolution fix: defaults now render at native resolution by shortening + the effective horizon/offset to the memory budget, with a visible/JSON warning. + Plain launches use the local project environment when available, and the done + screen retains actual dependency/encoder errors. Requested settings and exact + video timestamps remain unchanged. Regression coverage includes 1440p output._ --- @@ -441,7 +674,7 @@ Shared rules for the stateful effects: and pixelize's "keep" branch reads the working frame instead of the clip), so stretch+pixelize pixelates the stretched 1080x1920 frame and not the 640x360 source. options are keyed per effect: --set key=value still works while one - effect in the chain has that key, two effects sharing one (zoom and blockswap + effect in the chain has that key, two effects sharing one (zoom and wobble both take speed) is refused with both qualified forms named, --set effect.key=value always works, and --size lands on the first effect with a shape option. menu: space chips a row, the chip number is the run order, c @@ -539,7 +772,50 @@ Shared rules for the stateful effects: rows in place and keeps the cursor sane. the empty state also offers r, so a video dropped in while the menu is open shows up without a restart._ - [ ] E4.7 - remember the last used effect and video for the session -- [ ] E4.8 - README notes for windows and macos (the original script was windows-first) +- [x] E4.8 - README notes for windows and macos (the original script was windows-first) + _Done 2026-10-28: README documents the windows launcher (`effex.cmd`), the + powershell installer, the per-platform venv interpreter path, and a + requirements section with one row per prerequisite and one row per package + manager (winget/choco/scoop, brew, apt, dnf, pacman, zypper, apk). macos runs + the same README steps as linux: `./install.sh` plus homebrew._ +- [x] E4.14 - requirements panel in the main menu and a one-answer install prompt + _Done 2026-10-28: every launch checks ffmpeg, ffprobe, python 3.8+ and the + optional numpy/opencv/PyAV modules, and the main menu header says either + `requirements all 3 installed` or `N of 3 missing: · press c to + install`, with one row per requirement (path, version, or what it unlocks) + when the terminal is 26 rows or taller. Missing required binaries trigger + `install ffmpeg + ffprobe now? [Y/n]` before the menu opens: the installer is + picked from the host os (winget, chocolatey, scoop, homebrew, apt-get, dnf, + pacman, zypper, apk - none of the windows commands use sudo) and one command + covers both binaries because they share a package. Declining keeps the count + on screen and the `requirements` row (or c) asks again; answering yes runs the + commands, then re-probes, refreshes the windows PATH from the registry and + reports anything still missing. `--check` prints the same panel and exits 1 + when something required is absent, `--check --json` adds `platform`, + `installer`, `hint` and a requirements array. Windows now has a runtime, not + just docs: msvcrt key reading (arrow/NUL+code decoding), a no-op raw session, + UTF-8 + VT console setup, os.startfile folder opening, the `Scripts\python.exe` + venv path and a subprocess re-exec instead of execv. verified: 29/29 + install-prompt checks (including an ffprobe-only gap), 42/42 platform checks, + 177/177 layout checks, full suite green._ +- [x] E4.15 - diagnostics log with every warning and error, made to attach to an issue + _Done 2026-10-28: `diagnostics.py` is a leaf module (no effex imports) holding + one per-user log file - `$XDG_STATE_HOME/effex/logs` on linux, + `~/Library/Logs/effex` on macos, `%LOCALAPPDATA%\effex\logs` on windows, + `EFFEX_LOG_DIR` overrides it. Only warnings, errors, ffmpeg stderr and + tracebacks are written, so a clean run creates no file at all. Each run opens + with a header (version, command, cwd, platform, python, ffmpeg/ffprobe paths + and probed versions); events are one line each with detail indented under + them, ANSI stripped, so grep works. Rotation at 512 KiB keeping 5 files, + a lock so render threads cannot interleave, and every write wrapped so a + broken log can never break a render. `err()`/`warn()` log automatically; + render failures attach ffmpeg's own stderr, `process()` logs tracebacks, and + `__main__` records anything that escapes. `--logs` prints the path and the + last 40 lines, `--report [PATH]` writes a single file (environment header + + 200-line tail) for the issue tracker, and the failure screen shows the path + with an `l` key to open the folder. verified: 44/44 logging checks (record + format, rotation, threads, traceback, report, cli failure with real ffmpeg + stderr), full suite green._ - [ ] E4.9 - optional single-file build (pyinstaller) for machines without python - [x] E4.10 - wide-terminal layout check _Done 2026-09-24: tests/layout.py runs the whole flow at 60x20, 80x24 and @@ -562,9 +838,9 @@ feasibility noted so nobody spends a week on them by accident. - datamosh extras now shipped: configurable dice survival (0-100%) and explicit deterministic seed; append a second video into the same mpeg4 motion stream with start/length trims and auto-concatenated audio. The menu - selects the second input, the CLI accepts `--set second=path`, and source - formats are normalized before encoding. still on the table: motion-vector - transplants (ffglitch) and bloom/stutter p-vop duplication. note: "no + selects the second input, the CLI accepts `--with path` or `--set second=path`, + and source formats are normalized before encoding. Heavy motion blooming + now ships; still on the table: motion-vector transplants (ffglitch). note: "no keyframes at all" was tried and removed - an mpeg4 stream without its first i-vop decodes to zero video frames, so total mode cannot ship. section 6 has the sources. @@ -594,6 +870,12 @@ item it feeds. links were live at the time of writing. ### datamosh (landed as E2.25 - these shaped it) +- https://github.com/GlitchTools/tomato - bloom repeats one prediction frame; + pulse repeats groups periodically. Informs the heavy preset's burst/window + controls, but Effex replaces existing slots to avoid stretching the audio. +- https://github.com/rjmoggach/python-moshion - joins outgoing and incoming + sequences before keyframe removal, transferring motion across their boundary. + Informs the two-source workflow; no external code is vendored. - https://github.com/ramiropolla/ffglitch-core - THE tool: an ffmpeg fork that lets scripts read and rewrite motion vectors and dct blocks mid-decode (ffedit/ffgac). the gold standard; build-heavy, do not vendor. @@ -615,6 +897,35 @@ item it feeds. links were live at the time of writing. h264 nal, ffglitch) plus pixelmosh.py with 23 pixel effects (tear, chanshift, pixsort, dropout, melt, ghost...) - a catalogue of effect ideas by itself. +### future-cut compositing (feeds E2.47a-E2.47e) + +- https://docs.opencv.org/4.x/df/da0/group__photo__clone.html - cv.seamlessClone + and the Poisson solvers (NORMAL_CLONE / MIXED_CLONE). This is the seam tool: + normal clone keeps the pasted cutout's own lighting, mixed clone grafts the + destination structure, which is what a mismatched cloud wants. +- https://gfx.cs.princeton.edu/pubs/Barnes_2009_PAR/index.php - PatchMatch: + randomized approximate nearest-neighbour patch search. The reference for + matching a present tile against many future candidates without an exhaustive + search; the region-growing scorer in E2.47a is a cheap descendant of this idea. +- https://docs.opencv.org/4.10.0/de/d4f/classcv_1_1DISOpticalFlow.html - DIS dense + inverse-search flow, three presets, fast enough for per-frame analysis at + <=256px. Preferred over Farneback for the present->future gap because a 0.2-1s + jump is large motion. +- https://github.com/facebookresearch/sam2 - SAM 2 promptable video + segmentation. Optional `shape=semantic` phase only: constrain cutouts to sky / + water / skin so a transplant cannot paste half a building into the sky. Heavy + dependency, never the default path. +- https://github.com/facebookresearch/dinov2 - DINOv2 self-supervised patch + features. Optional `metric=learned` phase via ONNX; better "same texture, + different phase" matching for clouds and water than RGB statistics. Inference + only, no training. +- https://github.com/carhartt21/SkyCloudNet - sky and cloud segmentation + (ICIVC 2023) with published weights. A lighter `shape=semantic` candidate than + SAM 2 for the cloud case, if a sky-specific mask is ever needed. +- https://github.com/javgat/SkyMaskConvLSTM - all-sky cloud masks + short-term + cloud prediction. Background reading for what "a plausible future cloud" + means; the effect copies real future pixels, it does not predict them. + ### vhs (feeds E2.5, E2.12 and the crt family) - https://github.com/valadaptive/ntsc-rs - accurate ntsc/vhs artifact diff --git a/benchmarks/collapse-pixelated.json b/benchmarks/collapse-pixelated.json new file mode 100644 index 0000000..3e9bc6f --- /dev/null +++ b/benchmarks/collapse-pixelated.json @@ -0,0 +1,169 @@ +{ + "recorded_utc": "2026-10-25T20:34:02.832149+00:00", + "platform": "Linux-7.1.13+parrot7-amd64-x86_64-with-glibc2.41", + "logical_cpus": 22, + "pixelate": true, + "scope": "synthetic moving RGB; process() only; fresh process per mode/size; not end-to-end render FPS", + "results": [ + { + "mode": "elastic", + "pixelate": true, + "width": 640, + "height": 360, + "frames": 60, + "config": { + "intensity": 100, + "horizontal": 100, + "vertical": 100, + "preserve": 75, + "mode": "elastic", + "seam_strength": 100, + "motion": 50, + "smoothness": 70, + "speed": 0.35, + "coherence": 80, + "seed": 17, + "pixelate": true, + "block_size": 4, + "mosh": 60 + }, + "mean_ms": 4.53345871677205, + "p50_ms": 4.532778499196866, + "p95_ms": 4.629504198419454, + "max_ms": 5.058190999989165, + "processor_fps": 220.5821344088533, + "seam_updates": 0, + "seam_update_mean_ms": null, + "peak_rss_mib": 64.90234375, + "persistent_state_mib": 0.3794708251953125, + "work_shape": [ + 108, + 192 + ], + "python": "3.13.5", + "numpy": "2.2.4", + "opencv": "4.12.0", + "opencv_threads": 1 + }, + { + "mode": "elastic", + "pixelate": true, + "width": 1280, + "height": 720, + "frames": 60, + "config": { + "intensity": 100, + "horizontal": 100, + "vertical": 100, + "preserve": 75, + "mode": "elastic", + "seam_strength": 100, + "motion": 50, + "smoothness": 70, + "speed": 0.35, + "coherence": 80, + "seed": 17, + "pixelate": true, + "block_size": 4, + "mosh": 60 + }, + "mean_ms": 11.901544649845164, + "p50_ms": 11.89956549933413, + "p95_ms": 12.149075249908492, + "max_ms": 12.813671000913018, + "processor_fps": 84.02270708726952, + "seam_updates": 0, + "seam_update_mean_ms": null, + "peak_rss_mib": 110.078125, + "persistent_state_mib": 0.3794708251953125, + "work_shape": [ + 108, + 192 + ], + "python": "3.13.5", + "numpy": "2.2.4", + "opencv": "4.12.0", + "opencv_threads": 1 + }, + { + "mode": "seam", + "pixelate": true, + "width": 640, + "height": 360, + "frames": 60, + "config": { + "intensity": 100, + "horizontal": 100, + "vertical": 100, + "preserve": 75, + "mode": "seam", + "seam_strength": 100, + "motion": 50, + "smoothness": 70, + "speed": 0.35, + "coherence": 80, + "seed": 17, + "pixelate": true, + "block_size": 4, + "mosh": 60 + }, + "mean_ms": 22.30460616653242, + "p50_ms": 3.7979685002937913, + "p95_ms": 122.1038680003403, + "max_ms": 126.72199799999362, + "processor_fps": 44.83378870416813, + "seam_updates": 10, + "seam_update_mean_ms": 115.3640908007219, + "peak_rss_mib": 63.4140625, + "persistent_state_mib": 0.5376739501953125, + "work_shape": [ + 108, + 192 + ], + "python": "3.13.5", + "numpy": "2.2.4", + "opencv": "4.12.0", + "opencv_threads": 1 + }, + { + "mode": "seam", + "pixelate": true, + "width": 1280, + "height": 720, + "frames": 60, + "config": { + "intensity": 100, + "horizontal": 100, + "vertical": 100, + "preserve": 75, + "mode": "seam", + "seam_strength": 100, + "motion": 50, + "smoothness": 70, + "speed": 0.35, + "coherence": 80, + "seed": 17, + "pixelate": true, + "block_size": 4, + "mosh": 60 + }, + "mean_ms": 28.215429733124136, + "p50_ms": 10.980474999087164, + "p95_ms": 114.29859154904987, + "max_ms": 126.1383580022084, + "processor_fps": 35.44160090625973, + "seam_updates": 10, + "seam_update_mean_ms": 112.65644670020265, + "peak_rss_mib": 110.16796875, + "persistent_state_mib": 0.5376739501953125, + "work_shape": [ + 108, + 192 + ], + "python": "3.13.5", + "numpy": "2.2.4", + "opencv": "4.12.0", + "opencv_threads": 1 + } + ] +} diff --git a/benchmarks/collapse.json b/benchmarks/collapse.json index f893295..5e774f8 100644 --- a/benchmarks/collapse.json +++ b/benchmarks/collapse.json @@ -1,14 +1,16 @@ { - "recorded_utc": "2026-10-25T14:54:16.689872+00:00", + "recorded_utc": "2026-10-25T20:33:57.695141+00:00", "platform": "Linux-7.1.13+parrot7-amd64-x86_64-with-glibc2.41", "logical_cpus": 22, + "pixelate": false, "scope": "synthetic moving RGB; process() only; fresh process per mode/size; not end-to-end render FPS", "results": [ { "mode": "elastic", + "pixelate": false, "width": 640, "height": 360, - "frames": 120, + "frames": 60, "config": { "intensity": 100, "horizontal": 100, @@ -21,18 +23,19 @@ "speed": 0.35, "coherence": 80, "seed": 17, + "pixelate": false, "block_size": 4, "mosh": 60 }, - "mean_ms": 5.438171333351723, - "p50_ms": 5.658735499764589, - "p95_ms": 6.254641550503948, - "max_ms": 9.980480000194802, - "processor_fps": 183.8853428296948, + "mean_ms": 4.408682266512187, + "p50_ms": 4.397327000333462, + "p95_ms": 4.547749200355611, + "max_ms": 4.9772220008890145, + "processor_fps": 226.82514627916783, "seam_updates": 0, "seam_update_mean_ms": null, - "peak_rss_mib": 64.3984375, - "persistent_state_mib": 0.3794708251953125, + "peak_rss_mib": 64.4921875, + "persistent_state_mib": 0.375762939453125, "work_shape": [ 108, 192 @@ -44,9 +47,10 @@ }, { "mode": "elastic", + "pixelate": false, "width": 1280, "height": 720, - "frames": 120, + "frames": 60, "config": { "intensity": 100, "horizontal": 100, @@ -59,18 +63,19 @@ "speed": 0.35, "coherence": 80, "seed": 17, + "pixelate": false, "block_size": 4, "mosh": 60 }, - "mean_ms": 13.62421634172127, - "p50_ms": 12.938760000452021, - "p95_ms": 15.432022549703106, - "max_ms": 17.359763000058592, - "processor_fps": 73.39871702842183, + "mean_ms": 10.508820399991237, + "p50_ms": 10.509210498639732, + "p95_ms": 10.741252650950628, + "max_ms": 11.097615999460686, + "processor_fps": 95.15815875974376, "seam_updates": 0, "seam_update_mean_ms": null, - "peak_rss_mib": 110.52734375, - "persistent_state_mib": 0.3794708251953125, + "peak_rss_mib": 109.83203125, + "persistent_state_mib": 0.375762939453125, "work_shape": [ 108, 192 @@ -82,9 +87,10 @@ }, { "mode": "seam", + "pixelate": false, "width": 640, "height": 360, - "frames": 120, + "frames": 60, "config": { "intensity": 100, "horizontal": 100, @@ -97,18 +103,19 @@ "speed": 0.35, "coherence": 80, "seed": 17, + "pixelate": false, "block_size": 4, "mosh": 60 }, - "mean_ms": 22.617298016621135, - "p50_ms": 3.8585624997722334, - "p95_ms": 117.81829465030569, - "max_ms": 120.20515199947113, - "processor_fps": 44.213946301857725, - "seam_updates": 20, - "seam_update_mean_ms": 116.50683889988613, - "peak_rss_mib": 63.53515625, - "persistent_state_mib": 0.5376739501953125, + "mean_ms": 16.714707082913566, + "p50_ms": 2.2866194995003752, + "p95_ms": 89.15058604779915, + "max_ms": 89.45012199910707, + "processor_fps": 59.82755157116929, + "seam_updates": 10, + "seam_update_mean_ms": 88.90546039947367, + "peak_rss_mib": 63.57421875, + "persistent_state_mib": 0.533966064453125, "work_shape": [ 108, 192 @@ -120,9 +127,10 @@ }, { "mode": "seam", + "pixelate": false, "width": 1280, "height": 720, - "frames": 120, + "frames": 60, "config": { "intensity": 100, "horizontal": 100, @@ -135,18 +143,19 @@ "speed": 0.35, "coherence": 80, "seed": 17, + "pixelate": false, "block_size": 4, "mosh": 60 }, - "mean_ms": 32.12750047495471, - "p50_ms": 13.086824499623617, - "p95_ms": 127.86849640019682, - "max_ms": 131.97810400015442, - "processor_fps": 31.1259819536711, - "seam_updates": 20, - "seam_update_mean_ms": 127.5789061499836, - "peak_rss_mib": 108.13671875, - "persistent_state_mib": 0.5376739501953125, + "mean_ms": 26.341212083085946, + "p50_ms": 9.054444499270176, + "p95_ms": 114.34280774901708, + "max_ms": 116.21195299812825, + "processor_fps": 37.9633251820676, + "seam_updates": 10, + "seam_update_mean_ms": 112.72520959973917, + "peak_rss_mib": 110.17578125, + "persistent_state_mib": 0.533966064453125, "work_shape": [ 108, 192 diff --git a/benchmarks/futurecut.json b/benchmarks/futurecut.json new file mode 100644 index 0000000..293cd9a --- /dev/null +++ b/benchmarks/futurecut.json @@ -0,0 +1,242 @@ +{ + "recorded_utc": "2026-10-26T09:44:04.393418+00:00", + "platform": "Linux-7.1.13+parrot7-amd64-x86_64-with-glibc2.41", + "logical_cpus": 22, + "scope": "synthetic moving RGB; push() only; fresh process per mode/size; not end-to-end render FPS", + "results": [ + { + "mode": "smudge", + "width": 640, + "height": 360, + "frames": 90, + "config": { + "lookahead": 0.6, + "gap": 12, + "cutouts": 1, + "spawn_every": 24, + "size": 18, + "pick": "coherent", + "min_motion": 2, + "max_motion": 25, + "similarity": 60, + "shape": "blob", + "seam": "poisson", + "feather": 35, + "hold": 6, + "melt": 14, + "mode": "smudge", + "retention": 70, + "drift": 100, + "seed": 0 + }, + "mean_ms": 6.024855088293811, + "p50_ms": 0.538488500751555, + "p95_ms": 15.753391200269107, + "max_ms": 46.48726899904432, + "processor_fps": 165.97909581974554, + "spawns": 3, + "capacity": 128, + "peak_rss_mib": 97.3515625, + "persistent_state_mib": 14.2822265625, + "python": "3.13.5", + "numpy": "2.2.4", + "opencv": "4.12.0", + "opencv_threads": 1 + }, + { + "mode": "smudge", + "width": 1280, + "height": 720, + "frames": 90, + "config": { + "lookahead": 0.6, + "gap": 12, + "cutouts": 1, + "spawn_every": 24, + "size": 18, + "pick": "coherent", + "min_motion": 2, + "max_motion": 25, + "similarity": 60, + "shape": "blob", + "seam": "poisson", + "feather": 35, + "hold": 6, + "melt": 14, + "mode": "smudge", + "retention": 70, + "drift": 100, + "seed": 0 + }, + "mean_ms": 22.536080544038366, + "p50_ms": 2.1468899976753164, + "p95_ms": 80.75127664633328, + "max_ms": 148.40889100014465, + "processor_fps": 44.373288338487825, + "spawns": 3, + "capacity": 45, + "peak_rss_mib": 204.62109375, + "persistent_state_mib": 57.12890625, + "python": "3.13.5", + "numpy": "2.2.4", + "opencv": "4.12.0", + "opencv_threads": 1 + }, + { + "mode": "smudge", + "width": 1920, + "height": 1080, + "frames": 90, + "config": { + "lookahead": 0.6, + "gap": 12, + "cutouts": 1, + "spawn_every": 24, + "size": 18, + "pick": "coherent", + "min_motion": 2, + "max_motion": 25, + "similarity": 60, + "shape": "blob", + "seam": "poisson", + "feather": 35, + "hold": 6, + "melt": 14, + "mode": "smudge", + "retention": 70, + "drift": 100, + "seed": 0 + }, + "mean_ms": 36.44825179977084, + "p50_ms": 6.615653499466134, + "p95_ms": 69.50340254697949, + "max_ms": 212.32298399991123, + "processor_fps": 27.43615813163054, + "spawns": 3, + "capacity": 18, + "peak_rss_mib": 400.0703125, + "persistent_state_mib": 122.607421875, + "python": "3.13.5", + "numpy": "2.2.4", + "opencv": "4.12.0", + "opencv_threads": 1 + }, + { + "mode": "slide", + "width": 640, + "height": 360, + "frames": 90, + "config": { + "lookahead": 0.6, + "gap": 12, + "cutouts": 1, + "spawn_every": 24, + "size": 18, + "pick": "coherent", + "min_motion": 2, + "max_motion": 25, + "similarity": 60, + "shape": "blob", + "seam": "poisson", + "feather": 35, + "hold": 6, + "melt": 14, + "mode": "slide", + "retention": 70, + "drift": 100, + "seed": 0 + }, + "mean_ms": 4.176484855674466, + "p50_ms": 0.5296085000736639, + "p95_ms": 10.751267600426218, + "max_ms": 41.04346899839584, + "processor_fps": 239.43580177031643, + "spawns": 3, + "capacity": 128, + "peak_rss_mib": 91.3828125, + "persistent_state_mib": 13.623046875, + "python": "3.13.5", + "numpy": "2.2.4", + "opencv": "4.12.0", + "opencv_threads": 1 + }, + { + "mode": "slide", + "width": 1280, + "height": 720, + "frames": 90, + "config": { + "lookahead": 0.6, + "gap": 12, + "cutouts": 1, + "spawn_every": 24, + "size": 18, + "pick": "coherent", + "min_motion": 2, + "max_motion": 25, + "similarity": 60, + "shape": "blob", + "seam": "poisson", + "feather": 35, + "hold": 6, + "melt": 14, + "mode": "slide", + "retention": 70, + "drift": 100, + "seed": 0 + }, + "mean_ms": 18.042871343964684, + "p50_ms": 2.5331059987365734, + "p95_ms": 76.08115485018061, + "max_ms": 141.84232599654933, + "processor_fps": 55.423550993423156, + "spawns": 3, + "capacity": 45, + "peak_rss_mib": 185.26171875, + "persistent_state_mib": 54.4921875, + "python": "3.13.5", + "numpy": "2.2.4", + "opencv": "4.12.0", + "opencv_threads": 1 + }, + { + "mode": "slide", + "width": 1920, + "height": 1080, + "frames": 90, + "config": { + "lookahead": 0.6, + "gap": 12, + "cutouts": 1, + "spawn_every": 24, + "size": 18, + "pick": "coherent", + "min_motion": 2, + "max_motion": 25, + "similarity": 60, + "shape": "blob", + "seam": "poisson", + "feather": 35, + "hold": 6, + "melt": 14, + "mode": "slide", + "retention": 70, + "drift": 100, + "seed": 0 + }, + "mean_ms": 27.272397800132037, + "p50_ms": 6.0923650016775355, + "p95_ms": 49.46985474889516, + "max_ms": 197.8787390034995, + "processor_fps": 36.66710962961822, + "spawns": 3, + "capacity": 18, + "peak_rss_mib": 325.8984375, + "persistent_state_mib": 116.6748046875, + "python": "3.13.5", + "numpy": "2.2.4", + "opencv": "4.12.0", + "opencv_threads": 1 + } + ] +} diff --git a/datamosh_stream.py b/datamosh_stream.py new file mode 100644 index 0000000..82d5667 --- /dev/null +++ b/datamosh_stream.py @@ -0,0 +1,126 @@ +"""MPEG-4 Part 2 prediction reuse with the destination VOP's clock intact. + +Bloom/pulse techniques: github.com/GlitchTools/tomato and + github.com/tiberiuiancu/datamoshing. Independent implementation; no vendored code. +Only the rectangular, non-partitioned streams produced by Effex are supported. +""" +import re + + +class _Bits: + def __init__(self, data: bytes): + self.data = data + self.pos = 0 + + def read(self, count: int) -> int: + if self.pos + count > len(self.data) * 8: + raise ValueError("truncated MPEG-4 header") + value = 0 + for _ in range(count): + value = (value << 1) | ((self.data[self.pos // 8] + >> (7 - self.pos % 8)) & 1) + self.pos += 1 + return value + + +def clock_bits(raw: bytes) -> int: + """Read vop_time_increment_resolution from the first video object layer.""" + match = re.search(b"\x00\x00\x01[\x20-\x2f]", raw) + if match is None: + raise ValueError("MPEG-4 stream has no video object layer") + bits = _Bits(raw[match.end():match.end() + 64]) + bits.read(1) # random_accessible_vol + bits.read(8) # video_object_type_indication + version = 1 + if bits.read(1): + version = bits.read(4) + bits.read(3) + if bits.read(4) == 15: # extended aspect ratio + bits.read(16) + if bits.read(1): # vol_control_parameters + bits.read(3) + if bits.read(1): # vbv_parameters + for count in (15, 1, 15, 1, 15, 1, 3, 11, 1, 15, 1): + bits.read(count) + shape = bits.read(2) + if shape == 3 and version != 1: + bits.read(4) + if shape != 0 or bits.read(1) != 1: + raise ValueError("unsupported MPEG-4 video object layer") + resolution = bits.read(16) + if not resolution: + raise ValueError("invalid MPEG-4 clock resolution") + return max(1, (resolution - 1).bit_length()) + + +def _clock_end(vop: bytes, width: int) -> int: + bits = _Bits(vop) + bits.read(32) # VOP start code + bits.read(2) # coding type + while bits.read(1): # modulo_time_base (unary) + pass + if bits.read(1) != 1: + raise ValueError("invalid MPEG-4 time marker") + bits.read(width) + if bits.read(1) != 1: + raise ValueError("invalid MPEG-4 time marker") + return bits.pos + + +def retime_vop(donor: bytes, target: bytes, width: int) -> bytes: + """Reuse donor prediction bits with target time, without moving slice bits. + + Unary clock rollovers can change header length. Leave those slots alone + rather than shifting byte-aligned resync markers inside the coded payload. + """ + donor_end = _clock_end(donor, width) + target_end = _clock_end(target, width) + if donor_end != target_end: + return target + # Only the small timestamp prefix is parsed. Shift the payload as an integer + # rather than materializing a many-megabyte string of individual bits. + donor_size = len(donor) * 8 + target_size = len(target) * 8 + prefix = int.from_bytes(target, "big") >> (target_size - target_end) + type_shift = target_end - 34 + prefix = (prefix & ~(3 << type_shift)) | ((donor[4] >> 6) << type_shift) + payload_size = donor_size - donor_end + payload = int.from_bytes(donor, "big") & ((1 << payload_size) - 1) + size = target_end + payload_size + padding = (-size) % 8 + return (((prefix << payload_size) | payload) << padding).to_bytes( + (size + padding) // 8, "big") + + +def bloom_pvops(raw: bytes, repeat: int, window: int) -> bytes: + """Replace, not insert, P-VOP slots with cyclic bursts of prediction data. + + Every burst captures a fresh short run. This retains the frame count and + clock while repeatedly applying the same motion/residuals to stale pixels. + I-VOPs reset the burst so an explicitly retained recovery frame still works. + """ + if repeat <= 1: + return raw + width = clock_bits(raw) + starts = [m.start() for m in re.finditer(b"\x00\x00\x01", raw)] + starts.append(len(raw)) + out = bytearray(raw[:starts[0]]) + donors = [] + position = 0 + window = min(max(1, window), repeat) + for start, end in zip(starts, starts[1:]): + unit = raw[start:end] + if unit.startswith(b"\x00\x00\x01\xb6") and len(unit) > 4: + kind = unit[4] >> 6 + if kind == 0: + donors.clear() + position = 0 + elif kind == 1: + if position == 0: + donors.clear() + if len(donors) < window: + donors.append(unit) + unit = retime_vop(donors[position % len(donors)], unit, width) + position = (position + 1) % repeat + out.extend(unit) + return bytes(out) diff --git a/diagnostics.py b/diagnostics.py new file mode 100644 index 0000000..ec23cb1 --- /dev/null +++ b/diagnostics.py @@ -0,0 +1,215 @@ +"""effex diagnostics: one bounded log file you can attach to a github issue. + +Nothing here imports effex, so effex.py and the tests share it without cycles. +Every write is guarded: a broken log must never break a render. Only warnings, +errors and tracebacks are recorded - a clean run writes nothing, so the log +stays readable when something does go wrong. + + EFFEX_LOG_DIR override where the log lives (tests, portable installs) + effex --logs print the path and the last lines + effex --report write a report file (environment + log tail) to attach +""" +import os +import platform +import re +import subprocess +import sys +import threading +import time +import traceback +from pathlib import Path +from typing import Optional + +MAX_BYTES = 512 * 1024 +KEEP = 5 +ISSUES_URL = "https://github.com/kaffeindecaf/effex/issues/new" +LOG_NAME = "effex.log" + +_ANSI = re.compile(r"\033\[[0-9;]*m") +_VERSION = re.compile(r"version\s+(\S+)") + + +def strip_ansi(text: str) -> str: + return _ANSI.sub("", text) + + +def log_dir() -> Path: + """per-user log folder: state dir on linux, Library/Logs on macos, + LOCALAPPDATA on windows.""" + override = os.environ.get("EFFEX_LOG_DIR") + if override: + return Path(override).expanduser() + if os.name == "nt": + base = os.environ.get("LOCALAPPDATA") or str( + Path.home() / "AppData" / "Local") + return Path(base) / "effex" / "logs" + if sys.platform == "darwin": + return Path.home() / "Library" / "Logs" / "effex" + state = os.environ.get("XDG_STATE_HOME") or str( + Path.home() / ".local" / "state") + return Path(state) / "effex" / "logs" + + +def tool_version(binary: Optional[str]) -> str: + """first version token of `ffmpeg -version`, or empty when unknown.""" + if not binary: + return "" + try: + raw = subprocess.run([binary, "-version"], capture_output=True, + text=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return "" + first = (raw.stdout or "").splitlines()[:1] + match = _VERSION.search(first[0]) if first else None + return match.group(1) if match else "" + + +class Session: + """one process' diagnostics file: header, timestamped events, rotation.""" + + def __init__(self, version: str = "?", issues_url: str = ISSUES_URL): + self.version = version + self.issues_url = issues_url + self.fields: dict = {} + self.wrote = False + self._argv: list = [] + self._started = time.time() + self._header_written = False + self._lock = threading.RLock() + + # ---- state ----------------------------------------------------- + def start(self, version: Optional[str] = None, + argv: Optional[list] = None, + cwd: Optional[str] = None) -> "Session": + if version: + self.version = version + self._argv = list(argv if argv is not None else sys.argv[1:]) + self._started = time.time() + self.fields.update({ + "command": ("effex " + " ".join(self._argv)).strip(), + "cwd": cwd or os.getcwd(), + "platform": f"{platform.platform()} ({_os_name()})", + "python": f"{platform.python_version()} ({sys.executable})", + }) + return self + + def bind(self, key: str, value: str) -> None: + self.fields[key] = str(value) + + def path(self) -> Path: + return log_dir() / LOG_NAME + + def exists(self) -> bool: + return self.path().is_file() + + # ---- writing --------------------------------------------------- + def warn(self, message: str, detail: str = "") -> None: + self.event("warn", message, detail) + + def error(self, message: str, detail: str = "") -> None: + self.event("error", message, detail) + + def info(self, message: str, detail: str = "") -> None: + self.event("info", message, detail) + + def exception(self, exc: BaseException, context: str = "") -> None: + text = "".join(traceback.format_exception(type(exc), exc, + exc.__traceback__)) + label = context or type(exc).__name__ + self.event("error", f"{label}: {exc}", text.rstrip()) + + def event(self, level: str, message: str, detail: str = "") -> None: + """one record; never raises. a failed write is silently dropped.""" + with self._lock: + try: + target = self.path() + target.parent.mkdir(parents=True, exist_ok=True) + self._rotate(target) + chunks = [] + if not self._header_written: + chunks.append(self.header()) + self._header_written = True + stamp = time.strftime("%Y-%m-%d %H:%M:%S") + chunks.append(f"{stamp} {level:<5} {_one_line(message)}") + for line in str(detail or "").splitlines(): + chunks.append(" | " + _one_line(line)) + with target.open("a", encoding="utf-8", errors="replace") as fh: + fh.write("\n".join(chunks) + "\n") + self.wrote = True + except Exception: + return + + def header(self) -> str: + stamp = time.strftime("%Y-%m-%d %H:%M:%S %Z") + lines = [f"==== effex {self.version} {stamp} ===="] + for key, value in self.fields.items(): + lines.append(f"{key:<9} {value}") + for key in ("ffmpeg", "ffprobe"): + value = self.fields.get(key, "") + if value: + version = tool_version(value) + lines.append(f"{key + ' v':<9} {version or 'unknown'}") + return "\n".join(lines) + + def _rotate(self, target: Path) -> None: + try: + if not target.is_file() or target.stat().st_size <= MAX_BYTES: + return + except OSError: + return + for index in range(KEEP, 0, -1): + src = target if index == 1 else target.with_name( + f"{target.name}.{index - 1}") + dst = target.with_name(f"{target.name}.{index}") + if src.exists(): + try: + os.replace(src, dst) + except OSError: + pass + + # ---- reading --------------------------------------------------- + def tail(self, lines: int = 40) -> str: + target = self.path() + if not target.is_file(): + return "" + try: + with target.open("r", encoding="utf-8", errors="replace") as fh: + content = fh.readlines() + except OSError: + return "" + return "".join(content[-max(1, lines):]).rstrip() + + def report(self, destination: Optional[Path] = None) -> Path: + """a single self-contained file: what broke, where, with what tools.""" + stamp = time.strftime("%Y%m%d-%H%M%S") + if destination is None: + destination = self.path().with_name(f"effex-report-{stamp}.log") + destination = Path(destination).expanduser() + destination.parent.mkdir(parents=True, exist_ok=True) + head = [ + "effex diagnostics report", + f"generated {time.strftime('%Y-%m-%d %H:%M:%S %Z')}", + f"issues {self.issues_url}", + "", + self.header(), + "", + f"log file {self.path()}", + "---- log tail ----", + ] + body = self.tail(200) or "(nothing was logged in this file)" + destination.write_text("\n".join(head) + "\n" + body + "\n", + encoding="utf-8", errors="replace") + return destination + + +def _os_name() -> str: + name = platform.system().lower() + return {"windows": "windows", "darwin": "macos"}.get(name, "linux") + + +def _one_line(text: str) -> str: + """log lines stay one line per event so `grep error` works""" + return strip_ansi(str(text)).replace("\r", "").strip() + + +LOG = Session() diff --git a/effex.cmd b/effex.cmd new file mode 100644 index 0000000..fdfcebb --- /dev/null +++ b/effex.cmd @@ -0,0 +1,10 @@ +@echo off +rem effex launcher for windows: python effex.py with every argument passed on. +rem .py files are not executable here, so this shim is what windows users run. +setlocal +set "here=%~dp0" +set "py=python" +where py >nul 2>nul && set "py=py -3" +if exist "%here%.venv\Scripts\python.exe" set py="%here%.venv\Scripts\python.exe" +%py% "%here%effex.py" %* +exit /b %errorlevel% diff --git a/effex.py b/effex.py index 3362ee0..a52fca4 100755 --- a/effex.py +++ b/effex.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # effex - terminal menu for weird video effects -# v0.11.0 - Made by kaffeindecaf +# v0.16.0 - Made by kaffeindecaf # # interactive: ./effex.py # scripted: ./effex.py stretch input/clip.mp4 @@ -18,8 +18,10 @@ import argparse import difflib +import importlib.util import json import math +import platform import random import os import re @@ -34,8 +36,10 @@ from typing import Callable, Optional, cast from stateful_effects import CausalityInversion, ElasticCollapse, TemporalSediment +from future_cut import FutureCut +from diagnostics import ISSUES_URL, LOG, log_dir -VERSION = "0.12.0" +VERSION = "0.16.0" ROOT = Path(os.path.dirname(os.path.realpath(__file__))) # the folders can be pointed elsewhere (env or --input-dir/--output-dir); @@ -126,12 +130,14 @@ def ok(text: str) -> None: _say(" " + C_GRN + "\u2713" + NC + " " + text) -def err(text: str) -> None: +def err(text: str, detail: str = "") -> None: + LOG.error(text, detail) sys.stdout.flush() print(" " + C_RED + "\u2717" + NC + " " + text, file=sys.stderr) -def warn(text: str) -> None: +def warn(text: str, detail: str = "") -> None: + LOG.warn(text, detail) if not _QUIET: sys.stdout.flush() print(" " + C_AMB + "\u26a0" + NC + " " + text, file=sys.stderr) @@ -169,21 +175,272 @@ def human_time(seconds: Optional[float]) -> str: # --------------------------------------------------------------------------- -# ffmpeg discovery + install prompt +# platform + requirements discovery # --------------------------------------------------------------------------- +def host_os() -> str: + """the three platforms effex knows how to install packages on""" + name = platform.system().lower() + if name == "windows": + return "windows" + if name == "darwin": + return "macos" + return "linux" + + +def linux_distro() -> str: + try: + text = Path("/etc/os-release").read_text(encoding="utf-8", + errors="ignore") + except OSError: + return "" + for line in text.splitlines(): + if line.startswith("ID="): + return line[3:].strip().strip('"').lower() + return "" + + +def is_windows() -> bool: + return os.name == "nt" + + +@dataclass(frozen=True) +class Installer: + """one package manager: where it runs, what to look for, how to call it""" + + manager: str + os_name: str + label: str + template: tuple + packages: dict = field(default_factory=dict) + + INSTALLERS = [ - ("apt-get", ["sudo", "apt-get", "install", "-y", "ffmpeg"], - "debian / ubuntu / parrot / mint"), - ("dnf", ["sudo", "dnf", "install", "-y", "ffmpeg"], "fedora / rhel"), - ("pacman", ["sudo", "pacman", "-S", "--noconfirm", "ffmpeg"], - "arch / manjaro"), - ("zypper", ["sudo", "zypper", "install", "-y", "ffmpeg"], "opensuse"), - ("apk", ["sudo", "apk", "add", "ffmpeg"], "alpine"), - ("brew", ["brew", "install", "ffmpeg"], "macos / linuxbrew"), + Installer("winget", "windows", "windows 10/11 - winget", ( + "winget", "install", "-e", "--id", "{pkg}", + "--accept-package-agreements", "--accept-source-agreements"), + {"ffmpeg": "Gyan.FFmpeg", "ffprobe": "Gyan.FFmpeg"}), + Installer("choco", "windows", "windows - chocolatey", ( + "choco", "install", "-y", "{pkg}"), + {"ffmpeg": "ffmpeg", "ffprobe": "ffmpeg"}), + Installer("scoop", "windows", "windows - scoop", ( + "scoop", "install", "{pkg}"), + {"ffmpeg": "ffmpeg", "ffprobe": "ffmpeg"}), + Installer("brew", "macos", "macos - homebrew", ( + "brew", "install", "{pkg}"), + {"ffmpeg": "ffmpeg", "ffprobe": "ffmpeg"}), + Installer("brew", "linux", "linuxbrew", ( + "brew", "install", "{pkg}"), + {"ffmpeg": "ffmpeg", "ffprobe": "ffmpeg"}), + Installer("apt-get", "linux", "debian / ubuntu / parrot / mint", ( + "sudo", "apt-get", "install", "-y", "{pkg}"), + {"ffmpeg": "ffmpeg", "ffprobe": "ffmpeg"}), + Installer("dnf", "linux", "fedora / rhel", ( + "sudo", "dnf", "install", "-y", "{pkg}"), + {"ffmpeg": "ffmpeg", "ffprobe": "ffmpeg"}), + Installer("pacman", "linux", "arch / manjaro", ( + "sudo", "pacman", "-S", "--noconfirm", "{pkg}"), + {"ffmpeg": "ffmpeg", "ffprobe": "ffmpeg"}), + Installer("zypper", "linux", "opensuse", ( + "sudo", "zypper", "install", "-y", "{pkg}"), + {"ffmpeg": "ffmpeg", "ffprobe": "ffmpeg"}), + Installer("apk", "linux", "alpine", ( + "sudo", "apk", "add", "{pkg}"), + {"ffmpeg": "ffmpeg", "ffprobe": "ffmpeg"}), +] + +# where the windows installers drop binaries that a fresh shell would find, +# but this process (started before the install) does not have on PATH yet +WINDOWS_TOOL_DIRS = ( + ("LOCALAPPDATA", "Microsoft/WinGet/Links"), + ("PROGRAMFILES", "ffmpeg/bin"), + ("PROGRAMDATA", "chocolatey/bin"), + ("USERPROFILE", "scoop/shims"), +) + + +def windows_tool_dirs() -> list: + if not is_windows(): + return [] + folders = [Path("C:/ffmpeg/bin")] + for env, tail in WINDOWS_TOOL_DIRS: + root = os.environ.get(env) + if root: + folders.append(Path(root) / tail) + return [folder for folder in folders if folder.is_dir()] + + +def which_tool(name: str) -> Optional[str]: + """PATH lookup that also knows the windows installer folders.""" + hit = shutil.which(name) + if hit: + return hit + extra = os.pathsep.join(str(folder) for folder in windows_tool_dirs()) + return shutil.which(name, path=extra) if extra else None + + +def refresh_windows_path() -> bool: + """re-read the PATH an installer wrote to the registry (windows only).""" + if not is_windows(): + return False + try: + import winreg + except ImportError: + return False + chunks = [os.environ.get("PATH", "")] + for hive, key_path in ( + (winreg.HKEY_CURRENT_USER, r"Environment"), # type: ignore[attr-defined] + (winreg.HKEY_LOCAL_MACHINE, # type: ignore[attr-defined] + r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment")): + try: + with winreg.OpenKey(hive, key_path) as key: # type: ignore[attr-defined] + value, _kind = winreg.QueryValueEx(key, "Path") # type: ignore[attr-defined] + except OSError: + continue + chunks.append(str(value)) + merged = [] + for chunk in chunks: + for piece in chunk.split(os.pathsep): + if piece and piece not in merged: + merged.append(piece) + os.environ["PATH"] = os.pathsep.join(merged) + return True + + +def prepare_console() -> None: + """make stdout/stderr able to carry the tick and warning glyphs, and put a + windows console into utf-8 + ansi mode. a no-op when nothing needs it.""" + for stream in (sys.stdout, sys.stderr): + try: + encoding = getattr(stream, "encoding", "") or "ascii" + "\u2713".encode(encoding) + except (UnicodeEncodeError, LookupError): + # windows pipes default to cp1252 and a C locale to ascii - both + # crash on the first tick, so switch the stream to utf-8 + try: + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError, OSError): + pass + if not is_windows(): + return + try: + import ctypes + except ImportError: + return + try: + kernel32 = getattr(ctypes, "windll").kernel32 + kernel32.SetConsoleOutputCP(65001) + kernel32.SetConsoleCP(65001) + for handle in (-11, -12): # stdout, stderr + mode = ctypes.c_uint32() + if kernel32.GetConsoleMode(kernel32.GetStdHandle(handle), + ctypes.byref(mode)): + kernel32.SetConsoleMode( + kernel32.GetStdHandle(handle), + mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING + except Exception: + pass + + +# --------------------------------------------------------------------------- +# requirements +# --------------------------------------------------------------------------- + +OPTIONAL_MODULES = [ + ("numpy", "sediment, collapse, inversion", "requirements-stateful.txt"), + ("cv2", "collapse seam detail", "requirements-collapse.txt"), + ("av", "inversion timestamps", "requirements-inversion.txt"), ] +@dataclass +class Requirement: + key: str + label: str + required: bool + detail: str = "" + unlocks: str = "" + installable: bool = True + pip_file: str = "" + + @property + def found(self) -> bool: + return bool(self.detail) + + +def module_available(name: str) -> bool: + try: + return importlib.util.find_spec(name) is not None + except (ImportError, ValueError): + return False + + +def check_requirements(tools: Optional["Tools"] = None) -> list: + """every prerequisite, in the order the menu lists them.""" + tools = tools or find_tools() + python_fine = sys.version_info >= (3, 8) + reqs = [ + Requirement("ffmpeg", "ffmpeg", True, tools.ffmpeg or "", + "renders every effect"), + Requirement("ffprobe", "ffprobe", True, tools.ffprobe or "", + "clip details, datamosh, frame effects"), + Requirement("python", "python", True, + platform.python_version() if python_fine + else f"{platform.python_version()} is too old, need 3.8+", + "runs effex", installable=False), + ] + for module, unlocks, pip_file in OPTIONAL_MODULES: + reqs.append(Requirement( + module, "opencv" if module == "cv2" else module, False, + "installed" if module_available(module) else "", + unlocks, installable=False, pip_file=pip_file)) + return reqs + + +def missing_requirements(reqs: list) -> list: + return [r for r in reqs if r.required and not r.found] + + +def requirement_summary(reqs: list) -> str: + missing = missing_requirements(reqs) + if not missing: + return f"all {sum(1 for r in reqs if r.required)} installed" + return (f"{len(missing)} missing: " + + ", ".join(r.label for r in missing)) + + +def requirement_lines(reqs: list, width: int) -> list: + """one row per requirement: a tick, the name, then where/what it is.""" + lines = [] + for req in reqs: + if req.found: + mark = C_GRN + "\u2713" + NC + detail = req.detail if req.required else "optional, installed" + elif req.required: + mark = C_RED + "\u2717" + NC + detail = req.detail or "not on PATH" + else: + mark = C_DIM + "\u00b7" + NC + detail = f"optional, not installed ({req.unlocks})" + lines.append(" " + mark + " " + fit(req.label, 8) + " " + C_DIM + + trunc(detail, max(8, width - 16)) + NC) + return lines + + +def pip_hint(reqs: list) -> str: + files = [r.pip_file for r in reqs if not r.required and not r.found + and r.pip_file] + if not files: + return "" + return "python -m pip install -r " + " -r ".join(files) + + +# --------------------------------------------------------------------------- +# ffmpeg discovery + install prompt +# --------------------------------------------------------------------------- + @dataclass class Tools: ffmpeg: Optional[str] = None @@ -191,61 +448,103 @@ class Tools: @property def ok(self) -> bool: - return bool(self.ffmpeg) + return bool(self.ffmpeg and self.ffprobe) def find_tools() -> Tools: - return Tools(shutil.which("ffmpeg"), shutil.which("ffprobe")) + return Tools(which_tool("ffmpeg"), which_tool("ffprobe")) -def detect_installer() -> Optional[tuple]: - for name, cmd, label in INSTALLERS: - if shutil.which(name): - return name, cmd, label +def installers_for(os_name: Optional[str] = None) -> list: + target = os_name or host_os() + return [item for item in INSTALLERS if item.os_name == target] + + +def detect_installer(os_name: Optional[str] = None, + which: Optional[Callable] = None) -> Optional[Installer]: + finder = which or shutil.which + for installer in installers_for(os_name): + if finder(installer.manager): + return installer return None -def install_ffmpeg(tools: Tools, interactive: bool) -> Tools: +def install_commands(installer: Installer, missing: list) -> list: + """one command per distinct package, so a per-package template stays right""" + seen, packages = set(), [] + for key in missing: + package = installer.packages.get(key) + if package and package not in seen: + seen.add(package) + packages.append(package) + return [[package if part == "{pkg}" else part + for part in installer.template] for package in packages] + + +def install_text(installer: Installer, missing: list) -> str: + commands = install_commands(installer, missing) + return " && ".join(" ".join(cmd) for cmd in commands) + + +def install_requirements(tools: Tools, interactive: bool) -> Tools: """returns freshly detected Tools (unchanged if install skipped/failed)""" - hit = detect_installer() - if not hit: - err("no package manager found - install ffmpeg manually") + reqs = check_requirements(tools) + missing = missing_requirements(reqs) + if not missing: + return tools + names = " + ".join(req.label for req in missing) + installer = detect_installer() + if not installer: + err(f"no package manager found - install {names} manually") hint("https://ffmpeg.org/download.html") return tools - name, cmd, label = hit - shown = " ".join(cmd) + keys = [req.key for req in missing if req.installable] + shown = install_text(installer, keys) if keys else "" + if not keys: + err(f"{names} cannot be installed by effex") + hint("install python 3.8+ from https://www.python.org/downloads/") + return tools if not interactive: - err("ffmpeg is required to process video") - hint(f"install it with: {shown} ({label})") + err(f"{names} not on PATH - required to process video") + if shown: + hint(f"install it with: {shown} ({installer.label})") return tools _say() - warn("ffmpeg was not found on PATH") - info(f"detected package manager: {B}{name}{NC} ({label})") - _say(" " + C_DIM + "will run: " + NC + C_FROST + shown + NC) - if not ask_yes_no("install ffmpeg now?", default=True): - warn("skipped install - effex cannot process anything without ffmpeg") - hint(f"do it later with: {shown}") + warn(f"missing: {names}") + info(f"detected package manager: {B}{installer.manager}{NC} " + f"({installer.label})") + if shown: + _say(" " + C_DIM + "will run: " + NC + C_FROST + shown + NC) + if not ask_yes_no(f"install {names} now?", default=True): + warn(f"skipped install - effex cannot process anything without {names}") + if shown: + hint(f"do it later with: {shown}") return tools _say() - try: - rc = subprocess.run(cmd).returncode - except (OSError, KeyboardInterrupt) as exc: - err(f"install failed: {exc}") - return tools - - if rc != 0: - err(f"installer exited with code {rc}") - return tools + for command in install_commands(installer, keys): + try: + rc = subprocess.run(command).returncode + except (OSError, KeyboardInterrupt) as exc: + err(f"install failed: {exc}") + return tools + if rc != 0: + err(f"installer exited with code {rc} ({' '.join(command)})") + if is_windows() and installer.manager == "choco": + hint("chocolatey needs an elevated terminal - or use winget") + return tools + refresh_windows_path() fresh = find_tools() - if fresh.ok: - ok("ffmpeg installed") + left = missing_requirements(check_requirements(fresh)) + if left: + err("still not on PATH: " + ", ".join(r.label for r in left) + + " - open a new shell and retry") else: - err("ffmpeg still not on PATH - open a new shell and retry") + ok("requirements installed") return fresh @@ -253,7 +552,7 @@ def check_tools(interactive: bool) -> Tools: tools = find_tools() if tools.ok: return tools - return install_ffmpeg(tools, interactive) + return install_requirements(tools, interactive) # --------------------------------------------------------------------------- @@ -917,9 +1216,22 @@ def build_posterize(ctx: EffectContext) -> Plan: ) +DATAMOSH_PRESETS = { + "standard": {"cut": "heavy", "fill": "next", "hold": 150, "grit": 2, + "bloom": 1, "bloom_window": 1}, + "heavy": {"cut": "heavy", "fill": "chaos", "hold": 12, "grit": 18, + "bloom": 12, "bloom_window": 2}, +} + + DATAMOSH_PARAMS = ( Param("second", "second video", "append another video before the codec melt", "video", "none"), + Param("preset", "mosh preset", "load tuned codec settings; individual knobs stay editable", + "choice", "heavy", choices=( + ("heavy", "heavy", "crushed encode, no recovery frames, repeated motion bursts"), + ("standard", "standard", "classic keyframe removal without motion blooming"), + )), Param("second_start", "second start", "seconds into the second video", "float", 0.0, minimum=0.0, maximum=3600.0, step=0.5), Param("second_length", "second length", "seconds to use; 0 means to the end", @@ -936,16 +1248,20 @@ def build_posterize(ctx: EffectContext) -> Plan: )), Param("survival", "keyframe survival", "chance a keyframe survives in dice mode", "percent", 25, minimum=0, maximum=100, step=5), - Param("fill", "fill", "what fills each cut keyframe", "choice", "next", + Param("fill", "fill", "what fills each cut keyframe", "choice", "chaos", choices=( ("next", "next frame", "drift into the next motion frame"), ("prev", "previous frame", "freeze on the last clean frame"), ("chaos", "chaos", "a random motion frame from anywhere"), )), Param("hold", "hold", "frames between keyframes - how long a melt runs", - "int", 150, minimum=2, maximum=600, step=2), + "int", 12, minimum=2, maximum=600, step=2), Param("grit", "grit", "how crunchy the intermediate encode is", - "int", 2, minimum=1, maximum=31, step=1), + "int", 18, minimum=1, maximum=31, step=1), + Param("bloom", "motion bloom", "reuse prediction data over this many frame slots; 1 disables", + "int", 12, minimum=1, maximum=60, step=1), + Param("bloom_window", "motion window", "number of motion frames cycled inside each bloom burst", + "int", 2, minimum=1, maximum=12, step=1), Param("seed", "random seed", "0 uses a repeatable seed derived from the input", "int", 0, minimum=0, maximum=2147483647, step=1), Param("audio", "audio", "audio stream to keep when rendering", @@ -961,8 +1277,8 @@ def build_posterize(ctx: EffectContext) -> Plan: def build_datamosh(ctx: EffectContext) -> Plan: opts = ctx.opts cut = str(opts.get("cut", "heavy")) - fill = str(opts.get("fill", "next")) - hold = _clamp_int(ctx.opts.get("hold", 150), 150, 2, 600) + fill = str(opts.get("fill", "chaos")) + hold = _clamp_int(ctx.opts.get("hold", 12), 12, 2, 600) second = str(opts.get("second", "none")) width, height = _source_dims(ctx.clip, ctx.frame) suffix = "_DATAMOSHED" @@ -975,6 +1291,8 @@ def build_datamosh(ctx: EffectContext) -> Plan: "real datamosh - i-vops are cut from the mpeg4 bitstream and " "the decoder smears each run of motion vectors across the gap", f"{cut} cut, {fill} fill, {hold}-frame keyframe interval", + f"{opts.get('preset', 'heavy')} preset: grit {opts.get('grit', 18)}, " + f"motion bloom {opts.get('bloom', 12)} / window {opts.get('bloom_window', 2)}", "removed keyframes are replaced by a fill frame, so the clip " "keeps its length and its audio sync", ] @@ -992,8 +1310,8 @@ def build_datamosh(ctx: EffectContext) -> Plan: notes.append("dice: keyframe survival chance is configurable, " "seeded from the input or a chosen seed") return Plan( - vf=(f"pass 1: mpeg4 keyint={hold} i-vop stream, then {cut} i-vop " - f"strip with {fill} fill, pass 2: straight remux"), + vf=(f"pass 1: mpeg4 keyint={hold}, {cut} cut, {fill} fill, " + f"bloom={opts.get('bloom', 12)}, pass 2: remux"), out_suffix=suffix, width=width, height=height, @@ -1002,15 +1320,20 @@ def build_datamosh(ctx: EffectContext) -> Plan: def strip_ivops(raw: bytes, cut: str, fill: str, seed: int, - survival: int = 25) -> bytes: + survival: int = 25, bloom: int = 1, + bloom_window: int = 1) -> bytes: """cut i-vops out of an mpeg4 part 2 (m4v) bitstream. cut: heavy keeps only the first i-vop, medium every 2nd, light every 3rd, dice keeps keyframes at the configured survival percentage, with deterministic randomness from seed. every removed keyframe is replaced by a fill frame - next (the following p-vop), prev (the last clean p-vop) or chaos (a random p-vop, seeded) - so the frame - count and the audio sync survive the cut. headers (vos/vol/gov) and - p-vops pass through untouched.""" + count and the audio sync survive the cut. Bloom reuses prediction data + across existing frame slots, preserving their MPEG-4 time headers.""" + from bisect import bisect_left + from datamosh_stream import bloom_pvops, clock_bits, retime_vop + + time_width = clock_bits(raw) sc = b"\x00\x00\x01" units = [] i = 0 @@ -1062,28 +1385,29 @@ def ctype(unit: bytes) -> int: rng.shuffle(pool) out = bytearray() out += b"".join(units[:first_i + 1]) - out += b"".join(units[idx] for idx in pool) - return bytes(out) + targets = [unit for unit in units[first_i + 1:] if ctype(unit) >= 0] + for donor, target in zip(pool, targets): + out += retime_vop(units[donor], target, time_width) + return bloom_pvops(bytes(out), bloom, bloom_window) out = bytearray() for idx, unit in enumerate(units): if idx not in drop: out += unit continue + if not pvops: + out += unit # an all-keyframe clip has no prediction data to borrow + continue + position = bisect_left(pvops, idx) if fill == "next": - for later in units[idx + 1:]: - if ctype(later) == 1: - out += later - break + donor_idx = pvops[min(position, len(pvops) - 1)] elif fill == "prev": - for earlier in reversed(units[:idx]): - if ctype(earlier) == 1: - out += earlier - break + donor_idx = pvops[max(0, position - 1)] else: # chaos - if pvops: - out += units[rng.choice(pvops)] - return bytes(out) + donor_idx = rng.choice(pvops) + donor = units[donor_idx] + out += retime_vop(donor, unit, time_width) + return bloom_pvops(bytes(out), bloom, bloom_window) def resolve_second_video(raw: object, input_dir: Path, @@ -1107,14 +1431,84 @@ def resolve_second_video(raw: object, input_dir: Path, return candidate.resolve(), "" +def run_blockswap(ctx: EffectContext, clip: Clip, tools: Tools, args, + dst: Path, run_encode, on_progress, animate) -> tuple: + """apply the swaprect layout, then optionally mosh the whole stream.""" + effect = EFFECT_BY_KEY["blockswap"] + opts = ctx.opts + plan = build_plan(effect, ctx) + mosh = _clamp_int(opts.get("mosh", 0), 0, 0, 100) + if mosh <= 0: + # no codec pass requested: the filters plus the normal encoder are enough + inner = EffectContext(clip=clip, width=ctx.width, height=ctx.height, + suffix=ctx.suffix, crf=ctx.crf, + preset=ctx.preset, + opts={effect.key: dict(opts)}) + cmd, _plan = build_command(Chain((effect,)), inner, clip, tools, dst) + rc, stderr, took = run_encode(cmd, clip.duration, on_progress, animate) + return rc == 0, stderr, took + + grit = _clamp_int(opts.get("mosh_grit", 12), 12, 1, 31) + hold = _clamp_int(opts.get("mosh_hold", 12), 12, 2, 600) + # light by default: higher mosh keeps fewer recovery keyframes and blooms more + survival = max(5, 100 - mosh) + bloom = max(1, min(8, int(round(mosh / 12.0)))) + chosen_seed = _clamp_int(opts.get("seed", 0), 0, 0, 2147483647) + seed = chosen_seed or (20260925 + (clip.size % 10000)) + + raw = Path(args.output_dir) / f".{clip.path.stem}_bswap_{os.getpid()}.m4v" + stderr_parts, elapsed = [], 0.0 + try: + cmd1 = [tools.ffmpeg, "-hide_banner", "-nostdin", "-y", + "-i", str(clip.path), "-vf", plan.vf, "-an", + "-c:v", "mpeg4", "-q:v", str(grit), "-g", str(hold), + "-bf", "0", "-f", "m4v", "-progress", "pipe:1", + "-nostats", "-loglevel", "error", str(raw)] + rc, stderr, took = run_encode(cmd1, clip.duration, on_progress, + animate) + elapsed += took + stderr_parts.append(stderr) + if rc != 0: + return False, "\n".join(stderr_parts), elapsed + if not raw.exists() or raw.stat().st_size == 0: + return False, "the mosh pass produced no stream", elapsed + raw.write_bytes(strip_ivops(raw.read_bytes(), "dice", "chaos", seed, + survival, bloom=bloom)) + + cmd2 = [tools.ffmpeg, "-hide_banner", "-nostdin", "-y", + "-f", "m4v", "-i", str(raw), "-i", str(clip.path)] + cmd2 += ["-map", "0:v:0"] + if clip.audio: + cmd2 += ["-map", "1:a:0?", "-c:a", "aac", "-b:a", "192k", + "-ac", "2"] + else: + cmd2 += ["-an"] + cmd2 += ["-c:v", "copy", "-movflags", "+faststart", + "-progress", "pipe:1", "-nostats", "-loglevel", "error", + str(dst)] + rc, stderr, took = run_encode(cmd2, clip.duration, on_progress, + animate) + elapsed += took + stderr_parts.append(stderr) + if rc != 0: + return False, "\n".join(stderr_parts), elapsed + return True, "\n".join(stderr_parts), elapsed + finally: + try: + if raw.exists(): + raw.unlink() + except OSError: + pass + + def run_datamosh(ctx: EffectContext, clip: Clip, tools: Tools, args, dst: Path, run_encode, on_progress, animate) -> tuple: """encode one or two videos into an mpeg4 stream, edit its i-vops, remux.""" opts = ctx.opts cut = str(opts.get("cut", "heavy")) - fill = str(opts.get("fill", "next")) - hold = _clamp_int(opts.get("hold", 150), 150, 2, 600) - grit = _clamp_int(opts.get("grit", 2), 2, 1, 31) + fill = str(opts.get("fill", "chaos")) + hold = _clamp_int(opts.get("hold", 12), 12, 2, 600) + grit = _clamp_int(opts.get("grit", 18), 18, 1, 31) survival = _clamp_int(opts.get("survival", 25), 25, 0, 100) chosen_seed = _clamp_int(opts.get("seed", 0), 0, 0, 2147483647) second, problem = resolve_second_video(opts.get("second", "none"), @@ -1180,8 +1574,10 @@ def run_datamosh(ctx: EffectContext, clip: Clip, tools: Tools, args, return False, "\n".join(stderr_parts), elapsed if not raw.exists() or raw.stat().st_size == 0: return False, "pass 1 produced no stream", elapsed - raw.write_bytes(strip_ivops(raw.read_bytes(), cut, fill, seed, - survival)) + raw.write_bytes(strip_ivops( + raw.read_bytes(), cut, fill, seed, survival, + bloom=int(opts.get("bloom", 12)), + bloom_window=int(opts.get("bloom_window", 2)))) cmd2 = [tools.ffmpeg, "-hide_banner", "-nostdin", "-y", "-f", "m4v", "-i", str(raw)] @@ -1607,39 +2003,109 @@ def build_neon(ctx: EffectContext) -> Plan: BLOCKSWAP_PARAMS = ( - Param("size", "block size", "how big the swapped blocks are " - "(percent of the frame)", "int", 25, minimum=10, maximum=60, + Param("size", "block size", "how big each swapped block is " + "(percent of the frame)", "int", 20, minimum=5, maximum=60, step=5), - Param("speed", "speed", "how fast the blocks travel", "choice", "slow", - choices=(("slow", "slow", "a lazy drift"), - ("fast", "fast", "a nervous slide"))), + Param("blocks", "blocks", "how many block pairs trade places", + "int", 3, minimum=1, maximum=8, step=1), + Param("teleport", "teleport secs", "seconds per block layout; 0 keeps one layout forever", + "float", 3.0, minimum=0, maximum=30, step=0.5), + Param("mosh", "mosh strength", "light codec datamosh over the whole clip; 0 disables", + "percent", 0, minimum=0, maximum=100, step=5), + Param("mosh_grit", "mosh grit", "how crunchy the intermediate mosh encode is", + "int", 12, minimum=1, maximum=31, step=1), + Param("mosh_hold", "mosh hold", "frames between keyframes in the mosh encode", + "int", 12, minimum=2, maximum=600, step=2), + Param("seed", "random seed", "0 derives a repeatable layout from the clip", + "int", 0, minimum=0, maximum=2147483647, step=1), ) -BLOCKSWAP_PERIOD = {"slow": 2.0, "fast": 0.5} - def build_blockswap(ctx: EffectContext) -> Plan: - size = _clamp_int(ctx.opts.get("size", 25), 25, 10, 60) - speed = str(ctx.opts.get("speed", "slow")) - period = BLOCKSWAP_PERIOD.get(speed, 2.0) + size = _clamp_int(ctx.opts.get("size", 20), 20, 5, 60) + blocks = _clamp_int(ctx.opts.get("blocks", 3), 3, 1, 8) + try: + teleport = max(0.0, min(30.0, float(ctx.opts.get("teleport", 3.0)))) + except (TypeError, ValueError): + teleport = 3.0 + mosh = _clamp_int(ctx.opts.get("mosh", 0), 0, 0, 100) + chosen_seed = _clamp_int(ctx.opts.get("seed", 0), 0, 0, 2147483647) w, h = _source_dims(ctx.clip, ctx.frame) if not w or not h: w, h = ctx.width or DEFAULT_W, ctx.height or DEFAULT_H bw = max(2, int(w * size / 100) // 2 * 2) bh = max(2, int(h * size / 100) // 2 * 2) - span = f"(w-{bw})/2" + bw = min(bw, max(2, w // 2 // 2 * 2)) + bh = min(bh, max(2, h // 2 // 2 * 2)) + seed = chosen_seed or (20261025 + (ctx.clip.size % 10000)) + + def apart(a, b): + ax, ay, aw, ah = a + bx, by, bw_, bh_ = b + return (ax + aw <= bx or bx + bw_ <= ax + or ay + ah <= by or by + bh_ <= ay) + + def place(rng, used): + """Pick two rectangles that fit the frame and avoid earlier swaps.""" + max_x, max_y = max(0, w - bw), max(0, h - bh) + for _ in range(96): + x1, y1 = (rng.randint(0, max_x) // 2 * 2, + rng.randint(0, max_y) // 2 * 2) + first = (x1, y1, bw, bh) + if not all(apart(first, rect) for rect in used): + continue + x2, y2 = (rng.randint(0, max_x) // 2 * 2, + rng.randint(0, max_y) // 2 * 2) + second = (x2, y2, bw, bh) + clear = (not (first[0] + bw > second[0] + and second[0] + bw > first[0] + and first[1] + bh > second[1] + and second[1] + bh > first[1])) + if clear and all(apart(second, rect) for rect in used): + return x1, y1, x2, y2 + # Too crowded for a clean layout: keep the deterministic fallback. + return 0, 0, max(0, w - bw) // 2 * 2, max(0, h - bh) // 2 * 2 + + duration = float(ctx.clip.duration or 0.0) + windows = 1 + if teleport > 0 and duration > 0: + whole = int(duration // teleport) + leftover = duration - whole * teleport + windows = max(1, whole + (1 if leftover > 0.2 * teleport else 0)) + # a long clip must not explode into hundreds of swaprect filters + windows = max(1, min(windows, max(1, 48 // blocks))) + + swaps = [] + for window in range(windows): + used = [] + rng = random.Random(seed * 1000003 + window * 97) + for pair in range(blocks): + x1, y1, x2, y2 = place(rng, used) + used += [(x1, y1, bw, bh), (x2, y2, bw, bh)] + swap = (f"swaprect=w={bw}:h={bh}:x1={x1}:y1={y1}" + f":x2={x2}:y2={y2}") + if windows > 1: + start = window * teleport + if window == windows - 1: + swap += f":enable='gte(t,{start:g})'" + else: + swap += (f":enable='between(t,{start:g}," + f"{(window + 1) * teleport:g})'") + swaps.append(swap) return Plan( - vf=(f"swaprect=w={bw}:h={bh}" - f":x1='{span}*(1+sin(2*PI*t/{period:g}))'" - f":y1='(h-{bh})/2'" - f":x2='{span}*(1-sin(2*PI*t/{period:g}))'" - f":y2='(h-{bh})/2'"), + vf=",".join(swaps), out_suffix="_BLOCKSWAP", width=w, height=h, notes=[ - "two blocks slide through each other", - f"{size}% blocks, {speed} travel", + (f"{blocks} block pairs hold each layout, then teleport" + if windows > 1 else + f"{blocks} block pairs trade places once and stay put"), + f"{size}% blocks at seeded positions", + (f"new layout every {teleport:g}s ({windows} windows on this clip)" + if windows > 1 else "one static layout"), + (f"light codec mosh over the whole clip at {mosh}% - teleports " + f"smear into the stream" if mosh else "no codec mosh"), ], ) @@ -1943,9 +2409,11 @@ def build_temporal_sediment(ctx: EffectContext) -> Plan: "percent", 80, minimum=0, maximum=100, step=5), Param("seed", "random seed", "0 derives repeatable elastic phases from the clip", "int", 0, minimum=0, maximum=2147483647, step=1), - Param("block_size", "pixel blocks", "larger blocks make the output chunkier", + Param("pixelate", "pixelate", "finish with chunky pixel blocks; off keeps the smooth remap", + "bool", False), + Param("block_size", "pixel blocks", "larger blocks make the output chunkier; pixelate only", "int", 4, minimum=1, maximum=16, step=1), - Param("mosh", "block carry", "blend prior-frame blocks in moving areas; not codec datamosh", + Param("mosh", "block carry", "blend prior-frame blocks in moving areas; pixelate only", "percent", 60, minimum=0, maximum=100, step=5), ) @@ -1959,13 +2427,103 @@ def build_collapse(ctx: EffectContext) -> Plan: notes=[ "content-aware mode linearly shrinks over the clip and remaps to original dimensions", "seam analysis is capped at 192px; target scales remove at most half of either axis", - "a reduced pixel grid and partial moving-block carry add a restrained codec-mosh texture", + "pixelation is opt-in: off keeps the smooth warp, on adds the pixel grid and block carry", "elastic mode remains available for seeded smooth fields and CPU OpenCV remapping", "needs requirements-collapse.txt; no stateful preview or original VFR PTS", ], ) +ARCHAEOLOGY_PARAMS = ( + Param("lookahead", "look-ahead secs", + "how far the buffer sees ahead; also the output delay", + "float", 0.6, minimum=0.1, maximum=2.0, step=0.1), + Param("gap", "donor gap", + "frames ahead the borrowed cutout comes from", + "int", 18, minimum=1, maximum=60, step=1), + Param("cutouts", "cutouts", + "how many future cutouts can live at once", + "int", 1, minimum=1, maximum=3, step=1), + Param("spawn_every", "spawn every", + "frames between cutout spawns", + "int", 24, minimum=4, maximum=120, step=2), + Param("size", "cutout size", + "borrowed region area as a share of the frame", + "percent", 18, minimum=5, maximum=40, step=1), + Param("pick", "pick", + "which surface a cutout is taken from", "choice", "coherent", + choices=(("coherent", "coherent", + "similar surface, different phase"), + ("dissimilar", "dissimilar", + "the least matching surface under the cap"), + ("random", "random", "any textured region"))), + Param("min_motion", "min motion", + "ignore regions moving slower than this", + "percent", 2, minimum=0, maximum=30, step=1), + Param("max_motion", "max motion", + "ignore regions moving faster than this", + "percent", 25, minimum=1, maximum=100, step=1), + Param("similarity", "similarity", + "how close a surface must look to count as the same thing", + "percent", 60, minimum=0, maximum=100, step=5), + Param("shape", "shape", + "cutout outline", "choice", "blob", + choices=(("blob", "blob", "grown around the seed region"), + ("rect", "rectangle", "one clean rectangle"))), + Param("seam", "seam blend", + "how the cutout edge is merged", "choice", "poisson", + choices=(("poisson", "poisson", "seamless clone into the frame"), + ("none", "none", "plain mask, exact future pixels"))), + Param("feather", "edge feather", + "softness of the cutout border", + "percent", 35, minimum=0, maximum=100, step=5), + Param("hold", "hold frames", + "frames the cutout stays sharp before it dissolves", + "int", 6, minimum=1, maximum=30, step=1), + Param("melt", "melt frames", + "frames the cutout takes to dissolve away", + "int", 14, minimum=2, maximum=60, step=1), + Param("mode", "mosh out", + "how the cutout leaves", "choice", "smudge", + choices=(("smudge", "smudge", + "flow-warped feedback smear"), + ("slide", "dissolve", + "noisy blotch dissolve"))), + Param("retention", "smudge carry", + "how much smeared detail each melt frame keeps", + "percent", 70, minimum=0, maximum=95, step=5), + Param("drift", "flow drift", + "how far the cutout follows its own motion; 0 pins it", + "percent", 100, minimum=0, maximum=100, step=5), + Param("seed", "random seed", + "0 derives repeatable cutout timing from the source", + "int", 0, minimum=0, maximum=2147483647, step=1), +) + + +def build_archaeology(ctx: EffectContext) -> Plan: + width, height = _source_dims(ctx.clip, ctx.frame) + lookahead = max(0.1, min(2.0, float(ctx.opts.get("lookahead", 0.6)))) + gap = _clamp_int(ctx.opts.get("gap", 18), 18, 1, 60) + hold = _clamp_int(ctx.opts.get("hold", 6), 6, 1, 30) + melt = _clamp_int(ctx.opts.get("melt", 14), 14, 2, 60) + seam = str(ctx.opts.get("seam", "poisson")).lower() + mode = str(ctx.opts.get("mode", "smudge")).lower() + return Plan( + vf="stateful future-cut frame pass", + out_suffix=ctx.suffix or "_EXCAVATED", width=width, height=height, + notes=[f"borrows a cutout from {gap} frame(s) ahead inside a " + f"{lookahead:g}s future buffer", + f"cutout lives {hold} sharp frames then {melt} melt frames " + f"({mode} out)", + ("poisson seam blending needs headless OpenCV; falls back to " + "a feathered alpha blend" if seam == "poisson" else + "plain masked paste - edges stay exact"), + "needs requirements-stateful.txt (NumPy); no stateful preview", + "keeps source PTS through the timestamped transport"], + ) + + INVERSION_PARAMS = ( Param("lookahead", "look-ahead secs", "buffer horizon in seconds; adds processing latency", "float", 0.5, minimum=0, maximum=5, step=0.1), @@ -2005,7 +2563,7 @@ def build_inversion(ctx: EffectContext) -> Plan: out_suffix=ctx.suffix or "_INVERTED", width=width, height=height, notes=[f"buffers up to {lookahead:g}s to borrow {offset:g}s into the future", "preserves video PTS, dimensions and compatible unfiltered audio packets", - "EOF/cuts clamp to latest frame; 128 MiB/128-frame buffer budget", + "EOF/cuts clamp to latest frame; high resolutions shorten look-ahead to fit 128 MiB", "requires requirements-inversion.txt; aligned mode also needs headless OpenCV"], ) @@ -2049,13 +2607,13 @@ def build_inversion(ctx: EffectContext) -> Plan: build=build_datamosh, params=DATAMOSH_PARAMS, run=run_datamosh, stages=( - "pass 1: ffmpeg -i -an -c:v mpeg4 -q:v 2 " + "pass 1: ffmpeg -i [-i ] -an -c:v mpeg4 -q:v " "-g -bf 0 -f m4v _mosh.m4v", "cut: strip_ivops() removes i-vops from the bitstream - " "heavy keeps 1, medium every 2nd, light every 3rd, dice " "uses configured survival, shuffle scrambles every motion frame; " "each hole takes the next, previous or a random motion " - "frame", + "frame; motion bloom reuses P-VOP data in existing frame slots", "pass 2: ffmpeg -f m4v -i _mosh.m4v [-i for " "the audio] -c:v copy -c:a aac .mp4", )), @@ -2092,9 +2650,16 @@ def build_inversion(ctx: EffectContext) -> Plan: tag="hue=h=sin(2*PI*t/P),curves", build=build_neon, params=NEON_PARAMS), Effect(key="blockswap", name="blockswap", - blurb="two blocks slide through each other", - tag="swaprect moving blocks", - build=build_blockswap, params=BLOCKSWAP_PARAMS), + blurb="blocks trade places, teleport on a timer, optional mosh", + tag="swaprect fixed rectangles, teleport windows, optional light mosh", + build=build_blockswap, params=BLOCKSWAP_PARAMS, + run=run_blockswap, + stages=( + "layout: swaprect chain, one seeded layout per teleport window", + "optional mosh: mpeg4 encode with grit/hold, i-vop strip, " + "motion bloom over the whole clip", + "pass 2: remux the mosh stream with the source audio", + )), Effect(key="dropout", name="dropout", blurb="frames vanish at random - the picture holds", tag="select=gt(random(0),C)", @@ -2117,8 +2682,8 @@ def build_inversion(ctx: EffectContext) -> Plan: "pass 2: ffmpeg -f concat -i list.txt -c copy .mp4", )), Effect(key="collapse", name="elastic collapse", - blurb="progressive content-aware scale with pixelated block mosh", - tag="seam carve, pixel grid, motion block carry", + blurb="progressive content-aware scale, optional pixel mosh", + tag="seam carve + smooth remap; pixel grid and block carry are opt-in", build=build_collapse, params=COLLAPSE_PARAMS, frame_processor=ElasticCollapse, stages=( @@ -2134,6 +2699,15 @@ def build_inversion(ctx: EffectContext) -> Plan: stages=("decode timestamped RGB frames through earlier FFmpeg filters", "buffer future frames, mix selected regions, clamp unavailable future at EOF", "encode on original video PTS with later filters and source audio")), + Effect(key="archaeology", name="pixel archaeology", + blurb="borrow a cutout from the future, then melt it away", + tag="buffered future cutouts, coherent blobs, flow smudge", + build=build_archaeology, params=ARCHAEOLOGY_PARAMS, + frame_processor=FutureCut, + stages=("decode RGB frames through earlier FFmpeg filters", + "buffer look-ahead, pick a coherent future cutout, " + "hold it, then dissolve it", + "encode with later filters and source audio")), Effect(key="sediment", name="temporal sediment", blurb="deposit frames into bounded, decaying visual history", tag="stateful Python frame processor", @@ -2157,7 +2731,7 @@ def build_inversion(ctx: EffectContext) -> Plan: "lobotomy", "neon", )), ("TIME & MEMORY", ( - "trails", "timescratch", "sediment", "dropout", "inversion", + "trails", "timescratch", "sediment", "dropout", "inversion", "archaeology", )), ("CODEC & BLOCKS", ( "datamosh", "blockswap", "crunch", @@ -2383,6 +2957,7 @@ def apply_cli_opts(chain: Chain, args) -> tuple: as `effect.key=value`. """ opts = default_chain_opts(chain) + explicit = {effect.key: {} for effect in chain.effects} for raw in args.set or []: if "=" not in raw: return opts, f"--set wants key=value, got {raw!r}" @@ -2416,6 +2991,19 @@ def apply_cli_opts(chain: Chain, args) -> tuple: if problem: return opts, problem opts[effect.key][param.key] = coerced + explicit[effect.key][param.key] = coerced + if "datamosh" in opts: + mine = opts["datamosh"] + mine.update(DATAMOSH_PRESETS[mine["preset"]]) + mine.update(explicit["datamosh"]) + second_video = getattr(args, "second_video", None) + if second_video is not None: + if "datamosh" not in opts: + return opts, "--with is only available for datamosh" + chosen = explicit["datamosh"].get("second") + if chosen is not None and chosen != second_video: + return opts, "conflicting second videos: use --with or --set second, not both" + opts["datamosh"]["second"] = second_video if args.size_given: # the request shape belongs to the first effect that has one - a second # shape option further down the chain is reachable with its own --set @@ -2614,6 +3202,9 @@ def run_stateful_chain(chain: Chain, ctx: EffectContext, clip: Clip, tools: Tool seed = 20261023 + (clip.size % 1000003) processor.initialize(opts, (height, width, 3), fps, seed) processors.append(processor) + notes = getattr(processor, "runtime_notes", []) + if notes: + last_render.setdefault("warnings", []).extend(notes) except Exception as exc: return False, f"could not initialize {chain.name}: {exc}", 0.0 @@ -2883,14 +3474,20 @@ def on_progress(done, total, elapsed): def process(chain: Chain, clip: Clip, tools: Tools, args, opts_override: Optional[dict] = None) -> bool: + last_render.clear() + + def failed(message: str) -> bool: + last_render["error"] = message + err(message) + return False + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) if opts_override is not None: opts = dict(opts_override) else: opts, problem = apply_cli_opts(chain, args) if problem: - err(problem) - return False + return failed(problem) ctx = EffectContext(clip=clip, width=args.width, height=args.height, suffix=args.suffix, crf=args.crf, preset=args.preset, opts=opts) @@ -2898,8 +3495,7 @@ def process(chain: Chain, clip: Clip, tools: Tools, args, dst = resolve_output(clip, args, plan) if getattr(args, "output", "") and dst.exists() and not got_force(args): - err(f"{dst} already exists - add --force to overwrite") - return False + return failed(f"{dst} already exists - add --force to overwrite") dst.parent.mkdir(parents=True, exist_ok=True) if not clip.duration or clip.duration <= 0: @@ -2934,21 +3530,36 @@ def stateful_runner(run_ctx, run_clip, run_tools, run_args, run_dst, if args.dry_run: for line in chain.stage_lines: _say(" " + line) - if pass_effect is not None and pass_effect.key == "datamosh": + if pass_effect is not None: + values = opts_of(opts, pass_effect) for param in pass_effect.params: - value = opts_of(opts, pass_effect)[param.key] - _say(f" {param.key}={param.display(value)}") - second_raw = opts_of(opts, pass_effect).get("second", "none") - second_path, _problem = resolve_second_video( - second_raw, Path(args.input_dir), clip.path) - if second_path: - _say(" second source: " + str(second_path)) + _say(f" {param.key}={param.display(values[param.key])}") + if pass_effect.key == "datamosh": + second_raw = values.get("second", "none") + second_path, _problem = resolve_second_video( + second_raw, Path(args.input_dir), clip.path) + if second_path: + _say(" second source: " + str(second_path)) + pass_plan = build_plan(pass_effect, ctx) + if pass_plan.vf: + _say(" filter: " + pass_plan.vf) + for note in pass_plan.notes: + _say(" \u00b7 " + note) elif chain.stateful_effects: for effect in chain.stateful_effects: values = opts_of(opts, effect) for param in effect.params: _say(f" {effect.key}.{param.key}=" f"{param.display(values[param.key])}") + for effect in chain.stateful_effects: + state_plan = build_plan(effect, EffectContext( + clip=clip, width=ctx.width, height=ctx.height, + suffix=effect.key.upper(), crf=args.crf, + preset=args.preset, opts=opts_of(opts, effect))) + if state_plan.vf: + _say(f" {effect.key}: {state_plan.vf}") + for note in state_plan.notes: + _say(f" {effect.key} \u00b7 {note}") return True else: cmd, plan = build_command(chain, ctx, clip, tools, dst) @@ -3041,15 +3652,17 @@ def on_progress(done: float, total: Optional[float], elapsed: float) -> None: run_encode, on_progress, animate) except KeyboardInterrupt: drop_partial(dst, clip.path) + last_render["error"] = "cancelled, partial file removed" warn("cancelled, partial file removed") return False except Exception as exc: drop_partial(dst, clip.path) - err(f"{chain.name} failed: {exc}") - return False + LOG.exception(exc, f"{chain.name} failed") + return failed(f"{chain.name} failed: {exc}") if not okk: drop_partial(dst, clip.path) - err("ffmpeg failed, partial file removed") + last_render["error"] = stderr.strip() or "FFmpeg failed without an error message" + err("ffmpeg failed, partial file removed", stderr) for line in stderr.splitlines()[-6:]: print(" " + C_DIM + line + NC, file=sys.stderr) return False @@ -3059,19 +3672,20 @@ def on_progress(done: float, total: Optional[float], elapsed: float) -> None: animate) except KeyboardInterrupt: drop_partial(dst, clip.path) + last_render["error"] = "cancelled, partial file removed" warn("cancelled, partial file removed") return False if rc != 0: drop_partial(dst, clip.path) - err(f"ffmpeg failed (exit {rc}), partial file removed") + last_render["error"] = stderr.strip() or f"FFmpeg exited {rc}" + err(f"ffmpeg failed (exit {rc}), partial file removed", stderr) for line in stderr.splitlines()[-6:]: print(" " + C_DIM + line + NC, file=sys.stderr) return False if not dst.exists() or dst.stat().st_size == 0: - err("ffmpeg reported success but no output was written") - return False + return failed("ffmpeg reported success but no output was written") out_size = dst.stat().st_size speed = (render_duration / elapsed) if (render_duration and elapsed > 0) \ @@ -3093,6 +3707,8 @@ def on_progress(done: float, total: Optional[float], elapsed: float) -> None: } if not chain.single: payload["chain"] = [e.key for e in chain.effects] + if last_render.get("warnings"): + payload["warnings"] = last_render["warnings"] _say(json.dumps(payload)) return True @@ -3101,6 +3717,8 @@ def on_progress(done: float, total: Optional[float], elapsed: float) -> None: + ")" + NC) if not _QUIET: hint(str(dst)) + for note in last_render.get("warnings", []): + warn(note) return True @@ -3126,7 +3744,37 @@ def term_rows() -> int: return max(1, terminal_size().lines) +WINDOWS_KEY_NAMES = { + "H": "up", "P": "down", "M": "right", "K": "left", + "G": "home", "O": "end", "I": "pgup", "Q": "pgdn", "S": "delete", +} + + +def decode_windows_key(first: str, code: str = "") -> str: + """turn what msvcrt hands back into the names the menus compare against. + + arrows and friends arrive as a NUL/0xe0 prefix followed by a code letter; + a lone escape is just esc because the console never sends sequences. + """ + if first in ("\x00", "\xe0"): + return WINDOWS_KEY_NAMES.get(code, "esc") + if first == "\x1b": + return "esc" + return first + + +def _read_key_windows() -> str: + import msvcrt + getwch = msvcrt.getwch # type: ignore[attr-defined] + first = getwch() + if first in ("\x00", "\xe0"): + return decode_windows_key(first, getwch()) + return decode_windows_key(first) + + def _read_key(fd: int) -> str: + if is_windows(): + return _read_key_windows() raw = os.read(fd, 1) if not raw: raise EOFError("stdin closed") @@ -3154,6 +3802,10 @@ class RawSession: canonical mode get line-buffered, so an escape sequence sent as ESC O C can be delivered as garbage (a down arrow reading as enter). one session, and a temporary hand-back only for cooked line edits. + + windows consoles have no termios: msvcrt.getwch already returns keys + without waiting for enter and without echoing them, so the session only + tracks whether menus are live. """ def __init__(self, fd: int = 0): @@ -3164,6 +3816,9 @@ def __init__(self, fd: int = 0): def enter(self) -> None: if self.active or not sys.stdin.isatty(): return + if is_windows(): + self.active = True + return import termios import tty self.saved = termios.tcgetattr(self.fd) @@ -3174,9 +3829,10 @@ def enter(self) -> None: self.active = True def leave(self) -> None: - import termios - if self.active and self.saved is not None: - termios.tcsetattr(self.fd, termios.TCSADRAIN, self.saved) + if not is_windows(): + import termios + if self.active and self.saved is not None: + termios.tcsetattr(self.fd, termios.TCSADRAIN, self.saved) self.active = False @contextmanager @@ -3244,7 +3900,7 @@ def ask_yes_no(question: str, default: bool = False) -> bool: return default return raw[0] == "y" - tags = "y/n" if default else "y/N" + tags = "Y/n" if default else "y/N" sys.stdout.write(f" {B}{question}{NC} {C_DIM}[{tags}]{NC} ") sys.stdout.flush() answer = default @@ -3488,23 +4144,43 @@ def _select_numbered(crumb: str, items: list) -> object: # screens # --------------------------------------------------------------------------- +def requirement_banner(reqs: list) -> str: + """one line for the menu header: all good, or what is missing.""" + missing = missing_requirements(reqs) + total = sum(1 for req in reqs if req.required) + if not missing: + return (" " + C_GRN + "\u2713" + NC + " " + B + "requirements" + NC + + " " + C_GRN + f"all {total} installed" + NC) + return (" " + C_RED + "\u2717" + NC + " " + B + "requirements" + NC + " " + + C_RED + f"{len(missing)} of {total} missing" + NC + C_DIM + ": " + + ", ".join(req.label for req in missing) + + " \u00b7 press c to install" + NC) + + def screen_main(tools: Tools, args) -> str: videos = len(scan_videos()) - bits = ["ffmpeg ok" if tools.ffmpeg else "ffmpeg missing", - f"{videos} in input/", f"{count_renders()} in output/"] + reqs = check_requirements(tools) + missing = missing_requirements(reqs) + bits = [f"{videos} in input/", f"{count_renders()} in output/"] items = [ Item("process a video", hint="pick a clip, build an effect chain, render", value="process"), Item("effects", hint="browse effect options and descriptions", value="effects"), - Item("system check", hint="ffmpeg, ffprobe, folders", value="check"), + Item("requirements", + hint="install what is missing" if missing + else "ffmpeg, ffprobe, python, optional modules", value="check"), Item("quit", hint="leave effex", value="quit"), ] intro = [ B + C_FROST + "[SOURCE] -> [EFFECTS] -> [OUTPUT]" + NC, C_DIM + "pick a clip, shape a look, render it to output/" + NC, "", + requirement_banner(reqs), ] + if term_rows() >= 26: + intro += requirement_lines(reqs, term_cols() - 6) + intro.append("") note = " · ".join(bits) if not videos: note = "input/ is empty \u00b7 " + note @@ -3520,6 +4196,7 @@ def row_fn(it: Item, index: int, sel: bool) -> str: return "quit" if picked is None else str(picked) + def screen_help(args) -> None: width = term_cols() - 6 body = [ @@ -3576,22 +4253,19 @@ def screen_effects_info(args) -> None: def screen_check(tools: Tools, interactive: bool) -> Tools: width = term_cols() - 6 - body = [] - if tools.ffmpeg: - body.append(" " + C_GRN + "\u2713" + NC + " ffmpeg " + C_DIM - + trunc(tools.ffmpeg, width - 12) + NC) - else: - body.append(" " + C_RED + "\u2717" + NC + " ffmpeg not on PATH") - if tools.ffprobe: - body.append(" " + C_GRN + "\u2713" + NC + " ffprobe " + C_DIM - + trunc(tools.ffprobe, width - 12) + NC) - else: - body.append(" " + C_AMB + "\u26a0" + NC + " ffprobe missing, " - "durations show as --:--") - if not tools.ok: + reqs = check_requirements(tools) + if missing_requirements(reqs): TERM.leave() - tools = install_ffmpeg(tools, interactive) + tools = install_requirements(tools, interactive) TERM.enter() + reqs = check_requirements(tools) + body = requirement_lines(reqs, width) + hint_line = pip_hint(reqs) + if hint_line: + body.append("") + body.append(" " + C_DIM + + trunc("optional modules: " + hint_line, width) + NC) + body.append("") for folder in (INPUT_DIR, OUTPUT_DIR): folder.mkdir(parents=True, exist_ok=True) count = len(scan_videos(folder)) if folder == INPUT_DIR \ @@ -3599,7 +4273,8 @@ def screen_check(tools: Tools, interactive: bool) -> Tools: body.append(" " + C_GRN + "\u2713" + NC + " " + fit(folder.name, 8) + C_DIM + trunc(str(folder), width - 24) + NC + f" {count} file{'' if count == 1 else 's'}") - start_frame("system check", body, ["enter back"]) + start_frame("requirements", body, ["enter back"], + note=requirement_summary(reqs)) wait_key() return tools @@ -3833,12 +4508,27 @@ def make_ctx() -> EffectContext: rows = option_rows(chain) idx = next((i for i, r in enumerate(rows) if r.kind != "head"), 0) notice = initial_notice + last_mosh_preset = opts.get("datamosh", {}).get("preset") + top = 0 while True: + if "datamosh" in opts: + mine = opts["datamosh"] + if mine["preset"] != last_mosh_preset: + mine.update(DATAMOSH_PRESETS[mine["preset"]]) + last_mosh_preset = mine["preset"] cols = term_cols() width = cols - 6 body = [] - for i, entry in enumerate(rows): + reserve = 12 + (0 if chain.single else len(chain.effects)) + page = max(1, min(len(rows), term_rows() - reserve - (2 if notice else 0))) + top = max(0, min(top, len(rows) - page)) + if idx < top: + top = idx + elif idx >= top + page: + top = idx - page + 1 + for i in range(top, min(len(rows), top + page)): + entry = rows[i] if entry.kind == "head": body.append(" " + B + C_FROST + fit(entry.effect.name, 13) + NC + " " + C_DIM @@ -3861,6 +4551,10 @@ def make_ctx() -> EffectContext: + fit(param.display(mine[param.key]), 24) + C_DIM + trunc(param.help + extra, width - 44) + NC) body.append(row(text, i == idx)) + if page < len(rows): + body.append(" " + C_DIM + trunc( + f"options {top + 1}-{min(len(rows), top + page)} of {len(rows)}" + + " (arrows scroll)", width) + NC) try: plan = plan_for_chain(chain, make_ctx()) out_name = clip.path.stem + plan.out_suffix + ".mp4" @@ -4050,6 +4744,12 @@ def screen_confirm(chain: Chain, clip: Clip, args, opts: dict) -> bool: def open_folder(path: Path) -> bool: """hand the folder to the desktop file manager; False when none exists""" + if is_windows(): + try: + os.startfile(str(path)) # type: ignore[attr-defined] + except OSError: + return False + return True opener = shutil.which("xdg-open") or shutil.which("open") if not opener: return False @@ -4064,8 +4764,17 @@ def open_folder(path: Path) -> bool: def screen_done(args, ok_render: bool) -> None: if not ok_render or not last_render: body = [" " + C_AMB + "no output file was written" + NC] + detail = str(last_render.get("error", "")) + if detail: + import textwrap + lines = [part for line in detail.splitlines()[-6:] + for part in textwrap.wrap(line, max(12, term_cols() - 8))] + body += [""] + [" " + C_DIM + line + NC for line in lines[-8:]] notice = C_RED + "render failed or was cancelled" + NC keys = ["enter main menu"] + if LOG.exists(): + body += ["", kv("log", trunc(str(LOG.path()), term_cols() - 14))] + keys.append("l open log folder") else: speed = last_render.get("speed") body = [ @@ -4077,6 +4786,8 @@ def screen_done(args, ok_render: bool) -> None: + (f" {speed:.2f}x realtime" if speed else "")), kv("path", trunc(str(last_render["path"]), term_cols() - 14)), ] + for note in last_render.get("warnings", []): + body.append(" " + C_AMB + trunc(note, term_cols() - 6) + NC) notice = "" keys = ["enter main menu", "o open output folder"] while True: @@ -4094,6 +4805,11 @@ def screen_done(args, ok_render: bool) -> None: if open_folder(OUTPUT_DIR) \ else (C_AMB + "no file manager opener found - the folder " f"is {OUTPUT_DIR}" + NC) + if key == "l" and LOG.exists(): + folder = log_dir() + notice = (C_GRN + "opened " + str(folder) + NC) \ + if open_folder(folder) \ + else (C_AMB + f"the log is {LOG.path()}" + NC) # --------------------------------------------------------------------------- @@ -4181,9 +4897,16 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--list", action="store_true", help="list effects with their options, and videos in input/") p.add_argument("--check", action="store_true", - help="check ffmpeg/ffprobe and folders, exit 1 if missing") + help="check ffmpeg/ffprobe/python and optional modules, " + "exit 1 if something required is missing") + p.add_argument("--logs", action="store_true", + help="print the diagnostics log path and its last lines") + p.add_argument("--report", nargs="?", const="", default=None, metavar="PATH", + help="write a diagnostics report to attach to an issue") p.add_argument("--set", action="append", metavar="KEY=VALUE", help="set an effect option, eg --set target=16:9") + p.add_argument("--with", dest="second_video", metavar="VIDEO", + help="mash a second video into datamosh (heavy by default)") p.add_argument("--size", default=None, metavar="WxH", help="shorthand for --set target=WxH") p.add_argument("--crf", type=int, default=DEFAULT_CRF, @@ -4286,16 +5009,57 @@ def cmd_list(args, tools: Tools) -> int: return 0 +def cmd_logs(args) -> int: + path = LOG.path() + _say(" " + B + C_FROST + "log" + NC + " " + str(path)) + if not path.is_file(): + hint("nothing logged yet - warnings and errors get recorded here") + return 0 + _say("") + for line in LOG.tail(40).splitlines(): + _say(" " + C_DIM + line + NC) + _say("") + hint(f"attach this file to an issue: {ISSUES_URL}") + hint("--report writes one file with the environment header on top") + return 0 + + +def cmd_report(args) -> int: + destination = Path(args.report).expanduser() if args.report else None + try: + written = LOG.report(destination) + except OSError as exc: + err(f"could not write the report: {exc}") + return 1 + ok(str(written)) + hint(f"attach it to an issue: {ISSUES_URL}") + return 0 + + def cmd_check(args, tools: Tools) -> int: + reqs = check_requirements(tools) + missing = missing_requirements(reqs) if args.json: # machine mode: one json object on stdout, nothing else - payload = {"ok": tools.ok, "ffmpeg": tools.ffmpeg, - "ffprobe": tools.ffprobe} - if not tools.ok: + payload = { + "ok": not missing, + "ffmpeg": tools.ffmpeg, + "ffprobe": tools.ffprobe, + "platform": host_os(), + "requirements": [ + {"key": r.key, "label": r.label, "required": r.required, + "found": r.found, "detail": r.detail, "unlocks": r.unlocks} + for r in reqs + ], + } + if missing: hit = detect_installer() - payload["hint"] = " ".join(hit[1]) if hit else None + payload["installer"] = hit.manager if hit else None + payload["hint"] = (install_text(hit, [r.key for r in missing + if r.installable]) + if hit else None) _say(json.dumps(payload)) - return 0 if tools.ok else 1 + return 0 if not missing else 1 tools = screen_check(tools, interactive=sys.stdin.isatty()) return 0 if tools.ok else 1 @@ -4406,6 +5170,7 @@ def cmd_run(args, tools: Tools) -> int: def main(argv=None) -> int: + prepare_console() parser = build_parser() args = parser.parse_args(argv) args.size_given = args.size is not None @@ -4422,9 +5187,16 @@ def main(argv=None) -> int: or not sys.stdout.isatty())) global _QUIET _QUIET = args.quiet + LOG.start(version=VERSION, argv=list(sys.argv[1:] if argv is None else argv)) tools = find_tools() + LOG.bind("ffmpeg", tools.ffmpeg or "missing") + LOG.bind("ffprobe", tools.ffprobe or "missing") + if args.logs: + return cmd_logs(args) + if args.report is not None: + return cmd_report(args) if args.check: return cmd_check(args, tools) if args.list: @@ -4454,9 +5226,37 @@ def main(argv=None) -> int: return interactive_menu(args) +def venv_python_path(root: Path = ROOT, + windows: Optional[bool] = None) -> Optional[str]: + """the venv interpreter for this platform, if the venv exists.""" + if windows is None: + windows = is_windows() + names = ("Scripts", "python.exe") if windows else ("bin", "python3") + python = root / ".venv" / names[0] / names[1] + return str(python) if python.is_file() and os.access(python, os.X_OK) else None + + +def project_python(root: Path = ROOT) -> Optional[str]: + """Use local optional dependencies for plain launches, not active custom envs.""" + if sys.flags.no_site or sys.prefix != sys.base_prefix: + return None + return venv_python_path(root) + + if __name__ == "__main__": try: - sys.exit(main()) + python = project_python() + if python: + if is_windows(): + # os.execv leaves no parent to report the exit code on windows + sys.exit(subprocess.call([python, str(Path(__file__).resolve()), + *sys.argv[1:]])) + os.execv(python, [python, str(Path(__file__).resolve()), *sys.argv[1:]]) + code = main() + if code and LOG.wrote: + hint(f"details in {LOG.path()} · --report writes a file " + "to attach to an issue") + sys.exit(code) except BrokenPipeError: try: sys.stderr.close() @@ -4466,3 +5266,13 @@ def main(argv=None) -> int: except KeyboardInterrupt: _say() sys.exit(130) + except Exception as exc: + LOG.exception(exc) + try: + err(f"{type(exc).__name__}: {exc}") + if LOG.wrote: + hint(f"details in {LOG.path()}") + hint(f"please report it: {ISSUES_URL}") + except Exception: + pass + sys.exit(1) diff --git a/future_cut.py b/future_cut.py new file mode 100644 index 0000000..44726ab --- /dev/null +++ b/future_cut.py @@ -0,0 +1,723 @@ +"""FutureCut: bounded future-frame cutout compositor (E2.47a-d). + +Borrows a coherent cutout from a *future* frame, composites it into the present +frame, holds it sharp for a few frames, then dissolves it so the video returns +to itself. NumPy and OpenCV are imported lazily on initialize(); importing this +module only touches the standard library, so it never pulls NumPy/cv2 in on its +own. + +Design notes +------------ +* Timestamped push/flush stage: packets are buffered and each is emitted with + its ORIGINAL index/timestamp/duration. Output is delayed by + ``ceil(lookahead*fps)+1`` frames so a real future donor always exists. +* At each spawn slot a seed tile is chosen (coherent = similar surface moved by + a different phase; dissimilar = largest descriptor distance under a cap; + random = baseline), grown into ONE blob by region growing on descriptor + distance, morphologically cleaned and feathered. The donor pixels come from + the buffered future frame at ``min(gap, lookahead_frames)`` frames ahead. +* Lifecycle per spawn: birth -> ``hold`` sharp frames -> ``melt`` dissolve + frames. ``mode=slide`` dissolves the mask in blotches via a seeded noise + threshold (never a linear fade); ``mode=smudge`` keeps a feedback accumulator + ``acc = warp(acc, flow)*retention + future*(1-retention)`` composited through + the decaying mask. After ``hold+melt`` frames the cutout is gone and the + emitted frame is the live frame, bit-identical. +* Determinism: same seed + same packets => identical output. The spawn phase, + dissolve noise and random pick all derive from the seed. Input frames are + never mutated (buffers own copies). +""" +import math +from collections import deque +from dataclasses import replace +from typing import Any, Optional + +# Tile/growth constants (analysis scale). +_TILE_DIV = 6 # analysis shorter side // _TILE_DIV => tile side +_DESC_SIDE = 8 # descriptor patch side +_GROW_DIST = 0.6 # region-growing descriptor-distance budget (normalized) +_BLOCK = 8 # numpy flow block size +_SEARCH = 8 # numpy flow +/- search radius + + +class FutureCut: + """Borrow a future cutout, hold it, then dissolve it back into the video.""" + + requires_timestamps = True + MAX_STATE_BYTES: int = 128 * 1024 * 1024 + MAX_FRAMES = 128 + DEFAULTS = { + "lookahead": 0.6, "gap": 18, "cutouts": 1, "spawn_every": 24, + "size": 18, "pick": "coherent", "min_motion": 2, "max_motion": 25, + "similarity": 60, "shape": "blob", "seam": "poisson", + "feather": 35, "hold": 6, "melt": 14, "mode": "smudge", + "retention": 70, "drift": 100, "seed": 0, + } + _CHOICES = { + "pick": ("coherent", "dissimilar", "random"), + "shape": ("blob", "rect"), + "seam": ("none", "poisson"), + "mode": ("slide", "smudge"), + } + _SCENE_CUT = 0.55 # fraction of 255 on the analysis frame + _ANALYSIS_SIDE = 160 # longest side for scoring/flow + _SCENE_SIDE = 32 # scene-cut detection sample side + + def __init__(self): + self._np: Any = None + self._config: dict = {} + self._shape = (0, 0, 0) + self._fps = 30.0 + self._seed = 0 + self._capacity = 1 + self._analysis_shape = (0, 0) + self._lookahead_frames = 0 + self._delay = 0 + self._effective_gap = 1 + self._queue: deque = deque() + self._cutouts: list = [] + self._next_index: Optional[int] = None + self._last_timestamp: Optional[float] = None + self._scene_sample: Any = None + self._spawns = 0 + self._total_pushed = 0 + + # --- config helpers ------------------------------------------------- + + @staticmethod + def _clamp_int(value, default, low, high): + try: + number = int(value) + except (TypeError, ValueError): + return default + return max(low, min(high, number)) + + @staticmethod + def _clamp_float(value, default, low, high): + try: + number = float(value) + except (TypeError, ValueError): + return default + if not math.isfinite(number): + return default + return max(low, min(high, number)) + + @staticmethod + def _choice(key, value, default): + text = str(value).strip().lower() if value is not None else default + if text not in FutureCut._CHOICES[key]: + raise ValueError(f"invalid future-cut {key}: {value!r}") + return text + + # --- lifecycle ------------------------------------------------------ + + def initialize(self, config: dict, frame_shape: tuple, fps: float, + seed: int) -> None: + try: + import numpy as np + except ImportError as exc: + raise RuntimeError( + "FutureCut needs NumPy; install requirements-stateful.txt" + ) from exc + if (len(frame_shape) != 3 or int(frame_shape[2]) != 3 + or any(int(v) < 1 for v in frame_shape)): + raise ValueError("FutureCut expects an HxWx3 RGB frame") + height, width, channels = (int(v) for v in frame_shape) + self._np = np + self._shape = (height, width, channels) + self._fps = self._clamp_float(fps, 30.0, 0.001, 1000.0) + + config = dict(config) if config else {} + resolved = {} + for key, default in self.DEFAULTS.items(): + if key in self._CHOICES: + resolved[key] = self._choice(key, config.get(key), default) + elif key == "lookahead": + resolved[key] = self._clamp_float(config.get(key), default, + 0.1, 2.0) + elif key == "seed": + resolved[key] = self._clamp_int(config.get(key), default, + 0, 2147483647) + else: + low, high = { + "gap": (1, 60), "cutouts": (1, 3), + "spawn_every": (4, 120), "size": (5, 40), + "min_motion": (0, 30), "max_motion": (1, 100), + "similarity": (0, 100), "feather": (0, 100), + "hold": (1, 30), "melt": (2, 60), + "retention": (0, 95), "drift": (0, 100), + }[key] + resolved[key] = self._clamp_int(config.get(key), default, + low, high) + self._config = resolved + + # seed: config value wins over the argument (arg is the fallback). + self._seed = self._clamp_int( + config.get("seed", seed), seed, 0, 2147483647) + + # analysis resolution (longest side <= 160 px) + scale = min(1.0, self._ANALYSIS_SIDE / max(height, width)) + self._analysis_shape = (max(1, round(height * scale)), + max(1, round(width * scale))) + + # bounded memory: a live cutout costs roughly seven frames of state + # (donor copy, float32 accumulator, float32 mask, coarse noise field), + # so reserve that before handing the rest to the look-ahead buffer. + frame_bytes = height * width * channels + reserve = int(resolved["cutouts"] * 3 * frame_bytes) + 65536 + self._capacity = max(1, min(self.MAX_FRAMES, + (self.MAX_STATE_BYTES - reserve) // frame_bytes)) + if self._capacity < 1: + raise ValueError( + "future-cut cannot fit one RGB frame in its 128 MiB buffer " + "budget; resize before future-cut") + + # buffering horizon, clamped so the queue can never outgrow the budget + requested = int(math.ceil(resolved["lookahead"] * self._fps)) + self._lookahead_frames = max(1, min(self._capacity - 1, requested)) \ + if self._capacity > 1 else 1 + self._delay = self._lookahead_frames + 1 + self._effective_gap = max(1, min(resolved["gap"], self._lookahead_frames)) + + self.runtime_notes: list = [] + if self._lookahead_frames < requested: + self.runtime_notes.append( + f"look-ahead limited to {self._lookahead_frames} frames " + f"({self._lookahead_frames / max(1e-6, self._fps):.2f}s) by the " + f"128 MiB buffer at {width}x{height}; resize for a longer horizon") + self.reset() + + # --- diagnostics ---------------------------------------------------- + + @property + def state_nbytes(self) -> int: + total = sum(p.frame.nbytes for p in self._queue) + for cut in self._cutouts: + total += cut["donor"].nbytes + if cut["acc"] is not None: + total += cut["acc"].nbytes + total += cut["mask"].nbytes + total += cut["noise"].nbytes + return int(total) + + @property + def buffered_frames(self) -> int: + return len(self._queue) + + @property + def capacity(self) -> int: + return self._capacity + + @property + def spawns(self) -> int: + return self._spawns + + @property + def active_cutouts(self) -> int: + return len(self._cutouts) + + def serialize_config(self) -> dict: + return dict(self._config) + + def reset(self) -> None: + self._queue.clear() + self._cutouts.clear() + self._next_index = None + self._last_timestamp = None + self._scene_sample = None + self._spawns = 0 + self._total_pushed = 0 + + # --- numpy helpers -------------------------------------------------- + + def _gray(self, rgb): + return (rgb[..., 0].astype(self._np.float32) * 0.299 + + rgb[..., 1].astype(self._np.float32) * 0.587 + + rgb[..., 2].astype(self._np.float32) * 0.114) + + def _resize(self, img, shape): + """Nearest-neighbour resize of a (H,W[,C]) array to (h, w).""" + np = self._np + src_h, src_w = img.shape[:2] + h, w = shape + if (h, w) == (src_h, src_w): + return img + ys = (np.arange(h) * (src_h - 1) / max(1, h - 1)).round().astype(np.int64) + xs = (np.arange(w) * (src_w - 1) / max(1, w - 1)).round().astype(np.int64) + return img[ys[:, None], xs[None, :]] + + def _try_cv2(self): + try: + import cv2 + return cv2 + except (ImportError, ModuleNotFoundError, TypeError): + return None + + # --- flow ----------------------------------------------------------- + + def _flow_cv2(self, cv2, gray0, gray1): + try: + dis = cv2.DISOpticalFlow_create(cv2.DISOPTICAL_FLOW_PRESET_MEDIUM) + return dis.calc(gray0, gray1, None) + except Exception: + return cv2.calcOpticalFlowFarneback( + gray0, gray1, None, 0.5, 3, 21, 5, 7, 1.5, 0) + + def _flow_numpy(self, gray0, gray1): + np = self._np + h, w = gray0.shape + flow = np.zeros((h, w, 2), dtype=np.float32) + g0 = gray0.astype(np.float32) + g1 = gray1.astype(np.float32) + gh, gw = h // _BLOCK, w // _BLOCK + for by in range(gh): + for bx in range(gw): + y0, x0 = by * _BLOCK, bx * _BLOCK + ref = g0[y0:y0 + _BLOCK, x0:x0 + _BLOCK] + best, best_sad = (0, 0), float("inf") + for dy in range(-_SEARCH, _SEARCH + 1): + for dx in range(-_SEARCH, _SEARCH + 1): + sy, sx = y0 + dy, x0 + dx + if (sy < 0 or sx < 0 + or sy + _BLOCK > h or sx + _BLOCK > w): + continue + sad = float(np.abs( + ref - g1[sy:sy + _BLOCK, sx:sx + _BLOCK]).mean()) + if sad < best_sad: + best_sad, best = sad, (dx, dy) + flow[y0:y0 + _BLOCK, x0:x0 + _BLOCK] = best + return flow + + def _flow(self, gray0, gray1): + cv2 = self._try_cv2() + if cv2 is not None: + try: + return self._flow_cv2(cv2, gray0, gray1) + except Exception: + pass + return self._flow_numpy(gray0, gray1) + + # --- tile scoring --------------------------------------------------- + + @staticmethod + def _tile_desc(gray, y, x, t): + import numpy as _np + patch = _np.asarray(gray[y:y + t, x:x + t], dtype=_np.float32) + patch = FutureCut._resize_2d(_np, patch, (_DESC_SIDE, _DESC_SIDE)) + patch = patch - patch.mean() + norm = float(_np.sqrt((patch * patch).sum())) + if norm > 1e-6: + patch = patch / norm + return patch.reshape(-1) + + @staticmethod + def _resize_2d(np, img, shape): + src_h, src_w = img.shape[:2] + h, w = shape + ys = (np.arange(h) * (src_h - 1) / max(1, h - 1)).round().astype(np.int64) + xs = (np.arange(w) * (src_w - 1) / max(1, w - 1)).round().astype(np.int64) + return img[ys[:, None], xs[None, :]] + + def _choose_seed(self, pa, fa, flow): + """Score overlapping tiles; return the chosen tile (y, x) or None.""" + np = self._np + ah, aw = pa.shape + short = min(ah, aw) + tile = max(4, min(short, short // _TILE_DIV)) + if short < tile or ah < tile or aw < tile: + return None + step = max(1, tile // 2) + + gy, gx = np.gradient(pa) + grad_mag = np.sqrt(gy * gy + gx * gx) + flow_mag = np.sqrt(flow[..., 0] ** 2 + flow[..., 1] ** 2) + + min_motion = self._config["min_motion"] / 100.0 * aw + max_motion = self._config["max_motion"] / 100.0 * aw + sim_threshold = self._config["similarity"] / 100.0 + pick = self._config["pick"] + + records = [] + for y in range(0, ah - tile + 1, step): + for x in range(0, aw - tile + 1, step): + energy = float(grad_mag[y:y + tile, x:x + tile].mean()) + motion = float(flow_mag[y:y + tile, x:x + tile].mean()) + if energy < 0.5: # reject flat tiles + continue + dp = self._tile_desc(pa, y, x, tile) + df = self._tile_desc(fa, y, x, tile) + dist = float(np.sqrt(((dp - df) ** 2).sum())) # in [0, 2] + similarity = 1.0 - dist / 2.0 + spot = float(np.abs(pa[y:y + tile, x:x + tile] + - fa[y:y + tile, x:x + tile]).mean()) / 255.0 + records.append((y, x, energy, motion, similarity, dist, spot)) + + if not records: + return None + + def motion_ok(m): + return min_motion <= m <= max_motion + + motion_gated = [r for r in records if motion_ok(r[3])] + # A cutout is only worth compositing when the future actually differs + # from the present at that spot; identical-looking tiles make the + # borrow invisible (the old archaeology failure mode). + floor = max(2.0 / 255.0, (1.0 - sim_threshold) * 0.08) + eligible = [r for r in motion_gated + if r[4] >= sim_threshold and r[6] >= floor] + if not eligible: + eligible = [r for r in motion_gated if r[6] > 0] or motion_gated + pool = eligible or records # final fallback: any textured tile + + if pick == "random": + rng = np.random.default_rng(self._seed * 1000003) + y, x = pool[int(rng.integers(0, len(pool)))] + return (y, x) + if pick == "dissimilar": + # largest descriptor distance under a cap of 1.0 (normalized) + capped = [r for r in pool if r[5] <= 1.0] or pool + y, x, *_ = max(capped, key=lambda r: r[5]) + return (y, x) + # coherent: same surface class, but visibly displaced ahead - prefer the + # strongest visible difference so the slab reads as borrowed content + span = max(1e-3, (max_motion - min_motion)) + y, x, *_ = max( + pool, key=lambda r: 0.75 * min(1.0, r[6] / 0.25) + + 0.25 * min(1.0, (r[3] - min_motion) / span) + ) + return (y, x) + + def _grow_mask(self, pa, seed, target_area): + """Region-grow from the seed tile into ONE blob; bool mask at analysis + scale.""" + np = self._np + ah, aw = pa.shape + short = min(ah, aw) + tile = max(4, min(short, short // _TILE_DIV)) + step = max(1, tile // 2) + seed_desc = self._tile_desc(pa, seed[0], seed[1], tile) + + coverage = np.zeros((ah, aw), dtype=bool) + visited = {seed} + # Grow the seed into one blob, relaxing the descriptor budget in + # passes so a patchy surface still reaches the requested area. + for budget in (_GROW_DIST, _GROW_DIST * 1.7, _GROW_DIST * 3.0): + seeds = [(y, x) for y in range(0, ah - tile + 1, step) + for x in range(0, aw - tile + 1, step) + if coverage[y:y + tile, x:x + tile].any()] or [seed] + queue = deque(seeds) + while queue and int(coverage.sum()) < target_area: + y, x = queue.popleft() + if int(coverage.sum()) + tile * tile > target_area * 1.1: + break # stop before the blob overshoots the setting + coverage[y:y + tile, x:x + tile] = True + for dy in (-step, 0, step): + for dx in (-step, 0, step): + if dy == 0 and dx == 0: + continue + ny, nx = y + dy, x + dx + if not (0 <= ny <= ah - tile and 0 <= nx <= aw - tile): + continue + if (ny, nx) in visited: + continue + visited.add((ny, nx)) + d = float(np.sqrt( + ((self._tile_desc(pa, ny, nx, tile) - seed_desc) + ** 2).sum())) + if d <= budget: + queue.append((ny, nx)) + if int(coverage.sum()) >= target_area: + break + + # Still short (very small frame, tight mask): dilate until the area + # matches the setting, keeping the organic grown shape. + cv2 = self._try_cv2() + guard = 0 + while int(coverage.sum()) < target_area and guard < 64: + guard += 1 + if cv2 is not None: + grown = cv2.dilate(coverage.astype(np.uint8), np.ones((3, 3), np.uint8)) + else: + padded = np.pad(coverage, 1, mode="constant", constant_values=False) + grown = (padded[1:-1, 1:-1] | padded[:-2, 1:-1] | padded[2:, 1:-1] + | padded[1:-1, :-2] | padded[1:-1, 2:]) + if not grown.any() or np.array_equal(grown, coverage): + break + coverage = grown.astype(bool) + if int(coverage.sum()) > target_area * 1.1: + break + + if cv2 is not None: + kernel = np.ones((5, 5), np.uint8) + cleaned = cv2.morphologyEx(coverage.astype(np.uint8), cv2.MORPH_CLOSE, kernel) + cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, kernel) + if cleaned.any(): + coverage = cleaned.astype(bool) + return coverage + + def _feather(self, mask): + np = self._np + feather = self._config["feather"] + if feather <= 0: + return mask.astype(np.float32) + # Scale the softness to the blob, not the frame: a frame-sized blur + # turns an 18% cutout into a soft rectangle covering half the picture. + rows = np.where(mask.max(axis=1) > 0.01)[0] + cols = np.where(mask.max(axis=0) > 0.01)[0] + if rows.size == 0 or cols.size == 0: + return mask.astype(np.float32) + blob = max(3, min(int(rows.size), int(cols.size))) + radius = max(1, int(round(feather / 100.0 * 0.5 * blob))) + cv2 = self._try_cv2() + if cv2 is not None: + k = max(1, radius | 1) + return cv2.GaussianBlur(mask.astype(np.float32), (k, k), 0) + blurred = mask.astype(np.float32) + kernel = np.ones((3, 3), np.float32) / 9.0 + for _ in range(radius): + padded = np.pad(blurred, 1, mode="edge") + blurred = (padded[:-2, :-2] * kernel[0, 0] + padded[:-2, 1:-1] * kernel[0, 1] + + padded[:-2, 2:] * kernel[0, 2] + padded[1:-1, :-2] * kernel[1, 0] + + padded[1:-1, 1:-1] * kernel[1, 1] + padded[1:-1, 2:] * kernel[1, 2] + + padded[2:, :-2] * kernel[2, 0] + padded[2:, 1:-1] * kernel[2, 1] + + padded[2:, 2:] * kernel[2, 2]) + return blurred + + # --- spawn / composite --------------------------------------------- + + def _donor_index(self, birth): + newest = self._queue[-1].index if self._queue else birth + return min(birth + self._effective_gap, newest) + + def _noise_field(self, birth): + np = self._np + rng = np.random.default_rng(self._seed * 1000003 + birth) + h, w = self._shape[:2] + # Low-frequency noise: a per-pixel field dissolves the cutout into salt + # and pepper dust, which reads as compression garbage instead of a + # melt. Coarse noise breaks it into blotches. + small = rng.integers(0, 256, (max(2, h // 16), max(2, w // 16)), + dtype=np.uint8) + coarse = self._resize(small.astype(np.float32), (h, w)) + cv2 = self._try_cv2() + if cv2 is not None: + sigma = max(1.0, min(h, w) * 0.02) + coarse = cv2.GaussianBlur(coarse, (0, 0), sigma) + else: + for _ in range(3): + padded = np.pad(coarse, 1, mode="edge") + coarse = (padded[:-2, :-2] + padded[:-2, 1:-1] + padded[:-2, 2:] + + padded[1:-1, :-2] + padded[1:-1, 1:-1] + padded[1:-1, 2:] + + padded[2:, :-2] + padded[2:, 1:-1] + padded[2:, 2:]) / 9.0 + span = float(coarse.max() - coarse.min()) or 1.0 + return np.clip((coarse - coarse.min()) / span * 255.0, 0, 255).astype(np.uint8) + + def _spawn(self, packet): + np = self._np + birth = packet.index + newest = self._queue[-1].index if self._queue else birth + target = birth + self._effective_gap + if target > newest or newest <= birth: + return # no real future donor at the gap distance + donor_index = min(target, newest) + # locate the donor packet by contiguous index offset + front = self._queue[0].index + donor = self._queue[donor_index - front].frame + + h, w = self._shape[:2] + pa = self._resize(self._gray(packet.frame), self._analysis_shape) + fa = self._resize(self._gray(donor), self._analysis_shape) + flow = self._flow(pa, fa) + + seed = self._choose_seed(pa, fa, flow) + if seed is None: + return + ah, aw = self._analysis_shape + target_area = int(self._config["size"] / 100.0 * ah * aw) + + if self._config["shape"] == "rect": + mask_a = np.zeros((ah, aw), dtype=bool) + side = max(1, int(round(target_area ** 0.5))) + cy = min(ah - 1, seed[0] + (ah // 6)) + cx = min(aw - 1, seed[1] + (aw // 6)) + y0, x0 = max(0, cy - side // 2), max(0, cx - side // 2) + mask_a[y0:min(ah, y0 + side), x0:min(aw, x0 + side)] = True + else: + mask_a = self._grow_mask(pa, seed, target_area) + + if not mask_a.any(): + return + mask_full = self._resize(mask_a.astype(np.float32), (h, w)) + mask_full = self._feather(mask_full) + + # region flow at analysis scale, scaled to full resolution + region = mask_a + fx = float(flow[..., 0][region].mean()) + fy = float(flow[..., 1][region].mean()) + sx = w / max(1, aw) + sy = h / max(1, ah) + + center = np.argwhere(mask_full > 0.5) + cy_full, cx_full = (int(center[:, 0].mean()), int(center[:, 1].mean())) + + cut = { + "birth": birth, + "donor": donor.copy(), + # uint8 mask/accumulator keep a live cutout near three frames of + # memory instead of seven; the blend converts per frame anyway + "mask": np.clip(mask_full * 255.0, 0, 255).round().astype(np.uint8), + "acc": donor.copy() if self._config["mode"] == "smudge" else None, + "flow": (fx * sx, fy * sy), + "noise": self._noise_field(birth), + "center": (cx_full, cy_full), + } + self._cutouts.append(cut) + self._spawns += 1 + + def _composite(self, packet): + np = self._np + out = packet.frame # buffer already owns a copy; never mutate input + hold = self._config["hold"] + melt = self._config["melt"] + mode = self._config["mode"] + drift = self._config["drift"] / 100.0 + retention = self._config["retention"] / 100.0 + + alive = [] + for cut in self._cutouts: + age = packet.index - cut["birth"] + if age < 0 or age >= hold + melt: + continue + alive.append(cut) + + # life mask: full during hold, blotchy dissolve during melt + if age < hold: + life = np.float32(1.0) + fade = 1.0 + else: + progress = (age - hold + 1) / melt + life = (cut["noise"] > int(progress * 255)).astype(np.float32) + # ease the opacity down as the blotches open up, otherwise the + # tail of the melt stays fully opaque and eats the live frame + fade = float(max(0.0, 1.0 - progress) ** 0.6) + + mask = cut["mask"].astype(np.float32) / 255.0 + alpha = mask * life * fade + if alpha.max() <= 0: + continue + + if mode == "smudge": + fx, fy = cut["flow"] + acc = cut["acc"].astype(np.float32) + warped = np.roll(np.roll(acc, int(round(fy)), axis=0), + int(round(fx)), axis=1) + acc = warped * retention + cut["donor"].astype(np.float32) * (1.0 - retention) + cut["acc"] = np.clip(acc, 0, 255).round().astype(np.uint8) + src = acc + else: + dx = int(round(cut["flow"][0] * drift)) + dy = int(round(cut["flow"][1] * drift)) + src = np.roll(np.roll(cut["donor"], dy, axis=0), dx, axis=1) + + # Poisson seam (NORMAL_CLONE) during the sharp hold phase, else + # masked alpha blend. The dissolving mask is recomputed per frame + # so seamlessClone never receives a stale out-of-mask center. + cv2 = self._try_cv2() + mask_bin = (alpha > 0.5).astype(np.uint8) * 255 + if (self._config["seam"] == "poisson" and cv2 is not None + and age < hold and mask_bin.any()): + ys, xs = np.nonzero(mask_bin) + center = (int(round(xs.mean())), int(round(ys.mean()))) + try: + src8 = (np.ascontiguousarray(src) + if src.dtype == np.uint8 + else np.ascontiguousarray( + np.clip(src, 0, 255).astype(np.uint8))) + out = cv2.seamlessClone( + src8, np.ascontiguousarray(out), + np.ascontiguousarray(mask_bin), center, + cv2.NORMAL_CLONE) + continue + except Exception: + pass # fall through to alpha blend on any clone failure + a = alpha[..., None] + out = (out.astype(np.float32) * (1.0 - a) + + src.astype(np.float32) * a) + out = np.clip(out, 0, 255).round().astype(np.uint8) + self._cutouts = alive + return np.ascontiguousarray(out) + + # --- push / flush --------------------------------------------------- + + def _emit_front(self): + packet = self._queue.popleft() + if (len(self._cutouts) < self._config["cutouts"] + and packet.index % self._config["spawn_every"] + == self._seed % self._config["spawn_every"]): + self._spawn(packet) + frame = self._composite(packet) + yield replace(packet, frame=frame) + + def _drain(self): + while self._queue: + yield from self._emit_front() + self._cutouts.clear() + self._scene_sample = None + + def push(self, packet): + if self._np is None: + raise RuntimeError("initialize FutureCut before buffering") + np = self._np + if (not isinstance(packet.frame, np.ndarray) + or packet.frame.shape != self._shape + or packet.frame.dtype != np.uint8): + raise ValueError(f"expected uint8 RGB frame {self._shape}") + stamp = float(packet.timestamp) + if not math.isfinite(stamp): + raise ValueError("future-cut needs finite presentation timestamps") + + # reset on index discontinuity or timestamp rewind + if ((self._next_index is not None and packet.index != self._next_index) + or (self._last_timestamp is not None + and stamp < self._last_timestamp)): + yield from self._drain() + self._next_index = None + self._last_timestamp = None + + # scene-cut detection on the analysis frame + sample = self._resize( + packet.frame, (min(self._SCENE_SIDE, self._shape[0]), + min(self._SCENE_SIDE, self._shape[1]))).astype(np.float32) + cut = (self._scene_sample is not None and + float(np.abs(sample - self._scene_sample).mean()) + >= self._SCENE_CUT * 255) + if cut or packet.context.get("scene_cut"): + yield from self._drain() + self._next_index = None + self._last_timestamp = None + + self._scene_sample = sample + self._next_index = packet.index + 1 + self._last_timestamp = stamp + self._queue.append(replace(packet, frame=packet.frame.copy())) + self._total_pushed += 1 + + while (self._queue + and self._queue[-1].index - self._queue[0].index >= self._delay): + yield from self._emit_front() + + def flush(self): + """EOF: drain remaining packets, borrowing the latest available future.""" + if self._total_pushed <= self._effective_gap: + self.runtime_notes.append( + f"clip ({self._total_pushed} frames) is shorter than the " + f"look-ahead+gap horizon ({self._lookahead_frames}" + f"+{self._config['gap']} frames); passing through unchanged") + elif self._total_pushed < self._lookahead_frames + self._config["gap"]: + self.runtime_notes.append( + f"clip ({self._total_pushed} frames) shorter than " + f"look-ahead+gap; donor gap clamped") + if self._try_cv2() is None: + self.runtime_notes.append( + "flow is approximate (numpy block matching; install " + "requirements-collapse.txt for OpenCV DIS optical flow)") + yield from self._drain() diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..2cde2eb --- /dev/null +++ b/install.ps1 @@ -0,0 +1,37 @@ +# effex installer for windows: put an `effex` shim on PATH so it runs anywhere. +# idempotent - run it again after moving the repo and it repoints the shim. +$ErrorActionPreference = "Stop" + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$bin = Join-Path $env:LOCALAPPDATA "effex\bin" +New-Item -ItemType Directory -Force -Path $bin | Out-Null + +$python = Join-Path $here ".venv\Scripts\python.exe" +if (-not (Test-Path $python)) { + $found = Get-Command python -ErrorAction SilentlyContinue + if (-not $found) { + Write-Host "python is not on PATH - install it from https://python.org first" + exit 1 + } + $python = $found.Source +} + +$script = Join-Path $here "effex.py" +$shim = Join-Path $bin "effex.cmd" +@" +@echo off +"$python" "$script" %* +"@ | Set-Content -Path $shim -Encoding ASCII + +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +if ($userPath -notlike "*$bin*") { + [Environment]::SetEnvironmentVariable("Path", "$userPath;$bin", "User") + Write-Host "note: added $bin to your user PATH - open a new terminal" +} +Write-Host "effex -> $shim" + +$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue +if (-not $ffmpeg) { + Write-Host "note: ffmpeg is missing - run effex and answer y, or install it with" + Write-Host " winget install -e --id Gyan.FFmpeg" +} diff --git a/stateful_effects.py b/stateful_effects.py index d8bfbe3..073b55e 100644 --- a/stateful_effects.py +++ b/stateful_effects.py @@ -297,7 +297,7 @@ class ElasticCollapse: "intensity": 100, "horizontal": 100, "vertical": 100, "preserve": 75, "mode": "seam", "seam_strength": 100, "motion": 50, "smoothness": 70, "speed": 0.35, "coherence": 80, "seed": 0, - "block_size": 4, "mosh": 60, + "pixelate": False, "block_size": 4, "mosh": 60, } def __init__(self): @@ -331,6 +331,20 @@ def _number(value, default, low, high): return default return max(low, min(high, number)) if math.isfinite(number) else default + @staticmethod + def _flag(value, default: bool) -> bool: + """Accept bools, 0/1 and on/off-style strings from the CLI and menu.""" + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().lower() + if text in ("on", "yes", "true", "1", "y"): + return True + if text in ("off", "no", "false", "0", "n"): + return False + return default + def initialize(self, config: dict, frame_shape: tuple, fps: float, seed: int) -> None: try: @@ -353,6 +367,8 @@ def initialize(self, config: dict, frame_shape: tuple, fps: float, if mode not in ("elastic", "seam"): raise ValueError("collapse mode must be elastic or seam") self._config[key] = mode + elif key == "pixelate": + self._config[key] = self._flag(config.get(key, default), default) else: high = 4.0 if key == "speed" else (2147483647 if key == "seed" else 100) if key == "block_size": @@ -551,6 +567,9 @@ def process(self, frame, frame_index: int, timestamp: Optional[float], map_y += np.arange(height, dtype=np.float32)[:, None] warped = cv.remap(frame, map_x, map_y, interpolation=cv.INTER_LINEAR, borderMode=cv.BORDER_REFLECT_101) + if not self._config["pixelate"]: + self._previous_mosh_grid = None + return warped return self._pixel_mosh(warped, motion_delta) @@ -578,6 +597,9 @@ def __init__(self): self._scene_sample: Any = None self._coverage: Any = None self.capacity_fallbacks = 0 + self.effective_lookahead = 0.0 + self.effective_offset = 0.0 + self.runtime_notes = [] def initialize(self, config: dict, frame_shape: tuple, fps: float, seed: int) -> None: try: @@ -617,12 +639,20 @@ def initialize(self, config: dict, frame_shape: tuple, fps: float, seed: int) -> self._capacity = min(self.MAX_FRAMES, (self.MAX_STATE_BYTES - 65536) // frame_bytes) delay = self._config["lookahead"] if self._config["strength"] and self._config["offset"] else 0 rate = ElasticCollapse._number(fps, 30.0, 0.001, 1000) - needed = math.ceil(delay * rate) + 1 - if needed > self._capacity: + if self._capacity < 1: raise ValueError( - f"inversion lookahead needs {needed} frames but the 128 MiB/128-frame " - f"budget fits {self._capacity}; lower lookahead or resize before inversion" + "inversion cannot fit one RGB frame in its 128 MiB buffer budget; " + "resize before inversion" ) + self.effective_lookahead = min(delay, (self._capacity - 1) / rate) + self.effective_offset = min(self._config["offset"], self.effective_lookahead) + self.runtime_notes = [] + if self.effective_lookahead + 1e-9 < delay: + self.runtime_notes.append( + f"inversion buffer budget limits look-ahead to {self.effective_lookahead:.3f}s " + f"and future offset to {self.effective_offset:.3f}s " + f"at {self._shape[1]}x{self._shape[0]} / {rate:g} fps; " + "resize before inversion for a longer horizon") self.reset() @property @@ -717,7 +747,7 @@ def _mix(self, current, future): def _emit_oldest(self): current = self._queue.popleft() - target = float(current.timestamp) + self._config["offset"] + target = float(current.timestamp) + self.effective_offset future = next((p for p in self._queue if float(p.timestamp) + 1e-9 >= target), self._queue[-1] if self._queue else current) return self._mix(current, future) @@ -744,14 +774,14 @@ def push(self, packet): self._scene_sample = sample self._next_index = packet.index + 1 self._last_timestamp = stamp - if self._config["strength"] == 0 or self._config["offset"] == 0: + if self._config["strength"] == 0 or self.effective_offset == 0: yield replace(packet, frame=packet.frame.copy()) return while len(self._queue) >= self._capacity: self.capacity_fallbacks += 1 yield self._emit_oldest() self._queue.append(replace(packet, frame=packet.frame.copy())) - while self._queue and stamp - float(self._queue[0].timestamp) + 1e-9 >= self._config["lookahead"]: + while self._queue and stamp - float(self._queue[0].timestamp) + 1e-9 >= self.effective_lookahead: yield self._emit_oldest() def flush(self): diff --git a/tests/archaeology_integration.py b/tests/archaeology_integration.py new file mode 100644 index 0000000..de2104b --- /dev/null +++ b/tests/archaeology_integration.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Pixel archaeology (future cut) registry, media pipeline and terminal wiring.""" +import importlib.util +import json +import sys +import tempfile +import unittest +from fractions import Fraction +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from harness import (EFFEX, audio_codec, env_for, make_clip, run, # noqa: E402 + video_dims, video_frames) + + +@unittest.skipUnless(importlib.util.find_spec("numpy"), "install requirements-stateful.txt") +class ArchaeologyIntegration(unittest.TestCase): + def test_registered_and_lazy_without_numeric_dependencies(self): + import effex + self.assertIn("archaeology", effex.EFFECT_BY_KEY) + effect = effex.EFFECT_BY_KEY["archaeology"] + self.assertEqual(effect.name, "pixel archaeology") + self.assertIsNotNone(effect.frame_processor) + self.assertTrue(getattr(effect.frame_processor, "requires_timestamps", False)) + members = [key for _label, keys in effex.EFFECT_GROUPS for key in keys] + self.assertEqual(members.count("archaeology"), 1) + listed = run([sys.executable, "-S", EFFEX, "--list", "--json"]) + self.assertEqual(listed.returncode, 0, listed.stderr.decode()) + data = json.loads(listed.stdout) + self.assertIn("archaeology", [e["key"] for e in data["effects"]]) + + def test_cli_renders_future_cutouts_and_preserves_audio(self): + with tempfile.TemporaryDirectory(prefix="effex-archaeology-cli-") as directory: + root = Path(directory) + clip = root / "source.mp4" + self.assertTrue(make_clip(clip, seconds=2, size="160x96")) + env = env_for(root, root / "out") + proc = run([EFFEX, "archaeology", clip, "--json", "--preset", "ultrafast", + "--set", "gap=6", "--set", "spawn_every=8", "--set", + "size=20", "--set", "hold=2", "--set", "melt=4", + "--set", "seam=none", "--set", "seed=17"], env_extra=env) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + payload = json.loads(proc.stdout.decode()) + output = Path(payload["output"]) + self.assertTrue(output.exists()) + self.assertEqual(video_dims(output), (160, 96)) + self.assertEqual(video_frames(output), video_frames(clip)) + self.assertEqual(audio_codec(output), "aac") + self.assertIsInstance(payload.get("warnings", []), list) + decoded = run(["ffmpeg", "-v", "error", "-i", str(output), + "-an", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]) + self.assertEqual(decoded.returncode, 0, decoded.stderr.decode()) + self.assertEqual(len(decoded.stdout), video_frames(clip) * 160 * 96 * 3) + + def test_dry_run_describes_the_future_buffer(self): + with tempfile.TemporaryDirectory(prefix="effex-archaeology-dry-") as directory: + root = Path(directory) + clip = root / "source.mp4" + self.assertTrue(make_clip(clip, seconds=1, size="160x96")) + env = env_for(root, root / "out") + proc = run([EFFEX, "archaeology", clip, "--dry-run", + "--set", "gap=9", "--set", "lookahead=1.2", + "--set", "hold=3", "--set", "melt=5"], env_extra=env) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + text = proc.stdout.decode() + self.assertIn("archaeology.gap=9", text) + self.assertIn("archaeology.mode=smudge", text) + self.assertIn("stateful future-cut frame pass", text) + self.assertIn("9 frame(s) ahead", text) + self.assertIn("1.2s future buffer", text) + self.assertIn("3 sharp frames then 5 melt frames", text) + + def test_bad_choice_is_rejected(self): + with tempfile.TemporaryDirectory(prefix="effex-archaeology-bad-") as directory: + root = Path(directory) + clip = root / "source.mp4" + self.assertTrue(make_clip(clip, seconds=1, size="160x96")) + env = env_for(root, root / "out") + for setting in ("pick=sparkle", "shape=oval", "seam=glow", + "mode=warp", "gap=0", "size=41", "lookahead=4"): + proc = run([EFFEX, "archaeology", clip, "--dry-run", + "--set", setting], env_extra=env) + self.assertEqual(proc.returncode, 2, setting) + self.assertNotIn(b"Traceback", proc.stderr) + + def test_filter_and_timestamped_chains(self): + with tempfile.TemporaryDirectory(prefix="effex-archaeology-chains-") as directory: + root = Path(directory) + clip = root / "source.mp4" + self.assertTrue(make_clip(clip, seconds=0.5, size="160x96")) + env = env_for(root, root / "out") + chains = ["neon+archaeology+deepfry", "archaeology+sediment", + "sediment+archaeology"] + if importlib.util.find_spec("av"): + chains += ["inversion+archaeology", "archaeology+inversion"] + for chain in chains: + with self.subTest(chain=chain): + proc = run([EFFEX, chain, clip, "--json", "--preset", "ultrafast"], + env_extra=env) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + output = json.loads(proc.stdout)["output"] + self.assertEqual(video_dims(output), (160, 96)) + self.assertEqual(audio_codec(output), "aac") + if importlib.util.find_spec("av"): + vfr = root / "vfr.mkv" + made = run(["ffmpeg", "-v", "error", "-y", "-copyts", "-f", "lavfi", "-i", + "testsrc2=s=160x96:r=10:d=1.2", "-vf", + "select='not(eq(mod(n,4),3))',setpts=PTS+5/TB", + "-fps_mode", "vfr", "-c:v", "ffv1", vfr]) + self.assertEqual(made.returncode, 0, made.stderr.decode()) + proc = run([EFFEX, "archaeology+inversion", vfr, "--json"], env_extra=env) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + + def pts(path): + result = run(["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "frame=pts_time", "-of", "json", path]) + return [Fraction(f["pts_time"]) for f in json.loads(result.stdout)["frames"]] + self.assertEqual(pts(json.loads(proc.stdout)["output"]), pts(vfr)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/benchmark_collapse.py b/tests/benchmark_collapse.py index 5930856..f528032 100644 --- a/tests/benchmark_collapse.py +++ b/tests/benchmark_collapse.py @@ -20,7 +20,7 @@ sys.path.insert(0, str(REPO)) -def worker(mode, width, height, frames): +def worker(mode, width, height, frames, pixelate=False): import cv2 import numpy as np from stateful_effects import ElasticCollapse @@ -32,7 +32,8 @@ def worker(mode, width, height, frames): ((x // 20 + y // 20) % 2) * 255), axis=-1).astype(np.uint8) del x, y processor = ElasticCollapse() - config = dict(ElasticCollapse.DEFAULTS, mode=mode, seed=17) + config = dict(ElasticCollapse.DEFAULTS, mode=mode, seed=17, + pixelate=pixelate) processor.initialize(config, frame.shape, 30.0, 17) times, update_times = [], [] # Warm up libraries, then reset so measured frame zero includes first setup. @@ -49,7 +50,8 @@ def worker(mode, width, height, frames): if processor.seam_updates != updates: update_times.append(elapsed) return { - "mode": mode, "width": width, "height": height, "frames": frames, + "mode": mode, "pixelate": pixelate, + "width": width, "height": height, "frames": frames, "config": config, "mean_ms": float(np.mean(times)), "p50_ms": float(np.percentile(times, 50)), "p95_ms": float(np.percentile(times, 95)), "max_ms": max(times), @@ -69,30 +71,37 @@ def main(): parser.add_argument("--frames", type=int, default=120) parser.add_argument("--output", type=Path) parser.add_argument("--worker", choices=("elastic", "seam")) + parser.add_argument("--pixelate", choices=("off", "on"), default="off", + help="benchmark the opt-in pixel grid (default: off, the effect default)") parser.add_argument("--width", type=int, default=640) parser.add_argument("--height", type=int, default=360) args = parser.parse_args() if args.frames < 1: parser.error("--frames must be positive") if args.worker: - print(json.dumps(worker(args.worker, args.width, args.height, args.frames))) + print(json.dumps(worker(args.worker, args.width, args.height, args.frames, + pixelate=args.pixelate == "on"))) return + pixelate = args.pixelate == "on" rows = [] for mode in ("elastic", "seam"): for width, height in ((640, 360), (1280, 720)): proc = subprocess.run( [sys.executable, str(Path(__file__).resolve()), "--worker", mode, - "--width", str(width), "--height", str(height), "--frames", str(args.frames)], + "--width", str(width), "--height", str(height), "--frames", str(args.frames), + "--pixelate", args.pixelate], capture_output=True, text=True, check=True, cwd=REPO, ) row = json.loads(proc.stdout) rows.append(row) - print(f"{mode:7} {width}x{height}: {row['mean_ms']:.2f} ms mean, " + print(f"{mode:7} pixelate={args.pixelate:3} {width}x{height}: " + f"{row['mean_ms']:.2f} ms mean, " f"{row['p95_ms']:.2f} ms p95, {row['peak_rss_mib']:.1f} MiB peak RSS") assert len(rows) == 4 report = { "recorded_utc": datetime.now(timezone.utc).isoformat(), "platform": platform.platform(), "logical_cpus": os.cpu_count(), + "pixelate": pixelate, "scope": "synthetic moving RGB; process() only; fresh process per mode/size; not end-to-end render FPS", "results": rows, } diff --git a/tests/benchmark_futurecut.py b/tests/benchmark_futurecut.py new file mode 100644 index 0000000..33b47da --- /dev/null +++ b/tests/benchmark_futurecut.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""CPU-only FutureCut benchmark; each size/mode runs in a fresh process. + +python3 tests/benchmark_futurecut.py --frames 120 --output benchmarks/futurecut.json +RSS is process peak (Linux), including imports, buffered frames and masks. +Timing covers push() only, not FFmpeg, decode/encode or initialization. +""" +import argparse +import json +import os +import platform +import resource +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + + +def worker(mode, width, height, frames): + import cv2 + import numpy as np + from fractions import Fraction + from frame_pipeline import FramePacket + from future_cut import FutureCut + + # Fixed thread count avoids benchmark changes from OpenCV's host defaults. + cv2.setNumThreads(1) + y, x = np.indices((height, width)) + frame = np.stack(((x * 2) % 256, (y * 3) % 256, + ((x // 20 + y // 20) % 2) * 255), axis=-1).astype(np.uint8) + del x, y + + fps = 30.0 + processor = FutureCut() + processor.initialize({"gap": 12, "spawn_every": 24, "mode": mode}, + frame.shape, fps, 17) + + def packet(index): + source = np.roll(frame, index * 2, axis=1) + return FramePacket(source, index, Fraction(index * 1000, int(fps * 1000)), + Fraction(1, 1000), Fraction(1000, int(fps * 1000)), {}) + + # Warm up libraries, then reset so measured frame zero includes first setup. + list(processor.push(packet(0))) + processor.reset() + + times = [] + spawned = 0 + peak_state = 0 + for index in range(frames): + before = processor.spawns + start = time.perf_counter() + out = list(processor.push(packet(index))) + elapsed = (time.perf_counter() - start) * 1000 + for item in out: + assert item.frame.shape == frame.shape and item.frame.dtype == np.uint8 + times.append(elapsed) + peak_state = max(peak_state, processor.state_nbytes) + if processor.spawns != before: + spawned += 1 + list(processor.flush()) + return { + "mode": mode, + "width": width, "height": height, "frames": frames, + "config": processor.serialize_config(), + "mean_ms": float(np.mean(times)), + "p50_ms": float(np.percentile(times, 50)), + "p95_ms": float(np.percentile(times, 95)), "max_ms": max(times), + "processor_fps": 1000 / float(np.mean(times)), + "spawns": spawned, + "capacity": processor.capacity, + "peak_rss_mib": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024, + "persistent_state_mib": peak_state / (1024 * 1024), + "python": platform.python_version(), "numpy": np.__version__, + "opencv": cv2.__version__, "opencv_threads": cv2.getNumThreads(), + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--frames", type=int, default=120) + parser.add_argument("--output", type=Path) + parser.add_argument("--worker", choices=("smudge", "slide")) + parser.add_argument("--width", type=int, default=640) + parser.add_argument("--height", type=int, default=360) + args = parser.parse_args() + if args.frames < 1: + parser.error("--frames must be positive") + if args.worker: + print(json.dumps(worker(args.worker, args.width, args.height, args.frames))) + return + rows = [] + for mode in ("smudge", "slide"): + for width, height in ((640, 360), (1280, 720), (1920, 1080)): + proc = subprocess.run( + [sys.executable, str(Path(__file__).resolve()), "--worker", mode, + "--width", str(width), "--height", str(height), + "--frames", str(args.frames)], + capture_output=True, text=True, check=True, cwd=REPO, + ) + row = json.loads(proc.stdout) + rows.append(row) + print(f"{mode:6} {width}x{height}: {row['mean_ms']:.2f} ms mean, " + f"{row['p95_ms']:.2f} ms p95, {row['peak_rss_mib']:.1f} MiB peak RSS, " + f"{row['spawns']} spawns") + assert len(rows) == 6 + report = { + "recorded_utc": datetime.now(timezone.utc).isoformat(), + "platform": platform.platform(), "logical_cpus": os.cpu_count(), + "scope": "synthetic moving RGB; push() only; fresh process per mode/size; " + "not end-to-end render FPS", + "results": rows, + } + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2) + "\n") + else: + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/cli_regression.py b/tests/cli_regression.py index 44a2375..c319c60 100644 --- a/tests/cli_regression.py +++ b/tests/cli_regression.py @@ -361,17 +361,137 @@ def video_frames(path: Path) -> int: proc = run([EFFEX, "blockswap", static, "--json"], env_extra=env) moved = Path(json.loads(proc.stdout.decode())["output"]) - def frame_hash(path: Path, at: str) -> str: + def frame_bytes(path: Path, at: str) -> bytes: proc = run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-ss", at, "-i", str(path), "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]) - return proc.stdout.hex()[:64] - - h1, h2 = frame_hash(moved, "0.4"), frame_hash(moved, "2.2") - c.check("the blocks really travel (frames differ)", - h1 != h2, f"{h1} vs {h2}") + return proc.stdout + + def mean_delta(left: bytes, right: bytes) -> float: + """Average per-channel difference; codec noise stays below ~1.""" + if not left or len(left) != len(right): + return float("inf") + return sum(abs(a - b) for a, b in zip(left, right)) / len(left) + + early, late = frame_bytes(moved, "0.4"), frame_bytes(moved, "2.2") + source_frame = frame_bytes(static, "1.0") + drift = mean_delta(early, late) + c.check("the blocks stay in place (only codec noise differs)", + drift < 2.0, f"mean delta {drift:.2f}") + c.check("blockswap really relocates the blocks", + mean_delta(source_frame, early) > 3.0, + f"mean delta {mean_delta(source_frame, early):.2f}") c.check("blockswap keeps the frame size", video_dims(moved) == (128, 128)) + proc = run([EFFEX, "blockswap", static, "--set", "blocks=1", "--dry-run"], + env_extra=env) + one = proc.stdout.decode() + proc = run([EFFEX, "blockswap", static, "--set", "blocks=5", "--dry-run"], + env_extra=env) + five = proc.stdout.decode() + c.check("blockswap block count is selectable", + proc.returncode == 0 and one.count("swaprect=") == 1 + and five.count("swaprect=") == 5, + f"one={one.count('swaprect=')} five={five.count('swaprect=')}") + chain_line = next((line.split("filter:", 1)[1].strip() + for line in five.splitlines() + if "filter:" in line), "") + if not chain_line: + chain_line = five.split("-vf ", 1)[-1].split(" -map", 1)[0] + c.check("blockswap has no time expression left", + chain_line and "sin(" not in chain_line + and "t/" not in chain_line, chain_line[-200:]) + import re as _re + rects = [tuple(int(n) for n in m) for m in _re.findall( + r"swaprect=w=(\d+):h=(\d+):x1=(\d+):y1=(\d+):x2=(\d+):y2=(\d+)", five)] + boxes = [(x1, y1, w_, h_) for w_, h_, x1, y1, _x2, _y2 in rects] + \ + [(_x2, _y2, w_, h_) for w_, h_, _x1, _y1, _x2, _y2 in rects] + overlaps = [(a, b) for i, a in enumerate(boxes) for b in boxes[i + 1:] + if (a[0] < b[0] + b[2] and b[0] < a[0] + a[2] + and a[1] < b[1] + b[3] and b[1] < a[1] + a[3])] + c.check("blockswap keeps its blocks from overlapping", not overlaps, + str(overlaps[:3])) + proc = run([EFFEX, "blockswap", static, "--set", "seed=7", "--dry-run"], + env_extra=env) + seeded = proc.stdout.decode() + proc = run([EFFEX, "blockswap", static, "--set", "seed=8", "--dry-run"], + env_extra=env) + c.check("the seeded block layout is reproducible and seed-dependent", + seeded != proc.stdout.decode() + and seeded == run([EFFEX, "blockswap", static, "--set", "seed=7", + "--dry-run"], env_extra=env).stdout.decode()) + for setting in ("blocks=0", "blocks=9", "size=4", "size=61"): + proc = run([EFFEX, "blockswap", static, "--set", setting, + "--dry-run"], env_extra=env) + c.check(f"blockswap rejects {setting}", proc.returncode == 2, + proc.stderr.decode()[:160]) + + # ---- teleporting blocks + light codec mosh ------------------------ + proc = run([EFFEX, "blockswap", static, "--set", "teleport=0", + "--dry-run"], env_extra=env) + c.check("teleport=0 keeps one static layout", + proc.returncode == 0 and "enable=" not in proc.stdout.decode(), + proc.stdout.decode()[-200:]) + proc = run([EFFEX, "blockswap", clip, "--set", "blocks=2", + "--set", "teleport=1", "--dry-run"], env_extra=env) + windows = proc.stdout.decode() + c.check("blocks teleport on a timer", + proc.returncode == 0 + and "enable='between(t,1,2)'" in windows + and windows.count("swaprect=") == 6, + str(windows.count("swaprect="))) + c.check("a new layout is drawn for each teleport window", + len({tuple(m) for m in _re.findall( + r"x1=(\d+):y1=(\d+):x2=(\d+):y2=(\d+)", windows)}) >= 4) + + proc = run([EFFEX, "blockswap", clip, "--set", "mosh=40", "--dry-run"], + env_extra=env) + mult = proc.stdout.decode() + c.check("mosh switches blockswap to the codec pipeline", + proc.returncode == 0 and "mpeg4" in mult and "pass 2" in mult, + mult[-300:]) + c.check("mosh strength is reported", + "mosh=40%" in mult and "light codec mosh" in mult, mult[-400:]) + for setting in ("teleport=-1", "mosh=101", "mosh_grit=32", "mosh_hold=1"): + proc = run([EFFEX, "blockswap", clip, "--set", setting, "--dry-run"], + env_extra=env) + c.check(f"blockswap rejects {setting}", proc.returncode == 2, + proc.stderr.decode()[:160]) + + proc = run([EFFEX, "blockswap+deepfry", clip, "--dry-run"], env_extra=env) + c.check("the codec mosh makes blockswap a separate render", + proc.returncode == 2 and b"multi-pass" in proc.stderr, + proc.stderr.decode()[:200]) + + mosh_env = dict(env) + proc = run([EFFEX, "blockswap", clip, "--set", "teleport=1", "--json", + "--preset", "ultrafast"], env_extra=mosh_env) + c.check("teleporting without mosh still renders", proc.returncode == 0, + proc.stderr.decode()[:300]) + plain = Path(json.loads(proc.stdout.decode())["output"]) \ + if proc.returncode == 0 else None + + proc = run([EFFEX, "blockswap", clip, "--set", "teleport=1", + "--set", "mosh=40", "--json", "--preset", "ultrafast"], + env_extra=mosh_env) + c.check("a teleporting moshed render succeeds", proc.returncode == 0, + proc.stderr.decode()[:300]) + if proc.returncode == 0 and plain is not None: + moshed = Path(json.loads(proc.stdout.decode())["output"]) + codec = next(s["codec_name"] for s in ffprobe_streams(moshed)["streams"] + if s["codec_type"] == "video") + c.check("the mosh render is the codec stream", codec == "mpeg4", codec) + c.check("mosh keeps every frame", + video_frames(moshed) == video_frames(clip), + f"{video_frames(moshed)} vs {video_frames(clip)}") + c.check("mosh keeps the audio and frame size", + audio_codec(moshed) == "aac" and video_dims(moshed) == (640, 360)) + early = frame_bytes(moshed, "0.5") + # where the encoder puts keyframes varies with the ffmpeg build, so the + # gate is "beyond codec noise" (a noise-only delta stays under 2) + delta = mean_delta(frame_bytes(plain, "0.5"), early) + c.check("mosh really changes the picture", delta > 3.0, f"{delta:.2f}") + proc = run([EFFEX, "dropout", clip, "--set", "chance=30", "--dry-run"], env_extra=env) c.check("dropout dry-run carries the random select", @@ -688,12 +808,12 @@ def frame_hash(path: Path, at: str) -> str: "contrast=1.90" in qualified and bare.split("-vf ")[-1] == qualified.split("-vf ")[-1]) - proc = run([EFFEX, "zoom+blockswap", clip, "--set", "speed=fast", + proc = run([EFFEX, "zoom+wobble", clip, "--set", "speed=fast", "--dry-run"], env_extra=env) c.check("a shared key must be qualified", proc.returncode == 2) c.check("the ambiguity names both qualified forms", b"zoom.speed=..." in proc.stderr and - b"blockswap.speed=..." in proc.stderr, proc.stderr.decode()[:200]) + b"wobble.speed=..." in proc.stderr, proc.stderr.decode()[:200]) proc = run([EFFEX, "datamosh+deepfry", clip, "--dry-run"], env_extra=env) c.check("a multi-pass effect is refused in a chain", diff --git a/tests/collapse_regression.py b/tests/collapse_regression.py index 5dbae31..5f7edd3 100644 --- a/tests/collapse_regression.py +++ b/tests/collapse_regression.py @@ -177,6 +177,44 @@ def test_collapse_is_registered_with_all_controls(self): ("intensity", "horizontal", "vertical", "seam_strength")], [100, 100, 100, 100]) self.assertEqual((defaults["block_size"], defaults["mosh"]), (4, 60)) + self.assertIs(defaults["pixelate"], False) + self.assertIs(stateful_effects.ElasticCollapse.DEFAULTS["pixelate"], False) + + def test_pixelation_is_opt_in_and_off_by_default(self): + y, x = np.indices((64, 96)) + smooth = np.stack((x * 255 // 95, y * 255 // 63, (x + y) * 255 // 158), + axis=-1).astype(np.uint8) + + def run_lengths(processor): + out = processor.process(smooth, 0, 0.0, {}) + row = out[32] + runs, current = [], 1 + for left, right in zip(row, row[1:]): + if np.array_equal(left, right): + current += 1 + else: + runs.append(current) + current = 1 + runs.append(current) + return float(np.median(runs)), len(runs) + + clean = self.processor(mode="seam", preserve=0, _clip_duration=1) + blocky = self.processor(mode="seam", preserve=0, _clip_duration=1, + pixelate=True, block_size=4, mosh=0) + clean_run, clean_edges = run_lengths(clean) + blocky_run, blocky_edges = run_lengths(blocky) + self.assertLessEqual(clean_run, 2, "default output must not be blocky") + self.assertGreaterEqual(blocky_run, 3, "pixelate=on must produce blocks") + self.assertGreater(clean_edges, blocky_edges * 1.8, + f"clean={clean_edges} blocky={blocky_edges}") + + # Block knobs are inert while pixelation is off. + for settings in ({"block_size": 1, "mosh": 0}, + {"block_size": 16, "mosh": 100}): + other = self.processor(mode="seam", preserve=0, _clip_duration=1, + **settings) + np.testing.assert_array_equal(clean.process(smooth, 0, 0.0, {}), + other.process(smooth, 0, 0.0, {})) def test_seam_mode_is_distinct_and_zero_strength_disables_it(self): frame = self.frame() @@ -228,8 +266,10 @@ def test_block_size_reduces_high_frequency_detail(self): y, x = np.indices((64, 96)) checker = (((x + y) % 2) * 255).astype(np.uint8) frame = np.repeat(checker[:, :, None], 3, axis=2) - fine = self.processor(mode="seam", block_size=1, mosh=0, preserve=0) - chunky = self.processor(mode="seam", block_size=4, mosh=0, preserve=0) + fine = self.processor(mode="seam", pixelate=True, block_size=1, mosh=0, + preserve=0) + chunky = self.processor(mode="seam", pixelate=True, block_size=4, mosh=0, + preserve=0) fine_out = fine.process(frame, 0, 0.0, {}) chunky_out = chunky.process(frame, 0, 0.0, {}) fine_edges = np.count_nonzero(np.diff(fine_out[32, :, 0])) @@ -246,7 +286,7 @@ def test_mosh_carries_old_blocks_only_when_content_moves(self): def run(mosh): processor = self.processor( - mode="seam", block_size=4, mosh=mosh, preserve=0, + mode="seam", pixelate=True, block_size=4, mosh=mosh, preserve=0, coherence=0, _clip_duration=1, ) processor.process(before, 0, 0.0, {}) @@ -258,7 +298,8 @@ def run(mosh): self.assertGreater(float(np.abs(carried.astype(float) - clean).mean()), 2) def run_static(mosh): - processor = self.processor(mode="seam", block_size=4, mosh=mosh) + processor = self.processor(mode="seam", pixelate=True, + block_size=4, mosh=mosh) processor.process(before, 0, 0.0, {}) return processor.process(before, 1, 1 / 30, {}) @@ -340,7 +381,7 @@ def test_cli_bounds_and_real_renders(self): "preserve=101", "mode=unknown", "seam_strength=101", "motion=-1", "smoothness=101", "speed=-1", "speed=5", "block_size=0", "block_size=17", "mosh=-1", "mosh=101", - "coherence=101", "seed=-1"): + "coherence=101", "seed=-1", "pixelate=maybe"): with self.subTest(invalid=setting): proc = run([EFFEX, "collapse", clip, "--set", setting, "--dry-run"], env_extra=env) @@ -369,6 +410,25 @@ def test_cli_bounds_and_real_renders(self): self.assertEqual(int(stream["nb_frames"]), 30) self.assertAlmostEqual(float(stream["duration"]), 1, delta=0.05) + # Pixelation is a real switch, not just a config field. + outputs = {} + for pixelate in ("off", "on"): + args = [EFFEX, "collapse", clip, "--set", "mode=seam", + "--set", f"pixelate={pixelate}", "--set", "preserve=0", + "--json", "--preset", "ultrafast", + "--output", str(out_dir / f"pixelate-{pixelate}.mp4")] + proc = run(args, env_extra=env) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + data = json.loads(proc.stdout) + self.assertIs(data["options"]["pixelate"], pixelate == "on") + decoded = run(["ffmpeg", "-v", "error", "-i", data["output"], + "-frames:v", "1", "-f", "rawvideo", + "-pix_fmt", "rgb24", "-"]) + self.assertEqual(decoded.returncode, 0, decoded.stderr.decode()) + outputs[pixelate] = np.frombuffer(decoded.stdout, np.uint8) + self.assertFalse(np.array_equal(outputs["off"], outputs["on"]), + "the off render must not be pixelated") + def test_optional_dependency_error_and_stdlib_import(self): from unittest.mock import patch with patch.dict(sys.modules, {"cv2": None}): diff --git a/tests/datamosh_features.py b/tests/datamosh_features.py index 4437515..103b22d 100644 --- a/tests/datamosh_features.py +++ b/tests/datamosh_features.py @@ -65,6 +65,50 @@ def main() -> int: env = {"EFFEX_INPUT_DIR": str(in_dir), "EFFEX_OUTPUT_DIR": str(out_dir), "NO_COLOR": "1"} + proc = run([EFFEX, "datamosh", first, "--with", str(second), + "--json"], env_extra=env) + c.check("--with joins two clips using the heavy preset by default", + proc.returncode == 0, proc.stderr.decode()[:300]) + if proc.returncode == 0: + heavy = json.loads(proc.stdout.decode()) + c.check("JSON reports the actual tuned heavy settings", + heavy["options"]["preset"] == "heavy" and + heavy["options"]["bloom"] == 12 and + heavy["options"]["grit"] == 18) + output = Path(heavy["output"]) + video = next(s for s in ffprobe_streams(output)["streams"] + if s["codec_type"] == "video") + c.check("heavy two-clip blooming preserves all frame slots", + int(video["nb_frames"]) == 20) + decode = run(["ffmpeg", "-v", "error", "-i", str(output), + "-an", "-f", "null", "-"]) + c.check("heavy output decodes without damaged-header errors", + decode.returncode == 0 and not decode.stderr, + decode.stderr.decode()[:300]) + proc = run([EFFEX, "datamosh", first, "--with", str(second), + "--set", "preset=standard", "--json"], env_extra=env) + c.check("standard preset remains available for the old look", + proc.returncode == 0, proc.stderr.decode()[:300]) + if proc.returncode == 0: + standard = json.loads(proc.stdout.decode()) + c.check("heavy changes decoded pixels, not just metadata", + frame_rgb_mean(Path(heavy["output"]), "1.7") != + frame_rgb_mean(Path(standard["output"]), "1.7")) + + proc = run([EFFEX, "pixelize", first, "--with", str(second), + "--dry-run"], env_extra=env) + c.check("--with is explicitly datamosh-only", proc.returncode == 2 + and b"datamosh" in proc.stderr) + proc = run([EFFEX, "datamosh", first, "--with", str(second), + "--set", "second=other.mp4", "--dry-run"], env_extra=env) + c.check("conflicting secondary inputs are not silently ignored", + proc.returncode == 2 and b"conflict" in proc.stderr) + for setting in ("bloom=0", "bloom=61", "bloom_window=0", + "bloom_window=13", "preset=unknown"): + proc = run([EFFEX, "datamosh", first, "--set", setting, + "--dry-run"], env_extra=env) + c.check(f"invalid {setting} is rejected", proc.returncode == 2) + proc = run([EFFEX, "datamosh", first, "--set", "second=motion.mp4", "--set", "hold=4", "--json"], env_extra=env) c.check("a second video joins the datamosh stream", diff --git a/tests/datamosh_stream.py b/tests/datamosh_stream.py new file mode 100644 index 0000000..194b4c6 --- /dev/null +++ b/tests/datamosh_stream.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Codec blooming must reuse prediction data, not duplicate display frames.""" +import shutil +import sys +import tempfile +import unittest +from fractions import Fraction +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from effex import (EFFECT_BY_KEY, apply_cli_opts, build_parser, default_opts, + parse_chain, strip_ivops) # noqa: E402 + + +@unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), + "ffmpeg and ffprobe are required") +class BloomTests(unittest.TestCase): + def test_bloom_does_not_break_byte_aligned_slices_at_clock_rollover(self): + import subprocess + with tempfile.TemporaryDirectory(prefix="effex-slice-mosh-") as temp: + source = Path(temp) / "sliced.m4v" + subprocess.run([ + "ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", + "testsrc2=size=640x360:rate=30:duration=3", + "-c:v", "mpeg4", "-q:v", "18", "-g", "12", "-bf", "0", + "-f", "m4v", str(source)], check=True) + source.write_bytes(strip_ivops(source.read_bytes(), "heavy", "chaos", + 123, bloom=12, bloom_window=2)) + decoded = subprocess.run([ + "ffmpeg", "-v", "error", "-i", str(source), "-an", + "-fps_mode", "passthrough", "-f", "rawvideo", + "-pix_fmt", "rgb24", "-"], capture_output=True, check=True) + self.assertFalse(decoded.stderr, decoded.stderr.decode()[:300]) + self.assertEqual(len(decoded.stdout), 90 * 640 * 360 * 3) + + def test_final_keyframe_still_has_a_fill_frame(self): + import subprocess + with tempfile.TemporaryDirectory(prefix="effex-short-mosh-") as temp: + source = Path(temp) / "short.m4v" + subprocess.run([ + "ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", + "testsrc2=size=64x64:rate=30:duration=0.1", + "-c:v", "mpeg4", "-g", "2", "-bf", "0", "-f", "m4v", str(source)], + check=True) + raw = source.read_bytes() + for bloom in (1, 12): + with self.subTest(bloom=bloom): + stream = strip_ivops(raw, "heavy", "next", 123, bloom=bloom) + self.assertEqual(stream.count(b"\x00\x00\x01\xb6"), 3) + + def test_heavy_is_tuned_and_explicit_knobs_override_presets_in_any_order(self): + defaults = default_opts(EFFECT_BY_KEY["datamosh"]) + self.assertEqual(defaults["preset"], "heavy") + self.assertGreaterEqual(defaults["bloom"], 12) + self.assertGreaterEqual(defaults["grit"], 12) + chain, problem = parse_chain("datamosh") + self.assertFalse(problem) + for settings in (("grit=7", "preset=standard"), + ("preset=standard", "grit=7")): + args = build_parser().parse_args([ + "datamosh", "a.mp4", "--set", settings[0], + "--set", settings[1]]) + args.size_given = False + opts, problem = apply_cli_opts(chain, args) + self.assertFalse(problem) + self.assertEqual(opts["datamosh"]["grit"], 7) + self.assertEqual(opts["datamosh"]["bloom"], 1) + self.assertEqual(opts["datamosh"]["hold"], 150) + + def test_bloom_changes_motion_without_stretching_the_timeline(self): + for rate in ("30", "30000/1001", "60"): + with self.subTest(rate=rate): + self.check_motion_timeline(rate) + + def check_motion_timeline(self, rate): + import subprocess + with tempfile.TemporaryDirectory(prefix="effex-bloom-") as temp: + source = Path(temp) / "source.m4v" + subprocess.run([ + "ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", + f"testsrc2=size=160x96:rate={rate}", "-frames:v", "60", + "-c:v", "mpeg4", "-g", "15", "-bf", "0", "-f", "m4v", + str(source)], check=True) + raw = source.read_bytes() + normal = strip_ivops(raw, "heavy", "next", 123) + heavy = strip_ivops(raw, "heavy", "next", 123, + bloom=12, bloom_window=2) + self.assertNotEqual(normal, heavy) + decoded = [] + for name, stream in (("normal", normal), ("heavy", heavy)): + path = Path(temp) / (name + ".m4v") + path.write_bytes(stream) + result = subprocess.run([ + "ffmpeg", "-v", "error", "-i", str(path), "-an", + "-fps_mode", "passthrough", "-f", "rawvideo", + "-pix_fmt", "rgb24", "-"], capture_output=True, check=True) + decoded.append(result.stdout) + self.assertEqual(len(result.stdout), 60 * 160 * 96 * 3) + if name == "heavy": + self.assertFalse(result.stderr, result.stderr.decode()[:300]) + baked = path.with_suffix(".mp4") + subprocess.run([ + "ffmpeg", "-v", "error", "-y", "-f", "m4v", "-i", + str(path), "-c:v", "copy", str(baked)], + capture_output=True, check=True) + probe = subprocess.run([ + "ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "frame=pts_time", "-of", "csv=p=0", + str(baked)], capture_output=True, check=True) + times = [float(line) for line in probe.stdout.splitlines()] + self.assertEqual(len(times), 60) + self.assertTrue(all(b > a for a, b in zip(times, times[1:])), name) + self.assertAlmostEqual(times[-1] - times[0], + float(59 / Fraction(rate)), places=5) + self.assertNotEqual(decoded[0], decoded[1]) + self.assertEqual(heavy, strip_ivops(raw, "heavy", "next", 123, + bloom=12, bloom_window=2)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/future_cut_regression.py b/tests/future_cut_regression.py new file mode 100644 index 0000000..777d0e7 --- /dev/null +++ b/tests/future_cut_regression.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +"""FutureCut: future-frame cutout compositor (E2.47a-d) — regression tests. + +These tests target the CPU frame processor only. Registry, CLI/menu wiring, +integration renders, docs and benchmarks live elsewhere. +""" +import subprocess +import sys +import unittest +from fractions import Fraction +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from harness import REPO # noqa: E402 + +try: + import numpy as np +except ImportError: + print(" SKIP NumPy not installed; install requirements-stateful.txt") + sys.exit(0) + +try: + from future_cut import FutureCut # noqa: E402 +except ImportError as exc: + print(f" FAIL FutureCut processor is missing: {exc}") + sys.exit(1) + + +def packet(frame, index, timestamp=None, context=None): + from frame_pipeline import FramePacket + return FramePacket( + frame, index, + Fraction(index, 10) if timestamp is None else timestamp, + Fraction(1, 1000), Fraction(1, 10), + dict(context or {}), + ) + + +def make_processor(config=None, shape=(48, 64, 3), seed=0, fps=10.0): + processor = FutureCut() + processor.initialize(dict(config or {}), shape, fps, seed) + return processor + + +def push_all(processor, frames, fps=10.0): + out = [] + for i, frame in enumerate(frames): + out.extend(processor.push(packet(frame, i, Fraction(i, int(fps))))) + out.extend(processor.flush()) + return out + + +def _two_speed_textures(shape=(48, 64, 3), n=40): + """Two textures translating at different speeds (the 'cloud' case). + + A smooth vertical band (slow cloud, ~1 px/frame) and a sharp bright blob + (fast feature, ~2 px/frame) both roll rightward. No per-pixel noise so the + descriptors stay coherent and region growing can form one blob. + """ + h, w = shape[:2] + frames = [] + for i in range(n): + xx = np.arange(w, dtype=np.float32)[None, :] + band = (128 + 60 * np.sin(((xx + i) % w) / w * 2 * np.pi)).astype(np.float32) + frame = np.empty(shape, np.uint8) + frame[..., 0] = band + frame[..., 1] = band + frame[..., 2] = band + blob_y = slice(h // 3, h // 3 + 8) + blob_x0 = (10 + 2 * i) % (w - 12) + frame[blob_y, blob_x0:blob_x0 + 12] = 255 + frames.append(frame) + return frames + + +def label_components(mask): + """Pure-numpy 8-connected component labelling (BFS over flat indices).""" + mask = mask.astype(bool) + if not mask.any(): + return [] + h, w = mask.shape + remaining = mask.copy() + stack = list(np.flatnonzero(remaining)) + areas = [] + neighbours = [(-1, -1), (-1, 0), (-1, 1), (0, -1), + (0, 1), (1, -1), (1, 0), (1, 1)] + while stack: + seed = stack.pop() + if not remaining.flat[seed]: + continue + area = 0 + queue = [seed] + remaining.flat[seed] = False + while queue: + p = queue.pop() + area += 1 + py, px = divmod(p, w) + for dy, dx in neighbours: + ny, nx = py + dy, px + dx + if 0 <= ny < h and 0 <= nx < w: + q = ny * w + nx + if remaining.flat[q]: + remaining.flat[q] = False + queue.append(q) + areas.append(area) + return sorted(areas, reverse=True) + + +class FutureCutTests(unittest.TestCase): + def test_module_imports_without_numpy(self): + proc = subprocess.run( + [sys.executable, "-c", + "import future_cut, sys; " + "sys.exit(0 if 'numpy' not in sys.modules else 1)"], + cwd=str(REPO), capture_output=True) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + + def test_config_defaults_match_roadmap(self): + self.assertEqual(FutureCut.DEFAULTS["lookahead"], 0.6) + self.assertEqual(FutureCut.DEFAULTS["gap"], 18) + self.assertEqual(FutureCut.DEFAULTS["cutouts"], 1) + self.assertEqual(FutureCut.DEFAULTS["spawn_every"], 24) + self.assertEqual(FutureCut.DEFAULTS["size"], 18) + self.assertEqual(FutureCut.DEFAULTS["pick"], "coherent") + self.assertEqual(FutureCut.DEFAULTS["min_motion"], 2) + self.assertEqual(FutureCut.DEFAULTS["max_motion"], 25) + self.assertEqual(FutureCut.DEFAULTS["similarity"], 60) + self.assertEqual(FutureCut.DEFAULTS["shape"], "blob") + self.assertEqual(FutureCut.DEFAULTS["seam"], "poisson") + self.assertEqual(FutureCut.DEFAULTS["feather"], 35) + self.assertEqual(FutureCut.DEFAULTS["hold"], 6) + self.assertEqual(FutureCut.DEFAULTS["melt"], 14) + self.assertEqual(FutureCut.DEFAULTS["mode"], "smudge") + self.assertEqual(FutureCut.DEFAULTS["retention"], 70) + self.assertEqual(FutureCut.DEFAULTS["drift"], 100) + self.assertEqual(FutureCut.DEFAULTS["seed"], 0) + + def test_config_validation_and_clamping(self): + p = make_processor({"lookahead": 5.0, "gap": 999, "cutouts": 0, + "spawn_every": 0, "size": 900, "min_motion": -5, + "max_motion": 999, "similarity": 999, + "feather": 999, "hold": 999, "melt": 999, + "retention": 999, "drift": 999, "seed": -5}, + shape=(48, 64, 3), seed=9) + cfg = p.serialize_config() + self.assertEqual(cfg["lookahead"], 2.0) + self.assertEqual(cfg["gap"], 60) + self.assertEqual(cfg["cutouts"], 1) + self.assertEqual(cfg["spawn_every"], 4) + self.assertEqual(cfg["size"], 40) + self.assertEqual(cfg["min_motion"], 0) + self.assertEqual(cfg["max_motion"], 100) + self.assertEqual(cfg["similarity"], 100) + self.assertEqual(cfg["feather"], 100) + self.assertEqual(cfg["hold"], 30) + self.assertEqual(cfg["melt"], 60) + self.assertEqual(cfg["retention"], 95) + self.assertEqual(cfg["drift"], 100) + self.assertEqual(cfg["seed"], 0) + + for key, bad in (("pick", "bogus"), ("shape", "circle"), + ("seam", "melt"), ("mode", "codec")): + with self.assertRaises(ValueError): + make_processor({key: bad}, shape=(48, 64, 3)) + # bool/int/on-off style values are bad choice strings too + for key in ("pick", "shape", "seam", "mode"): + for value in (True, 1, "on", "off", 0): + with self.assertRaises(ValueError): + make_processor({key: value}, shape=(48, 64, 3)) + + p = make_processor({"pick": "dissimilar", "shape": "rect", + "seam": "none", "mode": "slide"}, shape=(48, 64, 3)) + self.assertEqual(p.serialize_config()["pick"], "dissimilar") + + def test_diagnostics_exposed_to_tests(self): + p = make_processor(shape=(48, 64, 3)) + self.assertIsInstance(p.spawns, int) + self.assertIsInstance(p.active_cutouts, int) + self.assertIsInstance(p.runtime_notes, list) + self.assertIsInstance(p.capacity, int) + self.assertGreater(p.capacity, 0) + + def test_metadata_preserved_and_flush_ordering(self): + shape = (24, 32, 3) + rng = np.random.default_rng(1) + frames = [rng.integers(0, 255, shape, dtype=np.uint8) for _ in range(10)] + p = make_processor({"lookahead": 0.1, "gap": 1}, shape=shape, fps=10.0) + out = push_all(p, frames, fps=10.0) + self.assertEqual([q.index for q in out], list(range(10))) + self.assertEqual([q.timestamp for q in out], + [Fraction(i, 10) for i in range(10)]) + self.assertEqual([q.duration for q in out], [Fraction(1, 10)] * 10) + self.assertEqual(p.state_nbytes, 0) + + def test_output_delayed_by_expected_frames_at_lookahead(self): + shape = (24, 32, 3) + rng = np.random.default_rng(2) + frames = [rng.integers(0, 255, shape, dtype=np.uint8) for _ in range(6)] + p = make_processor({"lookahead": 0.2, "gap": 1, "mode": "slide", + "hold": 1, "melt": 2}, shape=shape, fps=10.0) + # lookahead 0.2s at 10fps => ceil(2)+1 = 3 frame delay + partial = [] + for i, frame in enumerate(frames): + partial.extend(p.push(packet(frame, i, Fraction(i, 10)))) + self.assertEqual([q.index for q in partial], list(range(3))) + flushed = list(p.flush()) + self.assertEqual([q.index for q in flushed], [3, 4, 5]) + self.assertEqual(p.state_nbytes, 0) + + def test_short_clip_passes_through_with_note(self): + shape = (24, 32, 3) + rng = np.random.default_rng(3) + frames = [rng.integers(0, 255, shape, dtype=np.uint8) for _ in range(4)] + p = make_processor({"lookahead": 0.6, "gap": 18}, shape=shape, fps=10.0) + out = push_all(p, frames, fps=10.0) + self.assertEqual([q.index for q in out], list(range(4))) + for q, f in zip(out, frames): + np.testing.assert_array_equal(q.frame, f) + self.assertTrue(p.runtime_notes) + + def test_no_cv2_path_falls_back_to_block_matching(self): + import sys as _sys + shape = (48, 64, 3) + rng = np.random.default_rng(4) + frames = [rng.integers(0, 255, shape, dtype=np.uint8) for _ in range(30)] + saved = _sys.modules.get("cv2") + _sys.modules["cv2"] = None + try: + p = FutureCut() + p.initialize({"lookahead": 0.3, "gap": 10, "spawn_every": 6}, + shape, 10.0, 17) + out = push_all(p, frames, fps=10.0) + self.assertEqual([q.index for q in out], list(range(30))) + self.assertTrue(any("approximate" in n.lower() + for n in p.runtime_notes)) + finally: + if saved is None: + _sys.modules.pop("cv2", None) + else: + _sys.modules["cv2"] = saved + + def test_spawned_cutout_pixels_come_from_a_later_frame(self): + frames = _two_speed_textures(n=40) + shape = frames[0].shape + # Single spawn at birth index 0 (seed=0 => phase 0), donor 10 ahead. + config = {"lookahead": 1.0, "gap": 10, "spawn_every": 100, "cutouts": 1, + "size": 20, "pick": "coherent", "shape": "blob", "seam": "none", + "feather": 0, "hold": 8, "melt": 4, "mode": "slide", + "drift": 0} + p = make_processor(config, shape=shape, seed=0, fps=10.0) + out = push_all(p, frames, fps=10.0) + self.assertEqual([q.index for q in out], list(range(len(frames)))) + self.assertEqual(p.spawns, 1) + donor = frames[10] # gap = 10 ahead of the birth frame + changed_any = False + for q in out: + if q.index < 8: # hold phase: exact future pixels at same coords + mask = np.any(q.frame != frames[q.index], axis=2) + if mask.any(): + changed_any = True + np.testing.assert_array_equal(q.frame[mask], donor[mask]) + self.assertTrue(changed_any, "hold frames never showed a cutout") + + def test_changed_pixels_form_one_coherent_blob(self): + frames = _two_speed_textures(n=40) + shape = frames[0].shape + config = {"lookahead": 1.0, "gap": 10, "spawn_every": 100, "cutouts": 1, + "size": 20, "pick": "coherent", "shape": "blob", "seam": "none", + "feather": 10, "hold": 8, "melt": 4, "mode": "slide", + "drift": 0, "similarity": 60} + p = make_processor(config, shape=shape, seed=0, fps=10.0) + out = push_all(p, frames, fps=10.0) + examined = 0 + for q in out: + if q.index >= 8: + continue # melt/after: dissolving is allowed to fragment + diff = np.any(q.frame != frames[q.index], axis=2) + if diff.sum() < 4: + continue + components = label_components(diff) + examined += 1 + self.assertGreaterEqual(len(components), 1, f"frame {q.index}") + self.assertLessEqual(len(components), 3, + f"frame {q.index}: {len(components)} blobs") + self.assertGreaterEqual(components[0] / diff.sum(), 0.6, + f"frame {q.index} largest blob too small") + self.assertGreater(examined, 0, "no hold frame had any changed pixels") + + def test_coherent_pick_avoids_regions_that_look_identical_ahead(self): + """A cutout must carry visible displacement, not a copy of itself. + + Left half: one big soft region that is identical 10 frames later (a + static surface). Right half: a textured region that moves. A coherent + cutout has to come from the moving half, otherwise it is invisible. + """ + h, w = 48, 64 + frames = [] + for i in range(40): + frame = np.zeros((h, w, 3), np.uint8) + xx = np.arange(w, dtype=np.float32)[None, :] + yy = np.arange(h, dtype=np.float32)[:, None] + soft = (90 + 50 * np.sin(xx / 9.0) * np.cos(yy / 11.0)) + frame[:, :w // 2] = np.repeat(soft[:, :w // 2, None], 3, axis=2) + stripe = (120 + 110 * (((xx + 3 * i) % 7) < 3)).astype(np.float32) + frame[:, w // 2:] = np.repeat(stripe[:, w // 2:, None], 3, axis=2) + frames.append(frame) + config = {"lookahead": 1.0, "gap": 10, "spawn_every": 100, "size": 12, + "pick": "coherent", "seam": "none", "feather": 0, + "hold": 6, "melt": 2, "mode": "slide", "drift": 0, + "min_motion": 0, "max_motion": 100} + p = make_processor(config, shape=frames[0].shape, seed=0, fps=10.0) + out = push_all(p, frames, fps=10.0) + self.assertEqual(p.spawns, 1) + changed = np.zeros((h, w), bool) + for q in out[:6]: + changed |= np.any(q.frame != frames[q.index], axis=2) + self.assertTrue(changed.any(), "no cutout was composited") + cols = np.nonzero(changed)[1] + self.assertGreater(cols.mean(), w // 2, + "cutout came from the region that is identical ahead") + self.assertGreater(changed[:, w // 2:].sum() / changed.sum(), 0.9) + + def test_blob_area_follows_the_size_setting(self): + frames = _two_speed_textures(n=40) + areas = {} + for size in (10, 25): + config = {"lookahead": 1.0, "gap": 10, "spawn_every": 100, + "size": size, "pick": "coherent", "seam": "none", + "feather": 0, "hold": 8, "melt": 2, "mode": "slide", + "drift": 0, "similarity": 0, "min_motion": 0, + "max_motion": 100} + p = make_processor(config, shape=frames[0].shape, seed=0, fps=10.0) + out = push_all(p, frames, fps=10.0) + self.assertEqual(p.spawns, 1) + q = out[3] + changed = np.any(q.frame != frames[q.index], axis=2) + areas[size] = changed.mean() + self.assertGreater(areas[25], areas[10] * 1.4, + f"size does not scale the blob: {areas}") + self.assertGreater(areas[25], 0.12, f"25% blob covers only {areas[25]:.3f}") + self.assertLess(areas[25], 0.45, f"25% blob covers {areas[25]:.3f}") + + def test_high_resolution_lookahead_is_clamped_to_the_budget(self): + """A 1080p60 clip cannot hold a 2s horizon, so it must shorten it.""" + processor = FutureCut() + processor.initialize({"lookahead": 2.0, "cutouts": 3}, + (1080, 1920, 3), 60.0, 0) + self.assertLessEqual(processor.capacity, FutureCut.MAX_FRAMES) + self.assertLess(processor.capacity, 40, + "1080p must not pretend it can buffer 40 frames") + self.assertLessEqual(processor._lookahead_frames, processor.capacity - 1) + self.assertTrue(any("look-ahead limited" in note + for note in processor.runtime_notes), + processor.runtime_notes) + self.assertGreaterEqual(processor.capacity, 5, + "1080p should still buffer a few frames") + self.assertLessEqual(processor.capacity * 1920 * 1080 * 3 + + 3 * 3 * 1920 * 1080 * 3, + FutureCut.MAX_STATE_BYTES) + + def test_residue_after_hold_and_melt_is_live_frame(self): + frames = _two_speed_textures(n=40) + shape = frames[0].shape + for mode in ("slide", "smudge"): + config = {"lookahead": 1.0, "gap": 10, "spawn_every": 100, + "cutouts": 1, "size": 20, "pick": "coherent", + "shape": "blob", "seam": "none", "feather": 10, + "hold": 2, "melt": 6, "mode": mode, "drift": 0} + p = make_processor(config, shape=shape, seed=0, fps=10.0) + out = push_all(p, frames, fps=10.0) + last = out[-1] + np.testing.assert_array_equal(last.frame, frames[last.index], + f"mode={mode} left residue") + + def test_determinism_same_seed_different_seed(self): + frames = _two_speed_textures(n=30) + shape = frames[0].shape + config = {"lookahead": 1.0, "gap": 10, "spawn_every": 4, "size": 20, + "hold": 3, "melt": 6} + + def run(seed): + p = make_processor({**config, "seed": seed}, shape=shape, + seed=seed, fps=10.0) + return [q.frame for q in push_all(p, frames, fps=10.0)] + + first = run(7) + second = run(7) + other = run(8) + self.assertTrue(all(np.array_equal(a, b) + for a, b in zip(first, second))) + self.assertTrue(any(not np.array_equal(a, b) + for a, b in zip(first, other))) + + def test_reset_and_scene_cut_and_index_discontinuity(self): + shape = (24, 32, 3) + rng = np.random.default_rng(6) + frames = [rng.integers(0, 255, shape, dtype=np.uint8) for _ in range(40)] + p = make_processor({"lookahead": 0.3, "gap": 5}, shape=shape, fps=10.0) + for i in range(20): + list(p.push(packet(frames[i], i, Fraction(i, 10)))) + self.assertGreater(p.state_nbytes, 0) + p.reset() + self.assertEqual(p.state_nbytes, 0) + self.assertEqual(p.spawns, 0) + self.assertEqual(p.active_cutouts, 0) + + # explicit scene_cut drains the old buffer then starts a new scene + p2 = make_processor({"lookahead": 0.3, "gap": 5}, shape=shape, fps=10.0) + out = [] + for i in range(10): + out.extend(p2.push(packet(frames[i], i, Fraction(i, 10)))) + # lookahead 0.3 @10fps => 4-frame delay: frames 0..5 already emitted + self.assertEqual([q.index for q in out], list(range(6))) + drained = list(p2.push(packet(frames[10], 10, Fraction(10, 10), + {"scene_cut": True}))) + self.assertEqual([q.index for q in drained], [6, 7, 8, 9]) + tail = list(p2.flush()) + self.assertEqual([q.index for q in tail], [10]) + self.assertEqual([q.index for q in out + drained + tail], list(range(11))) + + # index discontinuity resets the timeline + p3 = make_processor({"lookahead": 0.3, "gap": 5}, shape=shape, fps=10.0) + for i in range(5): + list(p3.push(packet(frames[i], i, Fraction(i, 10)))) + list(p3.push(packet(frames[20], 20, Fraction(20, 10)))) # jump + self.assertEqual(p3.state_nbytes, 1 * frames[0].nbytes) + + def test_bounded_state_over_long_clip(self): + shape = (32, 48, 3) + p = make_processor({"lookahead": 0.3, "gap": 5}, shape=shape, fps=30.0) + rng = np.random.default_rng(8) + for i in range(300): + frame = rng.integers(0, 255, shape, dtype=np.uint8) + list(p.push(packet(frame, i, Fraction(i, 30)))) + self.assertLessEqual(p.state_nbytes, p.MAX_STATE_BYTES) + list(p.flush()) + self.assertEqual(p.state_nbytes, 0) + + def test_input_not_mutated(self): + shape = (24, 32, 3) + rng = np.random.default_rng(9) + frames = [rng.integers(0, 255, shape, dtype=np.uint8) for _ in range(20)] + saved = [f.copy() for f in frames] + p = make_processor({"lookahead": 0.3, "gap": 5}, shape=shape, fps=10.0) + out = push_all(p, frames, fps=10.0) + for q in out: + self.assertFalse(np.shares_memory(q.frame, frames[q.index])) + for f, s in zip(frames, saved): + np.testing.assert_array_equal(f, s) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/harness.py b/tests/harness.py index a84c927..ace7931 100644 --- a/tests/harness.py +++ b/tests/harness.py @@ -47,11 +47,18 @@ def summary(self) -> int: def run(args, stdin: bytes = b"", env_extra: Optional[dict] = None, timeout: int = 300, cwd=None): - """run effex (or anything) with a piped, non-tty stdin""" + """run effex (or anything) with a piped, non-tty stdin. + + windows cannot execute a .py file directly (no shebang support), so the + interpreter is put in front there; on unix the shebang path stays tested + """ + argv = [str(a) for a in args] + if os.name == "nt" and argv and argv[0].lower().endswith(".py"): + argv = [sys.executable] + argv env = dict(os.environ) if env_extra: env.update(env_extra) - proc = subprocess.run([str(a) for a in args], input=stdin, env=env, + proc = subprocess.run(argv, input=stdin, env=env, capture_output=True, timeout=timeout, cwd=str(cwd or REPO)) return proc @@ -69,7 +76,7 @@ def temp_folders(prefix: str = "effex-test-"): def env_for(in_dir, out_dir) -> dict: return {"EFFEX_INPUT_DIR": str(in_dir), "EFFEX_OUTPUT_DIR": str(out_dir), - "NO_COLOR": "1"} + "EFFEX_LOG_DIR": str(Path(in_dir).parent / "logs"), "NO_COLOR": "1"} def make_clip(path: Path, seconds: float = 3, size: str = "640x360", @@ -115,6 +122,16 @@ def newest_mp4(folder) -> Optional[Path]: return max(files, key=lambda p: p.stat().st_mtime) if files else None +def video_frames(path) -> int: + result = run(["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=nb_frames", "-of", "csv=p=0", + str(path)]) + try: + return int(result.stdout.decode().strip() or 0) + except ValueError: + return 0 + + def skip(message: str) -> int: print(f" SKIP {message}") return 0 diff --git a/tests/install_prompt.py b/tests/install_prompt.py index dd0a2c7..535d950 100644 --- a/tests/install_prompt.py +++ b/tests/install_prompt.py @@ -1,16 +1,19 @@ #!/usr/bin/env python3 -"""ffmpeg missing / install prompt tests, using a fake PATH. +"""requirements panel + install prompt tests, using a fake PATH. Never uninstall anything: the suite builds a throwaway bin folder holding an -apt-get shim and a sudo shim, points PATH at it, and checks that effex +apt-get shim, a sudo shim and (optionally) an ffmpeg shim that execs the real +binary, points PATH at it, and checks that effex - * reports the missing binary instead of crashing + * reports every missing requirement instead of crashing * prints the install command and exits 1 when there is no terminal to ask * asks y/n on a terminal, runs the detected command when told yes * keeps going (with a warning) when told no + * shows the requirement panel in the main menu either way Run: python3 tests/install_prompt.py """ +import shutil import stat import sys import tempfile @@ -24,44 +27,56 @@ ENTER = b"\r" -def fake_bin(with_manager: bool, marker: Path) -> Path: +def shim(folder: Path, name: str, body: str) -> None: + path = folder / name + path.write_text("#!/bin/sh\n" + body) + path.chmod(path.stat().st_mode | stat.S_IEXEC) + + +def fake_bin(marker: Path, with_manager: bool = True, + with_ffmpeg: bool = False) -> Path: folder = Path(tempfile.mkdtemp(prefix="effex-fakebin-")) if with_manager: - apt = folder / "apt-get" - apt.write_text(f"#!/bin/sh\necho ran >> {marker}\necho 'fake apt-get $*'\n" - "exit 0\n") - apt.chmod(apt.stat().st_mode | stat.S_IEXEC) - sudo = folder / "sudo" - sudo.write_text('#!/bin/sh\nexec "$@"\n') - sudo.chmod(sudo.stat().st_mode | stat.S_IEXEC) + shim(folder, "apt-get", f"echo ran >> {marker}\necho 'fake apt-get $*'\n") + shim(folder, "sudo", 'exec "$@"\n') + if with_ffmpeg: + real = shutil.which("ffmpeg") or "/usr/bin/ffmpeg" + shim(folder, "ffmpeg", f'exec {real} "$@"\n') return folder def main() -> int: - c = Checker("install prompt") + c = Checker("requirements + install prompt") if not require_tools(c): return 0 tmp = Path(tempfile.mkdtemp(prefix="effex-install-")) marker = tmp / "marker" marker.write_text("") + logs = tmp / "logs" # keep test failures out of the real log folder - # ---- no ffmpeg, a package manager, no terminal --------------------- - fake = fake_bin(True, marker) - env = {"PATH": str(fake)} + # ---- nothing installed, a package manager, no terminal ------------- + fake = fake_bin(marker) + env = {"PATH": str(fake), "EFFEX_LOG_DIR": str(logs)} proc = run([sys.executable, EFFEX, "--check"], env_extra=env) text = (proc.stdout + proc.stderr).decode() c.check("missing ffmpeg reports an error", "not on PATH" in text and "ffmpeg" in text, text[-160:]) + c.check("missing ffprobe is reported too", "ffprobe" in text) c.check("--check exits 1 without ffmpeg", proc.returncode == 1) c.check("piped run prints the install command instead of asking", - "apt-get install -y ffmpeg" in text and "install ffmpeg now?" not in text) + "apt-get install -y ffmpeg" in text + and "now?" not in text, text[-200:]) + c.check("one command covers ffmpeg and ffprobe (same package)", + text.count("apt-get install -y ffmpeg") == 1, text[-200:]) proc = run([sys.executable, EFFEX, "--check", "--json"], env_extra=env) + payload = proc.stdout.decode() c.check("--check --json stays parseable without ffmpeg", - proc.stdout.decode().strip().startswith("{")) - c.check("--check --json says ok=false", - '"ok": false' in proc.stdout.decode()) + payload.strip().startswith("{")) + c.check("--check --json says ok=false", '"ok": false' in payload) + c.check("--check --json lists the requirements", + '"requirements"' in payload and '"ffprobe"' in payload) proc = run([sys.executable, EFFEX, "stretch", tmp / "whatever.mp4"], env_extra=env) @@ -72,7 +87,8 @@ def main() -> int: # ---- interactive decline ------------------------------------------ d = PtyDriver([sys.executable, str(EFFEX)], env_extra=env, timeout=8) - c.check("prompt appears", d.wait_for("install ffmpeg now?")) + c.check("prompt appears", + d.wait_for("install ffmpeg + ffprobe now?")) d.send(b"n") c.check("decline is acknowledged", d.wait_for("cannot continue without ffmpeg")) @@ -84,7 +100,7 @@ def main() -> int: # ---- interactive accept ------------------------------------------- d = PtyDriver([sys.executable, str(EFFEX)], env_extra=env, timeout=8) - d.wait_for("install ffmpeg now?") + d.wait_for("install ffmpeg + ffprobe now?") d.send(b"y") c.check("accept runs the detected installer", d.wait_for("still not on PATH", timeout=10)) @@ -95,14 +111,59 @@ def main() -> int: d.close() # ---- no package manager at all ------------------------------------ - empty = fake_bin(False, marker) + empty = fake_bin(marker, with_manager=False) proc = run([sys.executable, EFFEX, "stretch", tmp / "x.mp4"], - env_extra={"PATH": str(empty)}) + env_extra={"PATH": str(empty), "EFFEX_LOG_DIR": str(logs)}) + text = (proc.stdout + proc.stderr).decode() c.check("no package manager falls back to a manual hint", - b"install ffmpeg manually" in proc.stdout + proc.stderr, - (proc.stdout + proc.stderr).decode()[-160:]) - c.check("manual hint links upstream", - b"ffmpeg.org" in proc.stdout + proc.stderr) + "install ffmpeg + ffprobe manually" in text, text[-200:]) + c.check("manual hint links upstream", "ffmpeg.org" in text) + + # ---- ffmpeg present, ffprobe missing ------------------------------ + marker.write_text("") + partial = fake_bin(marker, with_ffmpeg=True) + env = {"PATH": str(partial), "EFFEX_LOG_DIR": str(logs)} + proc = run([sys.executable, EFFEX, "--check"], env_extra=env) + text = (proc.stdout + proc.stderr).decode() + c.check("ffprobe-only gap still fails the check", proc.returncode == 1, + text[-200:]) + c.check("ffprobe-only gap names the binary", + "ffprobe" in text and "ffmpeg ok" not in text) + + d = PtyDriver([sys.executable, str(EFFEX)], env_extra=env, timeout=10) + c.check("startup asks only for the missing one", + d.wait_for("install ffprobe now?"), d.text()[-300:]) + d.send(b"n") + c.check("menu banner counts the missing requirement", + d.wait_for("1 of 3 missing") and d.wait_for("ffprobe"), + d.text()[-400:]) + c.check("menu shows the requirement panel", + d.wait_for("requirements") and d.wait_for("python"), + d.text()[-400:]) + d.send(b"c") + c.check("requirements screen offers the install", + d.wait_for("install ffprobe now?")) + d.send(b"n") + c.check("requirements screen marks ffprobe missing", + d.wait_for("not on PATH"), d.text()[-400:]) + d.send(ENTER) + d.send(b"q") + rc = d.expect_exit(6) + c.check(f"menu exits cleanly (rc={rc})", rc == 0) + c.check("declined install never ran the shim", + marker.read_text().strip() == "", marker.read_text()) + d.close() + + # ---- everything installed: the menu says so ----------------------- + d = PtyDriver([sys.executable, str(EFFEX)], + env_extra={"EFFEX_LOG_DIR": str(logs)}, timeout=10) + c.check("main menu reports all requirements installed", + d.wait_for("all 3 installed"), d.text()[-400:]) + c.check("main menu lists ffmpeg and ffprobe rows", + d.wait_for("ffmpeg") and d.wait_for("ffprobe")) + d.send(b"q") + d.expect_exit(6) + d.close() return c.summary() diff --git a/tests/inversion_regression.py b/tests/inversion_regression.py index 18a7808..c705eb5 100644 --- a/tests/inversion_regression.py +++ b/tests/inversion_regression.py @@ -21,6 +21,64 @@ class InversionTests(unittest.TestCase): + def test_done_screen_keeps_the_underlying_render_error(self): + import contextlib + import io + import effex + from types import SimpleNamespace + previous = dict(effex.last_render) + try: + effex.last_render.clear() + effex.last_render["error"] = "Causality Inversion needs requirements-inversion.txt (PyAV)" + output = io.StringIO() + with contextlib.redirect_stdout(output): + effex.screen_done(SimpleNamespace(), False) + self.assertIn("requirements-inversion.txt", output.getvalue()) + self.assertIn("PyAV", output.getvalue()) + finally: + effex.last_render.clear() + effex.last_render.update(previous) + + def test_plain_launch_selects_the_project_environment_only_when_needed(self): + import effex + from types import SimpleNamespace + from unittest.mock import patch + with tempfile.TemporaryDirectory(prefix="effex-python-") as directory: + root = Path(directory) + python = root / ".venv" / "bin" / "python3" + python.parent.mkdir(parents=True) + python.write_text("#!/bin/sh\nexit 0\n") + python.chmod(0o755) + with patch.object(effex.sys, "prefix", sys.base_prefix): + self.assertEqual(effex.project_python(root), str(python)) + with patch.object(effex.sys, "flags", SimpleNamespace(no_site=True)): + self.assertIsNone(effex.project_python(root)) + with patch.object(effex.sys, "prefix", "/some/active/venv"): + self.assertIsNone(effex.project_python(root)) + python.unlink() + self.assertIsNone(effex.project_python(root)) + + def test_nominal_lookahead_adapts_to_the_memory_budget(self): + from stateful_effects import CausalityInversion + shape = (24, 32, 3) + p = CausalityInversion() + p.MAX_STATE_BYTES = 65536 + int(np.prod(shape)) * 3 + p.initialize(dict(lookahead=0.5, offset=0.5, strength=100, + mode="replace", feather=0), shape, 10, 17) + self.assertEqual(p.capacity, 3) + self.assertAlmostEqual(p.effective_lookahead, 0.2) + self.assertAlmostEqual(p.effective_offset, 0.2) + self.assertTrue(p.runtime_notes) + self.assertEqual(p.serialize_config()["offset"], 0.5) + output = [] + for index in range(8): + output.extend(p.push(self.packet(np.full(shape, index * 10, np.uint8), index))) + self.assertLessEqual(p.state_nbytes, p.MAX_STATE_BYTES) + output.extend(p.flush()) + self.assertEqual([item.index for item in output], list(range(8))) + self.assertEqual(int(output[0].frame[0, 0, 0]), 20) + self.assertEqual([item.timestamp for item in output], [Fraction(i, 10) for i in range(8)]) + def processor(self, shape=(24, 32, 3), seed=17, **config): self.assertTrue(hasattr(stateful_effects, "CausalityInversion"), "CausalityInversion must be implemented") @@ -154,8 +212,10 @@ def test_ownership_passthrough_reset_and_buffer_budget(self): output.extend(p.flush()) self.assertEqual([q.index for q in output], list(range(100))) self.assertEqual(p.state_nbytes, 0) + high_res = self.processor(shape=(2160, 3840, 3), lookahead=1, offset=1) + self.assertLess(high_res.effective_lookahead, 1) with self.assertRaisesRegex(ValueError, "budget"): - self.processor(shape=(2160, 3840, 3), lookahead=1, offset=1) + self.processor(shape=(10000, 10000, 3), lookahead=1, offset=1) with self.assertRaisesRegex(ValueError, "offset"): self.processor(lookahead=0.1, offset=0.5) @@ -207,6 +267,22 @@ def test_streaming_stage_composition_preserves_packet_metadata(self): self.assertEqual([q.timestamp for q in output], [q.timestamp for q in packets]) self.assertEqual(len(output), len(packets)) + @unittest.skipUnless(importlib.util.find_spec("av"), "install requirements-inversion.txt") + def test_default_inversion_renders_high_resolution_instead_of_refusing(self): + with tempfile.TemporaryDirectory(prefix="effex-large-inversion-") as directory: + root = Path(directory) + clip = root / "large.mp4" + self.assertTrue(make_clip(clip, seconds=0.3, size="2560x1440", audio=False)) + proc = run([EFFEX, "inversion", clip, "--json", "--preset", "ultrafast"], + env_extra=env_for(root, root / "out"), timeout=60) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + data = json.loads(proc.stdout) + self.assertEqual(video_dims(data["output"]), (2560, 1440)) + self.assertTrue(any("buffer budget" in note for note in data.get("warnings", []))) + probe = run(["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=nb_frames", "-of", "json", data["output"]]) + self.assertEqual(int(json.loads(probe.stdout)["streams"][0]["nb_frames"]), 9) + @unittest.skipUnless(importlib.util.find_spec("av"), "install requirements-inversion.txt") def test_real_cli_preserves_vfr_pts_and_render_modes(self): import effex diff --git a/tests/layout.py b/tests/layout.py index 28afca5..611ba6f 100644 --- a/tests/layout.py +++ b/tests/layout.py @@ -184,9 +184,45 @@ def main() -> int: if not require_tools(c): return 0 scenario_sizes(c) + scenario_effect_options(c, "datamosh", 3, 0, "audio") + scenario_effect_options(c, "archaeology", 2, 5, "random seed") scenario_columns(c) return c.summary() +def scenario_effect_options(c: Checker, effect: str, group: int, + within_group: int, last_option: str) -> None: + from menu_smoke import DOWN, last_frame, next_effect_group, wait_last + for cols, height in ((36, 20), (60, 20), (80, 24)): + d = scenario_fit(c, cols, height) + d.send(b"p") + d.wait_for("pick a video") + d.send(ENTER) + d.wait_for("pick an effect") + next_effect_group(d, group) + if within_group: + d.send(DOWN * within_group) + d.send(ENTER) + d.wait_for(f"{effect} options") + frame = last_frame(d).split("\033[J")[0].rstrip() + c.check(f"{cols}x{height}: {effect} options fit vertically", + len(frame.splitlines()) < height, str(len(frame.splitlines()))) + check_frame(c, f"{cols}x{height} {effect}", frame, cols) + reached = False + from menu_smoke import press_and_wait + for _ in range(40): + press_and_wait(d, DOWN) + if last_option in last_frame(d): + reached = True + break + frame = last_frame(d).split("\033[J")[0].rstrip() + c.check(f"{cols}x{height}: bottom {last_option} option stays reachable", + reached and len(frame.splitlines()) < height) + for _ in range(4): + d.send(b"q") + c.check(f"{cols}x{height}: {effect} clean exit", d.expect_exit(6) == 0) + d.close() + + if __name__ == "__main__": sys.exit(main()) diff --git a/tests/logging_regression.py b/tests/logging_regression.py new file mode 100644 index 0000000..41ca51c --- /dev/null +++ b/tests/logging_regression.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Diagnostics log tests: the file you attach to a github issue. + +Two halves. The unit half imports diagnostics and checks the record format, +rotation, thread safety and the report; the cli half runs effex with +EFFEX_LOG_DIR pointed at a temp folder and checks that + + * a clean run writes nothing + * a failing run records the error, the warning before it, and the ffmpeg + stderr under it + * --logs prints the path and the tail, --report writes one attachable file + +Run: python3 tests/logging_regression.py +""" +import contextlib +import io +import os +import re +import shutil +import sys +import tempfile +import threading +import types +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import diagnostics # noqa: E402 +from harness import Checker, EFFEX, require_tools, run # noqa: E402 + +EVENT = re.compile(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d (warn|error|info) +\S.*$") + + +def fresh_dir(prefix: str) -> Path: + return Path(tempfile.mkdtemp(prefix=prefix)) + + +def main() -> int: + c = Checker("diagnostics log") + + # ---- unit: session records ------------------------------------- + logs = fresh_dir("effex-logs-") + os.environ["EFFEX_LOG_DIR"] = str(logs) + c.check("log dir follows EFFEX_LOG_DIR", diagnostics.log_dir() == logs, + str(diagnostics.log_dir())) + c.check("the default path is not inside the repo", + "effex" in diagnostics.LOG.path().name) + + session = diagnostics.Session() + session.start(version="9.9.9", argv=["stretch", "clip.mp4"], cwd="/tmp/x") + real_ffmpeg = shutil.which("ffmpeg") + session.bind("ffmpeg", real_ffmpeg or "/bin/ffmpeg") + c.check("nothing is written before an event", not session.exists()) + session.warn("ffmpeg is missing") + c.check("a warning creates the file", session.exists()) + text = session.path().read_text() + c.check("the header names the version", "==== effex 9.9.9" in text, text) + c.check("the header carries the command", + "effex stretch clip.mp4" in text, text) + c.check("the header carries platform and python", + "platform" in text and "python" in text) + # the version line only exists when the bound binary can actually run + if real_ffmpeg: + c.check("the header carries the tool paths and versions", + real_ffmpeg in text + and re.search(r"ffmpeg v\s+\d", text) is not None, text) + else: + c.check("the header carries the tool path", "/bin/ffmpeg" in text, + text) + c.check("the event line is timestamped and levelled", + any(EVENT.match(line) and "warn" in line + for line in text.splitlines()), text) + + session.error("\033[31mbang\033[0m", "first line\nsecond line") + text = session.path().read_text() + c.check("ansi codes never reach the log", "\033[" not in text, text) + c.check("detail lines are indented and prefixed", + " | first line" in text and " | second line" in text, text) + + try: + raise ValueError("boom") + except ValueError as exc: + session.exception(exc, "render failed") + text = session.path().read_text() + c.check("exceptions keep the traceback", + "Traceback" in text and "ValueError: boom" in text, text[-400:]) + c.check("the header is written once per session", + text.count("==== effex 9.9.9") == 1) + + c.check("tail returns the last lines", + len(session.tail(3).splitlines()) == 3, session.tail(3)) + c.check("info events are recorded too", + bool(session.info("hello") or "hello" in session.path().read_text())) + + # ---- unit: rotation stays bounded ------------------------------ + tidy = fresh_dir("effex-rotate-") + os.environ["EFFEX_LOG_DIR"] = str(tidy) + small = diagnostics.Session() + small.start(version="1.0") + old_bytes, old_keep = diagnostics.MAX_BYTES, diagnostics.KEEP + diagnostics.MAX_BYTES, diagnostics.KEEP = 1500, 3 + try: + for index in range(120): + small.error(f"event number {index} with a little padding to grow") + finally: + diagnostics.MAX_BYTES, diagnostics.KEEP = old_bytes, old_keep + base = tidy / diagnostics.LOG_NAME + c.check("rotation keeps the current file small", + base.stat().st_size <= 1500 + 400, str(base.stat().st_size)) + c.check("rotation keeps one old file", (tidy / (diagnostics.LOG_NAME + ".1")).is_file()) + c.check("rotation drops files past the keep count", + not (tidy / (diagnostics.LOG_NAME + ".4")).exists()) + + # ---- unit: threads never interleave --------------------------- + os.environ["EFFEX_LOG_DIR"] = str(fresh_dir("effex-threads-")) + threaded = diagnostics.Session() + threaded.start(version="2.0") + + def worker(n: int) -> None: + for i in range(20): + threaded.error(f"worker {n} line {i}") + + threads = [threading.Thread(target=worker, args=(n,)) for n in range(6)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + lines = [line for line in threaded.path().read_text().splitlines() + if EVENT.match(line)] + c.check("every threaded event lands", len(lines) == 120, str(len(lines))) + c.check("no half-written lines", + all("line" in line for line in lines), str(lines[:2])) + + # ---- unit: report --------------------------------------------- + target = fresh_dir("effex-report-") / "nested" / "report.log" + written = threaded.report(target) + body = written.read_text() + c.check("the report is written where asked", written == target) + c.check("the report opens with what it is", + body.startswith("effex diagnostics report"), body[:60]) + c.check("the report carries the environment", + "platform" in body and "python" in body and "log file" in body) + c.check("the report carries the log tail", + "---- log tail ----" in body and "worker 0 line 0" in body) + c.check("the report links the issue tracker", + diagnostics.ISSUES_URL in body) + + # ---- the failure screen points at the log --------------------- + os.environ["EFFEX_LOG_DIR"] = str(fresh_dir("effex-screen-")) + diagnostics.LOG.start(version="3.0", argv=["stretch"]) + diagnostics.LOG.error("ffmpeg failed (exit 1)") + import effex + effex.last_render.clear() + effex.last_render["error"] = "ffmpeg failed (exit 1)" + frame = io.StringIO() + with contextlib.redirect_stdout(frame): + effex.screen_done(types.SimpleNamespace(), False) + shown = frame.getvalue() + c.check("the failure screen shows the log path", + "log" in shown and effex.LOG.path().name in shown, shown[-300:]) + c.check("the failure screen offers the log key", + "l open log folder" in shown, shown[-300:]) + + # ---- cli: a clean run writes nothing -------------------------- + if not require_tools(c): + return c.summary() + + clean = fresh_dir("effex-clean-") + proc = run([EFFEX, "--check", "--json"], + env_extra={"EFFEX_LOG_DIR": str(clean)}) + c.check("a clean run still exits 0", proc.returncode == 0, + proc.stderr.decode()[:160]) + c.check("a clean run writes no log", + not (clean / diagnostics.LOG_NAME).exists(), + str(list(clean.iterdir()) if clean.exists() else "")) + + # ---- cli: a failing run records everything -------------------- + bad = fresh_dir("effex-bad-") + log = bad / diagnostics.LOG_NAME + proc = run([EFFEX, "bogus", "clip.mp4"], + env_extra={"EFFEX_LOG_DIR": str(bad)}) + text = log.read_text() if log.is_file() else "" + c.check("a failed run exits non-zero", proc.returncode != 0) + c.check("the failure names the file in the log", + "error unknown effect: bogus" in text, text[-200:]) + c.check("the failed run tells the user where the log is", + b"details in" in proc.stdout + proc.stderr, + (proc.stdout + proc.stderr).decode()[-200:]) + + # a real ffmpeg failure must keep its stderr, not just "it failed" + broken = fresh_dir("effex-broken-") / "broken.mp4" + broken.write_bytes(os.urandom(40_000)) + proc = run([EFFEX, "stretch", broken, "--no-color"], + env_extra={"EFFEX_LOG_DIR": str(bad)}) + text = log.read_text() + c.check("the render failure is logged", + "error ffmpeg failed" in text, text[-300:]) + c.check("the warning that led to it is logged", + "warn could not read duration" in text, text[-300:]) + c.check("ffmpeg's own stderr is attached under the error", + " | " in text and "Invalid data" in text, text[-300:]) + + # ---- cli: --logs --------------------------------------------- + proc = run([EFFEX, "--logs"], env_extra={"EFFEX_LOG_DIR": str(bad)}) + out = proc.stdout.decode() + c.check("--logs prints the path", str(log) in out, out[:200]) + c.check("--logs prints the tail", "unknown effect" in out, out[:200]) + c.check("--logs points at the issue tracker", + diagnostics.ISSUES_URL in out, out[-200:]) + c.check("--logs exits 0", proc.returncode == 0) + + empty = fresh_dir("effex-empty-") + proc = run([EFFEX, "--logs"], env_extra={"EFFEX_LOG_DIR": str(empty)}) + c.check("--logs explains an empty log", + b"nothing logged yet" in proc.stdout, proc.stdout.decode()[:200]) + + # ---- cli: --report ------------------------------------------- + proc = run([EFFEX, "--report"], env_extra={"EFFEX_LOG_DIR": str(bad)}) + out = proc.stdout.decode() + made = [p for p in bad.glob("effex-report-*.log")] + c.check("--report writes a file in the log folder", len(made) == 1, + str(made)) + c.check("--report prints where it went", + bool(made) and str(made[0]) in out, out[:200]) + if made: + body = made[0].read_text() + c.check("the report has the header and the tail", + "effex diagnostics report" in body + and "ffmpeg failed" in body, body[-200:]) + + custom = fresh_dir("effex-custom-") / "issue.log" + proc = run([EFFEX, "--report", custom], + env_extra={"EFFEX_LOG_DIR": str(bad)}) + c.check("--report honours an explicit path", + custom.is_file() and str(custom) in proc.stdout.decode(), + proc.stdout.decode()[:200]) + + return c.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/menu_smoke.py b/tests/menu_smoke.py index b4fee76..aabe755 100644 --- a/tests/menu_smoke.py +++ b/tests/menu_smoke.py @@ -418,6 +418,15 @@ def scenario_datamosh(c: Checker): c.check("the hold row is shown", "hold" in d.text()) c.check("no preview key on a two-pass effect", "p preview" not in d.text()) + d.send(DOWN) # mosh preset + press_and_wait(d, RIGHT) # standard + c.check("standard preset loads its codec knobs in the menu", + "standard" in last_frame(d) and "150" in last_frame(d), + last_frame(d)) + press_and_wait(d, LEFT) # heavy + c.check("heavy preset reloads the aggressive codec knobs", + "heavy" in last_frame(d) and "150" not in last_frame(d) + and "motion bloom" in last_frame(d), last_frame(d)) d.send(b"r") c.check("confirm screen opens", d.wait_for("render this video?")) d.send(ENTER) @@ -459,7 +468,7 @@ def scenario_datamosh_second(c: Checker): c.check("confirm shows the two-source datamosh plan", d.wait_for("render this video?") and "WITH_bbb" in last_frame(d) and - "mpeg4" in last_frame(d) and "gop 150" in last_frame(d), + "mpeg4" in last_frame(d) and "gop 12" in last_frame(d), last_frame(d)[-300:]) d.send(ENTER) c.check("the combined datamosh renders from the menu", @@ -885,6 +894,96 @@ def scenario_collapse(c: Checker): d.close() +def scenario_blockswap(c: Checker): + """Teleport and mosh controls are reachable and the effect renders alone.""" + in_dir, out_dir = temp_folders() + make_clip(in_dir / "clip.mp4", seconds=1, size="160x96") + d = start(env_for(in_dir, out_dir)) + try: + d.wait_for("main menu") + d.send(b"p") + d.wait_for("pick a video") + d.send(ENTER) + d.wait_for("pick an effect") + next_effect_group(d, 3) # codec & blocks group + d.send(DOWN * 1) # blockswap, after datamosh + d.send(ENTER) + c.check("blockswap options open", wait_last(d, "blockswap options")) + c.check("blockswap exposes teleport and mosh controls", all( + label in last_frame(d) for label in + ("blocks", "teleport secs", "mosh strength", "mosh grit", "mosh hold"))) + c.check("blockswap is a separate render - no preview", + "p preview" not in last_frame(d)) + for _ in range(2): + press_and_wait(d, DOWN) # onto teleport secs + press_and_wait(d, RIGHT) + c.check("teleport is editable in the menu", "3.5" in last_frame(d), + last_frame(d)[-300:]) + d.send(b"r") + c.check("blockswap confirm opens", wait_last(d, "render this video?")) + d.send(ENTER) + c.check("blockswap menu render finishes", wait_last(d, "wrote", timeout=30)) + from harness import newest_mp4 + output = newest_mp4(out_dir) + c.check("blockswap menu render keeps the frame", + output is not None and video_dims(output) == (160, 96)) + d.send(ENTER) + wait_last(d, "main menu") + d.send(b"q") + c.check("blockswap exits cleanly", d.expect_exit(6) == 0) + finally: + d.close() + + +def scenario_collapse_pixelate(c: Checker): + """Pixelation is an on/off switch on the collapse options screen.""" + import importlib.util + import re + if importlib.util.find_spec("cv2") is None or importlib.util.find_spec("numpy") is None: + print(" SKIP collapse pixelate tests need requirements-collapse.txt") + return + + def value_of(frame, label): + for line in frame.splitlines(): + bare = line.strip() + if bare.startswith(label): + parts = re.split(r"\s{2,}", bare) + return parts[1] if len(parts) > 1 else "" + return "" + + in_dir, out_dir = temp_folders() + make_clip(in_dir / "clip.mp4", seconds=1, size="160x90") + d = start(env_for(in_dir, out_dir)) + try: + d.wait_for("main menu") + d.send(b"p") + d.wait_for("pick a video") + d.send(ENTER) + d.wait_for("pick an effect") + d.send(b"8") # collapse + c.check("collapse pixelate: options open", wait_last(d, "collapse options")) + c.check("collapse pixelate: off by default", + value_of(last_frame(d), "pixelate") == "off", + value_of(last_frame(d), "pixelate")) + d.send(DOWN * 11) # intensity .. pixelate + d.pump(0.5) + d.send(ENTER) + d.pump(0.4) + c.check("collapse pixelate: enter switches it on", + value_of(last_frame(d), "pixelate") == "on", + value_of(last_frame(d), "pixelate")) + d.send(ENTER) + d.pump(0.4) + c.check("collapse pixelate: enter switches it off again", + value_of(last_frame(d), "pixelate") == "off", + value_of(last_frame(d), "pixelate")) + for _ in range(4): + d.send(b"q") + c.check("collapse pixelate: exits cleanly", d.expect_exit(6) == 0) + finally: + d.close() + + def scenario_inversion(c: Checker): """Buffered future borrowing has editable controls and uses the same renderer.""" import importlib.util @@ -926,6 +1025,57 @@ def scenario_inversion(c: Checker): d.close() +def scenario_archaeology(c: Checker): + """Future-cut controls stay editable and render through the shared pipeline.""" + import importlib.util + from harness import newest_mp4 + if importlib.util.find_spec("numpy") is None: + print(" SKIP archaeology menu tests need requirements-stateful.txt") + return + in_dir, out_dir = temp_folders() + make_clip(in_dir / "clip.mp4", seconds=1, size="160x96") + d = start(env_for(in_dir, out_dir)) + try: + d.wait_for("main menu") + d.send(b"p") + d.wait_for("pick a video") + d.send(ENTER) + d.wait_for("pick an effect") + next_effect_group(d, 2) + d.send(DOWN * 5) + d.send(ENTER) + c.check("pixel archaeology options open", wait_last(d, "archaeology options")) + c.check("archaeology exposes buffer, cutout and melt controls", all( + label in last_frame(d) for label in + ("look-ahead s", "donor gap", "cutout size", "spawn every", + "hold frames", "melt frames"))) + d.send(b"6") + press_and_wait(d, RIGHT) + c.check("archaeology pick mode is editable", "dissimilar" in last_frame(d)) + d.send(DOWN * 5) + press_and_wait(d, RIGHT) + c.check("archaeology seam blend is editable", "none" in last_frame(d)) + c.check("archaeology hides unsupported previews", "p preview" not in last_frame(d)) + d.send(b"1") + press_and_wait(d, RIGHT) + c.check("archaeology look-ahead is editable", "0.7" in last_frame(d), + last_frame(d)[-300:]) + d.send(b"r") + c.check("archaeology confirm opens", wait_last(d, "render this video?")) + d.send(ENTER) + c.check("archaeology menu render finishes", wait_last(d, "wrote", timeout=30)) + output = newest_mp4(out_dir) + c.check("archaeology menu render preserves dimensions and audio", + output is not None and video_dims(output) == (160, 96) + and audio_codec(output) == "aac") + d.send(ENTER) + wait_last(d, "main menu") + d.send(b"q") + c.check("archaeology exits cleanly", d.expect_exit(6) == 0) + finally: + d.close() + + def main() -> int: c = Checker("menu smoke") if not require_tools(c): @@ -937,7 +1087,9 @@ def main() -> int: scenario_vhs, scenario_channelswap, scenario_timescratch, scenario_temporal_sediment, scenario_chain, scenario_psychedelic, scenario_collapse, - scenario_inversion, scenario_backout): + scenario_collapse_pixelate, scenario_blockswap, + scenario_inversion, + scenario_archaeology, scenario_backout): print(f" -- {scenario.__name__}") scenario(c) return c.summary() diff --git a/tests/platform_support.py b/tests/platform_support.py new file mode 100644 index 0000000..e9d0ef1 --- /dev/null +++ b/tests/platform_support.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Platform + installer tests: no windows box needed, the tables are data. + +effex.py is imported as a module so the pure helpers can be checked directly: + + - host_os() agrees with python's own platform report + - each OS gets its own package managers, and only unix ones use sudo + - the command for a missing binary is the one that platform expects + - a missing ffprobe with ffmpeg present is still a failed check + - windows key codes decode to the menu's names + - the venv interpreter path follows the platform layout + +Run: python3 tests/platform_support.py +""" +import os +import platform +import shutil +import stat +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import effex # noqa: E402 +from harness import Checker, EFFEX, have, run # noqa: E402 + + +def main() -> int: + c = Checker("platform + installers") + + # ---- host detection ------------------------------------------------ + expected = {"Windows": "windows", "Darwin": "macos", + "Linux": "linux"}[platform.system()] + c.check("host_os matches platform.system", effex.host_os() == expected, + effex.host_os()) + c.check("linux_distro is a bare id or empty", + isinstance(effex.linux_distro(), str) + and effex.linux_distro() == effex.linux_distro().strip().lower(), + effex.linux_distro()) + + # ---- per-OS installer tables --------------------------------------- + managers = {os_name: [i.manager for i in effex.installers_for(os_name)] + for os_name in ("windows", "macos", "linux")} + c.check("windows uses winget, choco or scoop", + managers["windows"] == ["winget", "choco", "scoop"], + str(managers["windows"])) + c.check("macos uses homebrew", "brew" in managers["macos"], + str(managers["macos"])) + c.check("linux covers the five distro managers", + {"apt-get", "dnf", "pacman", "zypper", "apk"} + <= set(managers["linux"]), str(managers["linux"])) + c.check("no windows command asks for sudo", + all(command[0] != "sudo" + for installer in effex.installers_for("windows") + for command in effex.install_commands( + installer, ["ffmpeg", "ffprobe"]))) + distro_managers = [i for i in effex.installers_for("linux") + if i.manager != "brew"] + c.check("distro managers all ask for sudo", + all(command[0] == "sudo" for installer in distro_managers + for command in effex.install_commands(installer, ["ffmpeg"]))) + + # ---- detection picks what is actually there ------------------------ + picked = effex.detect_installer("windows", + which=lambda name: name == "choco") + c.check("detect_installer honours PATH", + picked is not None and picked.manager == "choco", + str(picked)) + c.check("detect_installer returns None with no manager", + effex.detect_installer("windows", which=lambda name: False) is None) + + # ---- the command each platform would run --------------------------- + winget = next(i for i in effex.installers_for("windows") + if i.manager == "winget") + commands = effex.install_commands(winget, ["ffmpeg", "ffprobe"]) + c.check("winget installs ffmpeg and ffprobe with one package", + len(commands) == 1 and "Gyan.FFmpeg" in commands[0], + str(commands)) + c.check("winget accepts the agreements unattended", + "--accept-package-agreements" in commands[0] + and "--accept-source-agreements" in commands[0]) + c.check("winget command starts with winget", commands[0][0] == "winget") + + apt = next(i for i in effex.installers_for("linux") + if i.manager == "apt-get") + c.check("apt command is the documented one", + effex.install_commands(apt, ["ffmpeg", "ffprobe"]) + == [["sudo", "apt-get", "install", "-y", "ffmpeg"]], + str(effex.install_commands(apt, ["ffmpeg", "ffprobe"]))) + c.check("install_text joins several commands", + effex.install_text(winget, ["ffmpeg"]) == + "winget install -e --id Gyan.FFmpeg " + "--accept-package-agreements --accept-source-agreements", + effex.install_text(winget, ["ffmpeg"])) + c.check("no missing binary, no command", + effex.install_commands(apt, []) == []) + + # ---- requirements model ------------------------------------------- + broken = effex.Tools(None, None) + reqs = effex.check_requirements(broken) + labels = [r.label for r in effex.missing_requirements(reqs)] + c.check("nothing installed means ffmpeg and ffprobe are missing", + labels == ["ffmpeg", "ffprobe"], str(labels)) + c.check("python counts as installed", len(reqs) == 6, str(len(reqs))) + c.check("optional modules never count as required", + all(not r.required for r in reqs[3:])) + c.check("a broken check says so", + effex.requirement_summary(reqs).startswith("2 missing"), + effex.requirement_summary(reqs)) + lines = effex.requirement_lines(reqs, 60) + c.check("missing rows read not on PATH", + any("not on PATH" in line for line in lines), str(lines)) + c.check("rows keep the binary name", any("ffprobe" in line + for line in lines)) + + fixed = effex.Tools("/usr/bin/ffmpeg", "/usr/bin/ffprobe") + good = effex.check_requirements(fixed) + c.check("both binaries present means nothing required is missing", + effex.missing_requirements(good) == []) + c.check("summary reports the required count", + effex.requirement_summary(good) == "all 3 installed", + effex.requirement_summary(good)) + c.check("a healthy panel has no missing marker", + all("not on PATH" not in line + for line in effex.requirement_lines(good, 60))) + + # ffprobe alone missing is a real gap (two-video mashing needs it) + half = effex.Tools("/usr/bin/ffmpeg", None) + c.check("ffmpeg without ffprobe fails the tools check", not half.ok) + c.check("ffmpeg without ffprobe is named", + [r.label for r in effex.missing_requirements( + effex.check_requirements(half))] == ["ffprobe"]) + + # ---- pip hints only mention files that are actually missing -------- + fake_reqs = [ + effex.Requirement("numpy", "numpy", False, "", "sediment", + pip_file="requirements-stateful.txt"), + effex.Requirement("cv2", "opencv", False, "", "collapse", + pip_file="requirements-collapse.txt"), + effex.Requirement("ffmpeg", "ffmpeg", True, "/bin/ffmpeg"), + ] + hint = effex.pip_hint(fake_reqs) + c.check("pip hint covers the missing optional modules", + "requirements-stateful.txt" in hint + and "requirements-collapse.txt" in hint, hint) + c.check("pip hint skips present requirements", + "av" not in hint and hint.startswith("python -m pip install")) + + # ---- windows specifics (pure data / decode paths) ------------------ + if effex.is_windows(): + c.check("windows tool dirs only lists real folders", + all(Path(folder).is_dir() + for folder in effex.windows_tool_dirs())) + c.check("refresh_windows_path reads the registry path", + effex.refresh_windows_path() is True) + else: + c.check("windows tool dirs are empty off windows", + effex.windows_tool_dirs() == []) + c.check("refresh_windows_path is a no-op off windows", + effex.refresh_windows_path() is False) + c.check("prepare_console never raises", + effex.prepare_console() is None) + + keys = {"H": "up", "P": "down", "M": "right", "K": "left", + "G": "home", "O": "end", "I": "pgup", "Q": "pgdn"} + c.check("windows arrow codes decode", + all(effex.decode_windows_key("\xe0", code) == name + for code, name in keys.items()), + str({code: effex.decode_windows_key("\xe0", code) + for code in keys})) + c.check("the NUL prefix decodes the same way", + effex.decode_windows_key("\x00", "H") == "up") + c.check("a lone escape is esc", effex.decode_windows_key("\x1b") == "esc") + c.check("plain letters pass through", effex.decode_windows_key("q") == "q") + c.check("enter passes through", effex.decode_windows_key("\r") == "\r") + c.check("unknown windows codes fall back to esc", + effex.decode_windows_key("\xe0", "Z") == "esc") + + # ---- venv layout per platform ------------------------------------- + for parts, is_win in ((("Scripts", "python.exe"), True), + (("bin", "python3"), False)): + root = Path(tempfile.mkdtemp(prefix="effex-venv-")) + target = root / ".venv" / parts[0] + target.mkdir(parents=True) + binary = target / parts[1] + binary.write_text("") + binary.chmod(binary.stat().st_mode | stat.S_IEXEC) + found = effex.venv_python_path(root, windows=is_win) + c.check(f"venv path uses the {'windows' if is_win else 'posix'} layout", + found is not None and found.endswith(os.path.join(*parts)), + str(found)) + if not is_win: + c.check("posix layout is not accepted as windows", + effex.venv_python_path(root, windows=True) is None, + str(effex.venv_python_path(root, windows=True))) + c.check("no venv, no re-exec", + effex.venv_python_path(Path(tempfile.mkdtemp(prefix="effex-novenv-")), + windows=True) is None) + + # ---- a pipe in a non-utf-8 locale still prints the glyphs --------- + proc = run([EFFEX, "--check"], env_extra={"PYTHONIOENCODING": "ascii", + "NO_COLOR": "1"}) + stream = proc.stdout + proc.stderr + c.check("a non-utf-8 stdout never crashes on the tick glyphs", + b"ffmpeg" in proc.stdout and b"UnicodeEncodeError" not in stream + and b"Traceback" not in stream, + stream.decode("utf-8", "replace")[-200:]) + + # ---- which_tool agrees with shutil.which on this host -------------- + name = "ffmpeg" if have("ffmpeg") else "sh" + found = effex.which_tool(name) + c.check("which_tool finds a binary on PATH", + found is not None and Path(found).is_file(), + f"{found} vs {shutil.which(name)}") + + return c.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/run_tests.py b/tests/run_tests.py index f3f11c1..3e1cb23 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -5,6 +5,8 @@ python3 tests/run_tests.py cli # only the cli regression python3 tests/run_tests.py menu # only the pty menu tests python3 tests/run_tests.py layout # only the frame layout checks + python3 tests/run_tests.py platform # os + installer table checks + python3 tests/run_tests.py logging # diagnostics log + report python3 tests/run_tests.py stateful # processor and FFmpeg chain checks python3 tests/run_tests.py psychedelic # color behavior and FFmpeg renders python3 tests/run_tests.py collapse seams # elastic collapse and seam carving @@ -19,14 +21,19 @@ SUITES = { "cli": HERE / "cli_regression.py", "datamosh": HERE / "datamosh_features.py", + "mosh-stream": HERE / "datamosh_stream.py", "menu": HERE / "menu_smoke.py", "layout": HERE / "layout.py", "install": HERE / "install_prompt.py", + "platform": HERE / "platform_support.py", + "logging": HERE / "logging_regression.py", "stateful": HERE / "stateful_regression.py", "psychedelic": HERE / "psychedelic_regression.py", "collapse": HERE / "collapse_regression.py", "seams": HERE / "seam_carving_regression.py", "inversion": HERE / "inversion_regression.py", + "archaeology": HERE / "future_cut_regression.py", + "archaeology-cli": HERE / "archaeology_integration.py", }