diff --git a/README.md b/README.md index f396648..cbe025f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ Fly adds a controlled leaf-like drift in arbitrary PDF directions, with the same reproducible review assets. Push transfers momentum between two softly compressing slide blocks in all four PDF directions. Dissolve fractures the outgoing slide into deterministic glass shards that clear to reveal the next page. +Glitter releases directional fragments with brief warm sparks. Wipe sweeps a +raised, beveled wiper across the slide in all four cardinal directions, revealing +the next page without stretching either image. Fade remains the next planned +effect; Replace is instant. [![CI](https://github.com/baol/pdfcube/actions/workflows/ci.yml/badge.svg)](https://github.com/baol/pdfcube/actions/workflows/ci.yml) diff --git a/docs/transitions/README.md b/docs/transitions/README.md index 8cc15a1..94600e5 100644 --- a/docs/transitions/README.md +++ b/docs/transitions/README.md @@ -12,13 +12,53 @@ review, to accepted; a passing build is not visual approval. | 4 | Fly | A page drifting like a leaf on a controlled gust | Direction, scale, readable flight, controlled landing | | 5 | Push | Cube pushes a soft cube | Deterministic contact and recovery | | 6 | Dissolve / Glitter | Glass shatter / flare and sparks | Stable geometry, bounded memory and brightness | -| 7 | Wipe / Fade / Replace | Physical wiper / crossfade / instant | Direction, clean endpoints | +| 7 | Wipe | Raised physical wiper sweeping the outgoing image away | Four directions, exact reveal mask and endpoints | +| 8 | Fade / Replace | Crossfade (next) / instant (already available) | Clean endpoints | Box keeps the classic cube. Unsupported PDF effects currently replace the page. Future effects must preserve document ratio, prepare full-resolution source images before motion, and finish on the exact static destination image. Geometry is a function of progress, never an uncontrolled physics simulation. +## Wipe: experimental, awaiting visual approval + +A shallow squeegee rail sweeps across the viewport, clearing the outgoing page +and revealing the destination behind it. A dark rubber lip, metal face and +highlighted bevel make the moving edge visible. The pages stay flat, readable +and at full resolution. Travel eases in and out and extends beyond both edges, +so the rail enters and leaves completely. There is no simulation or accumulating +state, and no per-frame geometry allocation. + +PDF `/Di` values 0, 90, 180 and 270 select right, up, left and down. Missing or +invalid angles default to right. `--program 4:wipe` uses the same effect and the +normal 750 ms programmed duration; document playback uses PDF `/Trans /D`, with +`/Dur` reserved for dwell time. Explicit cube and instant navigation are unchanged. + +Each page keeps its own fitted aspect ratio. The unrevealed region includes the +outgoing letterbox, so a differently shaped incoming page cannot show through +early. The clipping edge is pixel-aligned and concealed by the rail. At 0% and +100% the renderer bypasses the wiper and draws the exact static page. + +Try the [right](../../examples/transitions/wipe-right.pdf), +[up](../../examples/transitions/wipe-up.pdf), [left](../../examples/transitions/wipe-left.pdf) +and [down](../../examples/transitions/wipe-down.pdf) diagnostic PDFs, or the +[Beamer showcase](../../examples/showcase/README.md). Candidate contact sheets: + +![Wipe right](wipe-right.png) + +![Wipe up](wipe-up.png) + +Other directions: [left](wipe-left.png), [down](wipe-down.png). +These are software-renderer candidates, not visual acceptance or hardware +performance measurements. Fade is the next unimplemented roadmap effect; +Replace already changes the page instantly. + +Local Wipe captures were inspected with GTK 4.22.4, Poppler 26.01 and software +OpenGL, including a midpoint from the updated Beamer deck. All 26 Wipe cases +pass with address/undefined-behavior sanitizers. Cold sanitizer checkpoint cases +missed the 500 ms SLO (up to 1.47 seconds); this is recorded separately from +functional correctness and does not establish a hardware performance pass. + ## Glitter: experimental, awaiting visual approval A directional front releases small textured pieces of the outgoing slide, @@ -241,16 +281,16 @@ python3 tools/transition_gallery.py build/src/pdfcube build/transition-gallery Add `--update-docs` to regenerate the checked-in contact sheets from those same captures. The default 33 samples include exact quarter-progress frames. -Use `--effects Cover Uncover` for a focused page-flip review; CI captures all -implemented effects by default. Gallery capture reuses one Xvfb display and +Use `--effects Wipe` for a focused wiper review. The manual transition-review +workflow captures all implemented effects by default. Gallery capture reuses one Xvfb display and captures up to four frames concurrently. Use `--jobs N` to tune that limit for the available CPU and memory; this does not change the generated frames. Open build/transition-gallery/index.html. Each effect/axis/direction has a looping video, five-frame contact sheet, full-resolution individual frames, and its input PDF. The manifest records the source revision and binary version. Rebuild first; -captures must come from the revision under review. CI uploads this bundle on -every pull request. Download and unzip it to view locally. +captures must come from the revision under review. The manual transition-review +workflow uploads the bundle; ordinary CI keeps only one GUI smoke capture. Download and unzip it to view locally. Candidate captures are never automatically promoted to golden references. The reviewer checks framing, direction, readability, motion and final alignment, @@ -291,6 +331,11 @@ native-platform or motion approval. - Dissolve checks deterministic fracture geometry, bounded shard parameters, portrait/landscape/mixed ratios, PDF metadata, program selection, distinct sampled stages, exact endpoints and a reproducible example. +- Wipe checks bounded and monotonic clipping, symmetric travel, all four directions, + omitted/invalid angle fallback, exact revealed/unrevealed pixel regions including + mixed page letterboxes, deterministic replay, GL state cleanup, PDF metadata, + program dispatch, complete frame sweeps, repeated-playback RSS bounds in one + shared context, and reproducible example PDFs. - Pending manual approval: realistic Beamer deck; HD/2K/4K frame-time measurements; native Wayland/NVIDIA, X11, Windows and macOS playback; long-running playback memory checks. Still-frame and geometry checks are not substitutes for these. diff --git a/docs/transitions/wipe-down.png b/docs/transitions/wipe-down.png new file mode 100644 index 0000000..e5a9baf Binary files /dev/null and b/docs/transitions/wipe-down.png differ diff --git a/docs/transitions/wipe-left.png b/docs/transitions/wipe-left.png new file mode 100644 index 0000000..f9f0858 Binary files /dev/null and b/docs/transitions/wipe-left.png differ diff --git a/docs/transitions/wipe-right.png b/docs/transitions/wipe-right.png new file mode 100644 index 0000000..f813e41 Binary files /dev/null and b/docs/transitions/wipe-right.png differ diff --git a/docs/transitions/wipe-up.png b/docs/transitions/wipe-up.png new file mode 100644 index 0000000..250291c Binary files /dev/null and b/docs/transitions/wipe-up.png differ diff --git a/examples/showcase/README.md b/examples/showcase/README.md index a4e5008..420d8b0 100644 --- a/examples/showcase/README.md +++ b/examples/showcase/README.md @@ -92,9 +92,10 @@ MP4 output. Recording ignores interactive demo mode and requires no `--program`. - Push and Wipe: all four cardinal directions. - Dissolve, Glitter (right/down/lower right), Fade and Replace. -Glitter, Wipe and Fade currently fall back to an instant change -in PDFCube. Their slides say so; they preserve the real PDF metadata to provide -a showcase as these effects are implemented. Other viewers may implement them +Glitter and Wipe are animated in PDFCube. Glitter releases fading fragments and +sparks; Wipe moves a raised blade in the requested direction. Fade currently +falls back to an instant change; its slide says so and preserves the real PDF +metadata for the planned implementation. Other viewers may implement them or ignore PDF transitions entirely. Replace is deliberately instant. PDF transitions are stored on the **destination** page. Each `\ShowSlide` call diff --git a/examples/showcase/showcase.tex b/examples/showcase/showcase.tex index d2c3c02..fd78c8f 100644 --- a/examples/showcase/showcase.tex +++ b/examples/showcase/showcase.tex @@ -171,21 +171,21 @@ % Slide 37: transition entering this page. \ShowSlide{Push /Di 270 /D 1.25}{Push / down}{Soft slide blocks transfer\\momentum and briefly deform.}{Dissolve}{36}{night}{Animated in PDFCube} % Slide 38: transition entering this page. -\ShowSlide{Dissolve /D 1.25}{Dissolve}{The outgoing slide cracks\\into falling glass shards.}{Glitter / right}{37}{sea}{Animated in PDFCube} +\ShowSlide{Dissolve /D 1.25}{Dissolve}{The outgoing slide shatters\\into tumbling glass fragments.}{Glitter / right}{37}{sea}{Animated in PDFCube} % Slide 39: transition entering this page. -\ShowSlide{Glitter /Di 0 /D 1.25}{Glitter / right}{A standard PDF sparkle effect\\ready for a future renderer.}{Glitter / down}{38}{plum}{PDFCube currently replaces instantly} +\ShowSlide{Glitter /Di 0 /D 1.25}{Glitter / right}{A moving front releases pieces\\with a brief trail of sparks.}{Glitter / down}{38}{plum}{Animated in PDFCube} % Slide 40: transition entering this page. -\ShowSlide{Glitter /Di 270 /D 1.25}{Glitter / down}{A standard PDF sparkle effect\\ready for a future renderer.}{Glitter / lower right}{39}{night}{PDFCube currently replaces instantly} +\ShowSlide{Glitter /Di 270 /D 1.25}{Glitter / down}{A moving front releases pieces\\with a brief trail of sparks.}{Glitter / lower right}{39}{night}{Animated in PDFCube} % Slide 41: transition entering this page. -\ShowSlide{Glitter /Di 315 /D 1.25}{Glitter / lower right}{A standard PDF sparkle effect\\ready for a future renderer.}{Wipe / right}{40}{sea}{PDFCube currently replaces instantly} +\ShowSlide{Glitter /Di 315 /D 1.25}{Glitter / lower right}{A moving front releases pieces\\with a brief trail of sparks.}{Wipe / right}{40}{sea}{Animated in PDFCube} % Slide 42: transition entering this page. -\ShowSlide{Wipe /Di 0 /D 1.25}{Wipe / right}{A standard PDF directional reveal\\ready for a future renderer.}{Wipe / up}{41}{plum}{PDFCube currently replaces instantly} +\ShowSlide{Wipe /Di 0 /D 1.25}{Wipe / right}{A raised wiper sweeps right\\and reveals the next slide.}{Wipe / up}{41}{plum}{Animated in PDFCube} % Slide 43: transition entering this page. -\ShowSlide{Wipe /Di 90 /D 1.25}{Wipe / up}{A standard PDF directional reveal\\ready for a future renderer.}{Wipe / left}{42}{night}{PDFCube currently replaces instantly} +\ShowSlide{Wipe /Di 90 /D 1.25}{Wipe / up}{A raised wiper sweeps up\\and reveals the next slide.}{Wipe / left}{42}{night}{Animated in PDFCube} % Slide 44: transition entering this page. -\ShowSlide{Wipe /Di 180 /D 1.25}{Wipe / left}{A standard PDF directional reveal\\ready for a future renderer.}{Wipe / down}{43}{sea}{PDFCube currently replaces instantly} +\ShowSlide{Wipe /Di 180 /D 1.25}{Wipe / left}{A raised wiper sweeps left\\and reveals the next slide.}{Wipe / down}{43}{sea}{Animated in PDFCube} % Slide 45: transition entering this page. -\ShowSlide{Wipe /Di 270 /D 1.25}{Wipe / down}{A standard PDF directional reveal\\ready for a future renderer.}{Fade}{44}{plum}{PDFCube currently replaces instantly} +\ShowSlide{Wipe /Di 270 /D 1.25}{Wipe / down}{A raised wiper sweeps down\\and reveals the next slide.}{Fade}{44}{plum}{Animated in PDFCube} % Slide 46: transition entering this page. \ShowSlide{Fade /D 1.25}{Fade}{A standard PDF crossfade\\ready for a future renderer.}{Replace}{45}{night}{PDFCube currently replaces instantly} % Slide 47: transition entering this page. diff --git a/examples/transitions/README.md b/examples/transitions/README.md index ac2f25e..48aea80 100644 --- a/examples/transitions/README.md +++ b/examples/transitions/README.md @@ -23,6 +23,10 @@ Ready-to-open diagnostic slides: - Push: [right](push-right.pdf), [up](push-up.pdf), [left](push-left.pdf), [down](push-down.pdf) - [Dissolve](dissolve.pdf) +- Glitter: [right](glitter-right.pdf), [down](glitter-down.pdf), + [lower right](glitter-down-right.pdf) +- Wipe: [right](wipe-right.pdf), [up](wipe-up.pdf), + [left](wipe-left.pdf), [down](wipe-down.pdf) From the repository root: @@ -56,6 +60,13 @@ python3 tests/transition_fixture.py examples/transitions The generator is the source of truth; do not edit the PDFs by hand. See the [review roadmap](../../docs/transitions/README.md) for videos and tests. +## Wipe + +The four Wipe examples move a raised, beveled blade in the named PDF direction. +The destination is revealed behind its rubber lip; the unrevealed part stays +exactly on the outgoing page. Both pages retain their aspect ratios. Use the +normal Space/Page Down playback or `--program 4:wipe`. + ## Glitter `glitter-right.pdf`, `glitter-down.pdf` and `glitter-down-right.pdf` demonstrate diff --git a/examples/transitions/wipe-down.pdf b/examples/transitions/wipe-down.pdf new file mode 100644 index 0000000..026ab1e Binary files /dev/null and b/examples/transitions/wipe-down.pdf differ diff --git a/examples/transitions/wipe-left.pdf b/examples/transitions/wipe-left.pdf new file mode 100644 index 0000000..d1702f5 Binary files /dev/null and b/examples/transitions/wipe-left.pdf differ diff --git a/examples/transitions/wipe-right.pdf b/examples/transitions/wipe-right.pdf new file mode 100644 index 0000000..8a35530 Binary files /dev/null and b/examples/transitions/wipe-right.pdf differ diff --git a/examples/transitions/wipe-up.pdf b/examples/transitions/wipe-up.pdf new file mode 100644 index 0000000..dcdf7d9 Binary files /dev/null and b/examples/transitions/wipe-up.pdf differ diff --git a/man/pdfcube.1 b/man/pdfcube.1 index c2c8d1f..78e0e08 100644 --- a/man/pdfcube.1 +++ b/man/pdfcube.1 @@ -79,6 +79,12 @@ selects the soft-block Push effect with a 750 ms animation and the document's direction. .B dissolve selects the glass-shatter Dissolve effect with a 750 ms animation. +.B glitter +selects directional fragments and sparks with a 750 ms animation. +.B wipe +selects a raised physical wiper with a 750 ms animation and the PDF direction +(0/right, 90/up, 180/left, 270/down; other angles default to right). +Both pages retain their fitted aspect ratios. The last pair is repeated for remaining slides. For example, .B \-\-program 5:c:3:p diff --git a/src/options.cc b/src/options.cc index fc44202..ec07b28 100644 --- a/src/options.cc +++ b/src/options.cc @@ -66,7 +66,7 @@ ParseResult parse_options(int argc, char** argv) { ("experimental", po::value()->implicit_value(true), "Enable experimental animation details, including thin glass shard edges.") ("program", po::value(), - "Transition program duration:type pairs (c, p, blinds, split, cover, uncover, fly, push, dissolve, glitter), for example 5:c:3:p.") + "Transition program duration:type pairs (c, p, blinds, split, cover, uncover, fly, push, dissolve, glitter, wipe), for example 5:c:3:p.") ("performance-log", po::value(), "Write buffered animation frame timings to a CSV file on exit.") ("no-fullscreen,n", po::value()->implicit_value(true), diff --git a/src/playback.hpp b/src/playback.hpp index 159917c..668e67f 100644 --- a/src/playback.hpp +++ b/src/playback.hpp @@ -18,6 +18,7 @@ inline Transition transition_from_name(const std::string& effect) { if (effect == "fly") return Transition::fly; if (effect == "push") return Transition::push; if (effect == "dissolve") return Transition::dissolve; + if (effect == "wipe") return Transition::wipe; if (effect == "glitter") return Transition::glitter; return Transition::switch_page; } @@ -32,6 +33,7 @@ inline Transition transition_from_page(const PageInfo& page) { case POPPLER_PAGE_TRANSITION_FLY: return Transition::fly; case POPPLER_PAGE_TRANSITION_PUSH: return Transition::push; case POPPLER_PAGE_TRANSITION_DISSOLVE: return Transition::dissolve; + case POPPLER_PAGE_TRANSITION_WIPE: return Transition::wipe; case POPPLER_PAGE_TRANSITION_GLITTER: return Transition::glitter; default: return Transition::switch_page; } diff --git a/src/renderer.cc b/src/renderer.cc index dbd3e50..dcf696c 100644 --- a/src/renderer.cc +++ b/src/renderer.cc @@ -8,6 +8,7 @@ #include "push.hpp" #include "dissolve.hpp" #include "glitter.hpp" +#include "wipe.hpp" #include #include @@ -555,6 +556,52 @@ void Renderer::render(int width, int height, const FrameState& frame) { texture_size, Matrix::identity().value.data()); return; } + if (frame.transition == Transition::wipe) { + const auto wipe = wipe_geometry(frame.progress, frame.direction_degrees, width, height); + const auto draw_fitted = [&](int index) { + const auto& info = document_.page_info(index); + const auto fit = content_viewport(width, height, info.width / info.height); + glViewport(fit.x, fit.y, fit.width, fit.height); + draw_page(index, page_size(index), Matrix::identity().value.data()); + }; + glDisable(GL_DEPTH_TEST); + draw_fitted(frame.next_page); + glEnable(GL_SCISSOR_TEST); + glScissor(wipe.outgoing.x, wipe.outgoing.y, + wipe.outgoing.width, wipe.outgoing.height); + // The outgoing letterbox is part of its image too. Clear the unrevealed + // region before drawing it, so a taller incoming page cannot leak through. + glClear(GL_COLOR_BUFFER_BIT); + draw_fitted(frame.current_page); + glDisable(GL_SCISSOR_TEST); + + // A shallow squeegee rail, with dark rubber lip and a bright metal bevel. + // Its center covers the pixel-aligned reveal edge. Fixed geometry and + // closed-form travel keep memory bounded and replay exactly reproducible. + glViewport(0, 0, width, height); + const Matrix view = perspective(2 * std::atan(1 / flip_camera), 1, .5F, 10) * + translate(0, 0, -flip_camera); + const Matrix rail = translate(wipe.horizontal ? wipe.center : 0, + wipe.horizontal ? 0 : wipe.center, 0) * + rotate_z(wipe.horizontal ? 0 : pi / 2); + const float half = wipe.half_width; + constexpr float depth = .035F; + for (const float side : {-1.0F, 1.0F}) { + draw_color(Color{.11F,.14F,.17F,1}, + (view * rail * translate(side * half,0,depth/2) * + rotate_y(side * pi/2) * scale(depth/2,1.08F,1)).value.data()); + } + draw_color(Color{.14F,.17F,.20F,1}, + (view * rail * translate(0,0,depth) * scale(half,1.08F,1)).value.data()); + draw_color(Color{.43F,.48F,.53F,1}, + (view * rail * translate(-half*.12F,0,depth) * scale(half*.58F,1.08F,1)).value.data()); + draw_color(Color{.78F,.82F,.85F,1}, + (view * rail * translate(-half*.67F,0,depth) * scale(half*.10F,1.08F,1)).value.data()); + draw_color(Color{.035F,.045F,.055F,1}, + (view * rail * translate(half*.78F,0,depth) * scale(half*.22F,1.08F,1)).value.data()); + glEnable(GL_DEPTH_TEST); + return; + } if (frame.transition == Transition::push) { const PushPose pose = push_pose(frame.progress, frame.direction_degrees); const Matrix view = perspective(2 * std::atan(1 / flip_camera), 1, .5F, 10) * diff --git a/src/renderer.hpp b/src/renderer.hpp index 6d61be5..2e74ac6 100644 --- a/src/renderer.hpp +++ b/src/renderer.hpp @@ -17,14 +17,15 @@ namespace pdfcube { enum class Transition { none, cube_forward, cube_backward, switch_page, blinds, split, cover, uncover, fly, push, - dissolve, glitter + dissolve, glitter, wipe }; inline bool page_effect(Transition transition) { return transition == Transition::blinds || transition == Transition::split || transition == Transition::cover || transition == Transition::uncover || transition == Transition::fly || transition == Transition::push || - transition == Transition::dissolve || transition == Transition::glitter; + transition == Transition::dissolve || transition == Transition::glitter || + transition == Transition::wipe; } struct FrameState { diff --git a/src/wipe.hpp b/src/wipe.hpp new file mode 100644 index 0000000..0c73f97 --- /dev/null +++ b/src/wipe.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +namespace pdfcube { + +inline int wipe_direction(int degrees) { + return degrees == 90 || degrees == 180 || degrees == 270 ? degrees : 0; +} + +struct WipeClip { int x, y, width, height; }; +struct WipeGeometry { + WipeClip outgoing; + bool horizontal; + float center; // Clip-space blade center along its travel axis. + float half_width; + int direction; +}; + +// The blade travels just beyond both edges, including its bevel. Pixel-aligned +// clipping leaves no partially sampled seam between independently fitted pages. +inline WipeGeometry wipe_geometry(double progress, int degrees, int width, int height) { + const int direction = wipe_direction(degrees); + const bool horizontal = direction == 0 || direction == 180; + const bool positive = direction == 0 || direction == 90; + const int extent = horizontal ? width : height; + const float half_pixels = std::max(2.0F, .014F * std::min(width, height)); + const float margin = 2 * half_pixels / extent; + const double p = std::clamp(progress, 0.0, 1.0); + const double eased = p * p * (3 - 2 * p); + const double front = -margin + (1 + 2 * margin) * eased; + const int swept = std::clamp(static_cast(std::lround(front * extent)), 0, extent); + WipeClip clip{0, 0, width, height}; + if (horizontal) { + clip.x = positive ? swept : 0; + clip.width = width - swept; + } else { + clip.y = positive ? swept : 0; + clip.height = height - swept; + } + return {clip, horizontal, static_cast((positive ? 1 : -1) * (2 * front - 1)), + 2 * half_pixels / extent, direction}; +} + +} // namespace pdfcube diff --git a/tests/README.md b/tests/README.md index 6c6fb4b..d8d0931 100644 --- a/tests/README.md +++ b/tests/README.md @@ -31,6 +31,11 @@ and reads back all 61 frames of a 60 Hz second, including experimental glass. A small Fontconfig fixture uses DejaVu Sans instead of scanning all system font folders; both cold font and shader initialization remain inside the measured budget. +Wipe cases compare the regions ahead of and behind the blade with exact static +page pixels, including mixed page letterboxes. They verify that the next static +frame is unaffected by clipping/depth state. Geometry tests sample 1001 explicit +progress values per direction and viewport shape without wall-clock waits. + Clock and auto-advance tests use synthetic timestamps and a recording scheduler. They verify frame progress and the exact millisecond delays sent to the backend, including cancellation and idle dispatch. They never sleep. diff --git a/tests/meson.build b/tests/meson.build index 47943ee..b5465eb 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -3,6 +3,7 @@ python = import('python').find_installation('python3') test_watchdog = 10 foreach item : [ + ['physical wiper geometry', 'test_wipe.cc'], ['directional glitter geometry', 'test_glitter.cc'], ['deterministic glass fracture', 'test_dissolve.cc'], ['Fly leaf trajectory', 'test_fly.cc'], @@ -74,7 +75,7 @@ if host_machine.system() == 'linux' render_environment.set('FONTCONFIG_FILE', meson.current_source_dir() / 'fonts.conf') render_environment.set('XDG_CACHE_HOME', meson.current_build_dir() / 'native-test-cache') # Every case targets 500 ms, including cold setup. Serialize to avoid GPU contention. - foreach effect : ['Blinds', 'Split', 'Cover', 'Uncover', 'Fly', 'Push', 'Dissolve', 'Glitter'] + foreach effect : ['Blinds', 'Split', 'Cover', 'Uncover', 'Fly', 'Push', 'Dissolve', 'Glitter', 'Wipe'] axes = ['H'] angles = ['none'] motions = ['I'] @@ -84,7 +85,7 @@ if host_machine.system() == 'linux' if effect == 'Split' or effect == 'Fly' motions = ['I', 'O'] endif - if effect == 'Cover' or effect == 'Uncover' or effect == 'Push' + if effect == 'Cover' or effect == 'Uncover' or effect == 'Push' or effect == 'Wipe' angles = ['0', '90', '180', '270'] elif effect == 'Fly' angles = ['0', '45', '90', '135', '180', '225', '270', '315'] @@ -112,6 +113,9 @@ if host_machine.system() == 'linux' endforeach endforeach endforeach + test('Wipe repeated playback memory', python, + args: [files('test_wipe_replay.py'), render_driver], + timeout: test_watchdog, is_parallel: false, suite: ['render'], env: render_environment) test('rendering compatibility', python, args: [files('test_render.py'), render_driver, files('fixture.pdf'), files('fixture-wide.pdf')], timeout: test_watchdog, is_parallel: false, suite: ['render'], env: render_environment) diff --git a/tests/test_playback.cc b/tests/test_playback.cc index 534285e..f875ff5 100644 --- a/tests/test_playback.cc +++ b/tests/test_playback.cc @@ -10,7 +10,8 @@ int main() { {POPPLER_PAGE_TRANSITION_COVER,"cover"}, {POPPLER_PAGE_TRANSITION_UNCOVER,"uncover"}, {POPPLER_PAGE_TRANSITION_FLY,"fly"}, {POPPLER_PAGE_TRANSITION_PUSH,"push"}, {POPPLER_PAGE_TRANSITION_DISSOLVE,"dissolve"}, - {POPPLER_PAGE_TRANSITION_GLITTER,"glitter"}}; + {POPPLER_PAGE_TRANSITION_GLITTER,"glitter"}, + {POPPLER_PAGE_TRANSITION_WIPE,"wipe"}}; for (const auto& [kind,name] : kinds) { pdfcube::PageInfo page; page.transition_type=kind; diff --git a/tests/test_transition_metadata.cc b/tests/test_transition_metadata.cc index 8c53cb7..ebe4017 100644 --- a/tests/test_transition_metadata.cc +++ b/tests/test_transition_metadata.cc @@ -12,6 +12,7 @@ int main(int argc, char** argv) { const bool fly = argc >= 5 && argv[3][0] == 'F'; const bool push = argc >= 5 && argv[3][0] == 'P'; const bool dissolve = argc >= 5 && argv[3][0] == 'D'; + const bool wipe = argc >= 5 && argv[3][0] == 'W'; const bool glitter = argc >= 5 && argv[3][0] == 'G'; const auto type = split ? POPPLER_PAGE_TRANSITION_SPLIT : cover ? POPPLER_PAGE_TRANSITION_COVER : @@ -19,6 +20,7 @@ int main(int argc, char** argv) { fly ? POPPLER_PAGE_TRANSITION_FLY : push ? POPPLER_PAGE_TRANSITION_PUSH : dissolve ? POPPLER_PAGE_TRANSITION_DISSOLVE : + wipe ? POPPLER_PAGE_TRANSITION_WIPE : glitter ? POPPLER_PAGE_TRANSITION_GLITTER : POPPLER_PAGE_TRANSITION_BLINDS; const bool success = page.transition_type == type && (argc < 6 || page.angle == std::atoi(argv[5])) && diff --git a/tests/test_transitions.py b/tests/test_transitions.py index bc163f0..d90d4a8 100644 --- a/tests/test_transitions.py +++ b/tests/test_transitions.py @@ -17,6 +17,7 @@ push = effect == "Push" dissolve = effect == "Dissolve" glitter = effect == "Glitter" +wipe = effect == "Wipe" with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -57,7 +58,7 @@ def capture(pdf, progress=None, *extra): sys.exit(0) assert mode == "checkpoints", "unknown rendering test mode" metadata = [metadata_test, str(deck), orientation, effect, motion] - extra_metadata = [str(angle)] if flip or push or glitter else \ + extra_metadata = [str(angle)] if flip or push or glitter or wipe else \ [str(angle), ".35", "1"] if fly else [] subprocess.run(metadata + extra_metadata, check=True) make_pdf(first, orientation, sizes, (0,)) @@ -79,7 +80,7 @@ def capture(pdf, progress=None, *extra): fallback = root / "fallback.pdf" make_pdf(fallback, orientation, sizes, effect=effect, angle=value) assert capture(fallback, .5) == middle, "default/invalid direction differs" - if (push or glitter) and angle == 0: + if (push or glitter or wipe) and angle == 0: for value in (None, 45, -90): fallback = root / "fallback.pdf" make_pdf(fallback, orientation, sizes, effect=effect, angle=value) @@ -107,6 +108,23 @@ def capture(pdf, progress=None, *extra): make_pdf(opposite, orientation, sizes, effect=effect, motion="O" if motion == "I" else "I") assert middle != capture(opposite, .5), "inward/outward direction ignored" + if wipe: + from PIL import Image + from io import BytesIO + # Pixels safely behind/ahead of the blade must be exact page + # pixels, including the independent letterboxes in mixed ratios. + before = Image.open(BytesIO(start)) + after = Image.open(BytesIO(end)) + wiped = Image.open(BytesIO(middle)) + w,h = wiped.size + if angle in (0,180): + low,high = (0,0,w*2//5,h),(w*3//5,0,w,h) + else: + low,high = (0,0,w,h*2//5),(0,h*3//5,w,h) + revealed,covered = (low,high) if angle in (0,270) else (high,low) + assert wiped.crop(revealed).tobytes() == after.crop(revealed).tobytes() + assert wiped.crop(covered).tobytes() == before.crop(covered).tobytes() + assert capture(first) == start, "wiper leaked GL state into a static frame" quarter, three_quarters = capture(deck, .25), capture(deck, .75) assert len({start, quarter, middle, three_quarters, end}) == 5, "cascade stalled" if not dissolve: @@ -114,7 +132,7 @@ def capture(pdf, progress=None, *extra): if glitter: make_pdf(changed, orientation, sizes, effect=effect, angle={0: 270, 270: 315, 315: 0}[angle]) - elif flip or fly or push: + elif flip or fly or push or wipe: make_pdf(changed, orientation, sizes, effect=effect, motion=motion, angle=(angle+180)%360) else: diff --git a/tests/test_wipe.cc b/tests/test_wipe.cc new file mode 100644 index 0000000..f32be96 --- /dev/null +++ b/tests/test_wipe.cc @@ -0,0 +1,44 @@ +#include "wipe.hpp" + +#include +#include + +int main() { + using namespace pdfcube; + for (const int direction : {0,90,180,270}) { + for (const auto size : {WipeClip{0,0,320,240},WipeClip{0,0,180,320},WipeClip{0,0,1,1}}) { + const auto first = wipe_geometry(0,direction,size.width,size.height); + const auto last = wipe_geometry(1,direction,size.width,size.height); + assert(first.outgoing.width == size.width && first.outgoing.height == size.height); + assert(last.outgoing.width == 0 || last.outgoing.height == 0); + int previous = size.width * size.height; + float previous_center = first.center; + for (int step=0; step<=1000; ++step) { + const auto pose = wipe_geometry(step/1000.0,direction,size.width,size.height); + const auto again = wipe_geometry(step/1000.0,direction,size.width,size.height); + const auto clip = pose.outgoing; + assert(clip.x >= 0 && clip.y >= 0 && clip.width >= 0 && clip.height >= 0); + assert(clip.x + clip.width <= size.width && clip.y + clip.height <= size.height); + const int area = clip.width * clip.height; + assert(area <= previous); + previous = area; + assert(pose.center == again.center && pose.half_width == again.half_width); + assert(pose.half_width > 0 && std::isfinite(pose.center)); + if (direction == 0 || direction == 90) assert(pose.center >= previous_center); + else assert(pose.center <= previous_center); + previous_center = pose.center; + } + assert(wipe_geometry(-1,direction,size.width,size.height).center == first.center); + assert(wipe_geometry(2,direction,size.width,size.height).center == last.center); + // Symmetric travel and a midpoint exactly at the center, without time integration. + const auto quarter = wipe_geometry(.25,direction,size.width,size.height); + const auto three = wipe_geometry(.75,direction,size.width,size.height); + assert(std::abs(quarter.center + three.center) < .00001F); + assert(std::abs(wipe_geometry(.5,direction,size.width,size.height).center) < .00001F); + } + } + for (const int invalid : {-90,45,135,360}) { + assert(wipe_direction(invalid) == 0); + assert(wipe_geometry(.37,invalid,320,240).center == wipe_geometry(.37,0,320,240).center); + } +} diff --git a/tests/test_wipe_replay.py b/tests/test_wipe_replay.py new file mode 100644 index 0000000..3a5e58a --- /dev/null +++ b/tests/test_wipe_replay.py @@ -0,0 +1,23 @@ +"""Replay in one live headless renderer; RSS growth must stay bounded.""" +from pathlib import Path +import sys +import tempfile + +from render_client import Frames +from transition_fixture import make_pdf + +with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + deck = root / 'wipe.pdf' + make_pdf(deck,effect='Wipe',angle=270) + frames = Frames(Path(sys.argv[1]),root) + command = ('@sequence','--snapshot',root/'unused.png','--snapshot-progress','0',deck) + frames.render(*command) # Warm font, texture and shader caches in this context. + def rss(): + status = Path(f'/proc/{frames.process.pid}/status').read_text() + return int(next(line.split()[1] for line in status.splitlines() if line.startswith('VmRSS:')))*1024 + baseline = rss() + for _ in range(4): + frames.render(*command) + assert rss() <= baseline + 4*1024*1024, 'Wipe memory grew during repeated playback' + frames.close() diff --git a/tests/transition_fixture.py b/tests/transition_fixture.py index d6fec8e..fb1c586 100644 --- a/tests/transition_fixture.py +++ b/tests/transition_fixture.py @@ -21,6 +21,9 @@ EXAMPLES += [(f"glitter-{name}", "Glitter", "H", "I", angle) for angle, name in ((0, "right"), (270, "down"), (315, "down-right"))] +EXAMPLES += [(f"wipe-{name}", "Wipe", "H", "I", angle) + for angle,name in ((0,"right"),(90,"up"),(180,"left"),(270,"down"))] + def make_pdf(path, orientation="H", sizes=((640, 360), (640, 360)), pages=(0, 1), effect="Blinds", motion="I", angle=None, start_scale=.35, rectangular=True): diff --git a/tools/transition_gallery.py b/tools/transition_gallery.py index 919aadc..888b5e1 100644 --- a/tools/transition_gallery.py +++ b/tools/transition_gallery.py @@ -21,7 +21,7 @@ help="Number of snapshots to capture concurrently (default: up to 4)") parser.add_argument("--update-docs", action="store_true", help="Refresh the checked-in contact sheets from these captures") -parser.add_argument("--effects", nargs="+", choices=("Blinds", "Split", "Cover", "Uncover", "Fly", "Push", "Dissolve", "Glitter"), +parser.add_argument("--effects", nargs="+", choices=("Blinds", "Split", "Cover", "Uncover", "Fly", "Push", "Dissolve", "Glitter", "Wipe"), help="Capture only selected effects (default: all)") parser.add_argument("--inside-xvfb", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args()