diff --git a/MIN_VERSION b/MIN_VERSION index 6e8bf73..ac39a10 100644 --- a/MIN_VERSION +++ b/MIN_VERSION @@ -1 +1 @@ -0.1.0 +0.9.0 diff --git a/docs/transitions/README.md b/docs/transitions/README.md index 17c51cd..7624820 100644 --- a/docs/transitions/README.md +++ b/docs/transitions/README.md @@ -369,3 +369,28 @@ For every new effect, add parameter/default/invalid-value coverage, sampled geometry and image checks, repeated-playback memory checks, and a review bundle. Record renderer, resolution, duration and frame-time distribution for performance reviews; do not infer a performance pass from still-image tests. + +## Fade: experimental, awaiting visual approval + +Fade exchanges light between two stationary slides. An sRGB decode, linear-light +blend, and sRGB encode keep white artwork steady and avoid a dark midpoint. +The blend weight is smoothstep (`3t² − 2t³`), with gentle starts and finishes. +Each slide retains its own fitted aspect ratio; the surrounding background is +part of each composition. Both full-resolution textures are ready before motion, +and endpoints use the exact static page path. + +PDF `/S /Fade` uses `/Trans /D` for animation duration. `--program 4:fade` uses +the normal 750 ms programmed animation. Direction and axis have no effect. +Try [fade.pdf](../../examples/transitions/fade.pdf). + +Candidate headless software-renderer captures (light, dark, and colored slides): + +![Fade review checkpoints](fade-review/contact-sheet.png) + +Animated previews: [light](fade-review/white.gif), [dark](fade-review/dark.gif), +[colored](fade-review/color.gif). These synthetic artwork pairs check shared +labels and brightness; photographic material still needs visual review. +Regenerate these previews with `python3 tools/fade_review.py` after building. +Tests compare rendered pixels against the sRGB transfer function at three +intermediate times, including mixed aspect ratios, and verify unchanged identical +slides, deterministic output, PDF metadata, CLI dispatch, and exact endpoints. diff --git a/docs/transitions/fade-review/color.gif b/docs/transitions/fade-review/color.gif new file mode 100644 index 0000000..6f48c3b Binary files /dev/null and b/docs/transitions/fade-review/color.gif differ diff --git a/docs/transitions/fade-review/color.pdf b/docs/transitions/fade-review/color.pdf new file mode 100644 index 0000000..f17e002 Binary files /dev/null and b/docs/transitions/fade-review/color.pdf differ diff --git a/docs/transitions/fade-review/color.png b/docs/transitions/fade-review/color.png new file mode 100644 index 0000000..1550c70 Binary files /dev/null and b/docs/transitions/fade-review/color.png differ diff --git a/docs/transitions/fade-review/contact-sheet.png b/docs/transitions/fade-review/contact-sheet.png new file mode 100644 index 0000000..e730043 Binary files /dev/null and b/docs/transitions/fade-review/contact-sheet.png differ diff --git a/docs/transitions/fade-review/dark.gif b/docs/transitions/fade-review/dark.gif new file mode 100644 index 0000000..b450bcc Binary files /dev/null and b/docs/transitions/fade-review/dark.gif differ diff --git a/docs/transitions/fade-review/dark.pdf b/docs/transitions/fade-review/dark.pdf new file mode 100644 index 0000000..d96d934 Binary files /dev/null and b/docs/transitions/fade-review/dark.pdf differ diff --git a/docs/transitions/fade-review/dark.png b/docs/transitions/fade-review/dark.png new file mode 100644 index 0000000..c08a94b Binary files /dev/null and b/docs/transitions/fade-review/dark.png differ diff --git a/docs/transitions/fade-review/white.gif b/docs/transitions/fade-review/white.gif new file mode 100644 index 0000000..50d1e57 Binary files /dev/null and b/docs/transitions/fade-review/white.gif differ diff --git a/docs/transitions/fade-review/white.pdf b/docs/transitions/fade-review/white.pdf new file mode 100644 index 0000000..30bb00b Binary files /dev/null and b/docs/transitions/fade-review/white.pdf differ diff --git a/docs/transitions/fade-review/white.png b/docs/transitions/fade-review/white.png new file mode 100644 index 0000000..d5dcf57 Binary files /dev/null and b/docs/transitions/fade-review/white.png differ diff --git a/examples/transitions/README.md b/examples/transitions/README.md index 48aea80..29869db 100644 --- a/examples/transitions/README.md +++ b/examples/transitions/README.md @@ -71,3 +71,8 @@ normal Space/Page Down playback or `--program 4:wipe`. `glitter-right.pdf`, `glitter-down.pdf` and `glitter-down-right.pdf` demonstrate the experimental directional flare and sparks transition. Press Space to advance. + +## Fade + +[fade.pdf](fade.pdf) demonstrates a stationary, linear-light crossfade with gently +eased timing. Press Space to advance; `/Trans /D` sets its 1.25-second duration. diff --git a/examples/transitions/fade.pdf b/examples/transitions/fade.pdf new file mode 100644 index 0000000..f17e002 Binary files /dev/null and b/examples/transitions/fade.pdf differ diff --git a/src/playback.hpp b/src/playback.hpp index 668e67f..b76b83a 100644 --- a/src/playback.hpp +++ b/src/playback.hpp @@ -19,6 +19,7 @@ inline Transition transition_from_name(const std::string& effect) { if (effect == "push") return Transition::push; if (effect == "dissolve") return Transition::dissolve; if (effect == "wipe") return Transition::wipe; + if (effect == "fade") return Transition::fade; if (effect == "glitter") return Transition::glitter; return Transition::switch_page; } @@ -34,6 +35,7 @@ inline Transition transition_from_page(const PageInfo& page) { 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_FADE: return Transition::fade; case POPPLER_PAGE_TRANSITION_GLITTER: return Transition::glitter; default: return Transition::switch_page; } diff --git a/src/renderer.cc b/src/renderer.cc index cf249fe..5722e95 100644 --- a/src/renderer.cc +++ b/src/renderer.cc @@ -135,7 +135,32 @@ uniform vec4 solid_color; uniform bool textured; uniform float shade; out vec4 output_color; +uniform bool fading; +uniform sampler2D incoming_texture; +uniform vec4 outgoing_fit; +uniform vec4 incoming_fit; +uniform vec3 fade_background; +uniform float fade_weight; +vec3 linear_light(vec3 c) { + return mix(c / 12.92, pow((c + .055) / 1.055, vec3(2.4)), step(vec3(.04045), c)); +} +vec3 encoded_light(vec3 c) { + return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - .055, step(vec3(.0031308), c)); +} +vec3 fitted_page(sampler2D image, vec4 fit) { + vec2 local = (uv - fit.xy) / fit.zw; + if (any(lessThan(local, vec2(0))) || any(greaterThan(local, vec2(1)))) + return fade_background; + return texture(image, local).rgb; +} void main() { + if (fading) { + vec3 a = linear_light(fitted_page(page_texture, outgoing_fit)); + vec3 b = linear_light(fitted_page(incoming_texture, incoming_fit)); + output_color = vec4(encoded_light(mix(a, b, fade_weight)), 1); + return; + } + output_color = textured ? texture(page_texture, uv) : solid_color; output_color = mix(output_color, vec4(.80, .90, .96, 1.0), rim); output_color.rgb *= shade * light; @@ -175,7 +200,32 @@ uniform vec4 solid_color; uniform bool textured; uniform float shade; out vec4 output_color; +uniform bool fading; +uniform sampler2D incoming_texture; +uniform vec4 outgoing_fit; +uniform vec4 incoming_fit; +uniform vec3 fade_background; +uniform float fade_weight; +vec3 linear_light(vec3 c) { + return mix(c / 12.92, pow((c + .055) / 1.055, vec3(2.4)), step(vec3(.04045), c)); +} +vec3 encoded_light(vec3 c) { + return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - .055, step(vec3(.0031308), c)); +} +vec3 fitted_page(sampler2D image, vec4 fit) { + vec2 local = (uv - fit.xy) / fit.zw; + if (any(lessThan(local, vec2(0))) || any(greaterThan(local, vec2(1)))) + return fade_background; + return texture(image, local).rgb; +} void main() { + if (fading) { + vec3 a = linear_light(fitted_page(page_texture, outgoing_fit)); + vec3 b = linear_light(fitted_page(incoming_texture, incoming_fit)); + output_color = vec4(encoded_light(mix(a, b, fade_weight)), 1); + return; + } + output_color = textured ? texture(page_texture, uv) : solid_color; output_color = mix(output_color, vec4(.80, .90, .96, 1.0), rim); output_color.rgb *= shade * light; @@ -543,6 +593,7 @@ void Renderer::render(int width, int height, const FrameState& frame) { glUniform4f(uv_rect_uniform_, 0, 0, 1, 1); glUniform1f(shade_uniform_, 1); + glUniform1i(glGetUniformLocation(program_, "fading"), GL_FALSE); const bool panels = page_effect(frame.transition); const bool landed = panels && frame.progress >= 1; const auto& page = document_.page_info(landed ? frame.next_page : frame.current_page); @@ -641,6 +692,30 @@ void Renderer::render(int width, int height, const FrameState& frame) { glVertexAttrib1f(3, 0); return; } + if (frame.transition == Transition::fade) { + // Blend complete slide compositions, including independently fitted margins. + glViewport(0, 0, width, height); + const auto fit_uniform = [&](const char* name, int index) { + const auto& info = document_.page_info(index); + const auto fit = content_viewport(width, height, info.width / info.height); + glUniform4f(glGetUniformLocation(program_, name), + float(fit.x) / width, float(height - fit.y - fit.height) / height, + float(fit.width) / width, float(fit.height) / height); + }; + fit_uniform("outgoing_fit", frame.current_page); + fit_uniform("incoming_fit", frame.next_page); + glUniform3f(glGetUniformLocation(program_, "fade_background"), + background_.red, background_.green, background_.blue); + const float t = static_cast(frame.progress); + glUniform1f(glGetUniformLocation(program_, "fade_weight"), t * t * (3 - 2 * t)); + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, texture_for_page(frame.next_page, page_size(frame.next_page))); + glUniform1i(glGetUniformLocation(program_, "incoming_texture"), 1); + glUniform1i(glGetUniformLocation(program_, "fading"), GL_TRUE); + draw_page(frame.current_page, page_size(frame.current_page), Matrix::identity().value.data()); + glUniform1i(glGetUniformLocation(program_, "fading"), GL_FALSE); + 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 ffff85a..b874844 100644 --- a/src/renderer.hpp +++ b/src/renderer.hpp @@ -18,7 +18,7 @@ namespace pdfcube { enum class Transition { none, cube_forward, cube_backward, switch_page, blinds, split, cover, uncover, fly, push, - dissolve, glitter, wipe + dissolve, glitter, wipe, fade }; inline bool page_effect(Transition transition) { @@ -26,7 +26,7 @@ inline bool page_effect(Transition transition) { transition == Transition::cover || transition == Transition::uncover || transition == Transition::fly || transition == Transition::push || transition == Transition::dissolve || transition == Transition::glitter || - transition == Transition::wipe; + transition == Transition::wipe || transition == Transition::fade; } struct FrameState { diff --git a/tests/meson.build b/tests/meson.build index b5465eb..676d515 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -75,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', 'Wipe'] + foreach effect : ['Blinds', 'Split', 'Cover', 'Uncover', 'Fly', 'Push', 'Dissolve', 'Glitter', 'Wipe', 'Fade'] axes = ['H'] angles = ['none'] motions = ['I'] diff --git a/tests/test_playback.cc b/tests/test_playback.cc index f875ff5..ce95138 100644 --- a/tests/test_playback.cc +++ b/tests/test_playback.cc @@ -10,6 +10,7 @@ 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_FADE,"fade"}, {POPPLER_PAGE_TRANSITION_GLITTER,"glitter"}, {POPPLER_PAGE_TRANSITION_WIPE,"wipe"}}; for (const auto& [kind,name] : kinds) { diff --git a/tests/test_transition_metadata.cc b/tests/test_transition_metadata.cc index ebe4017..7cb0cb8 100644 --- a/tests/test_transition_metadata.cc +++ b/tests/test_transition_metadata.cc @@ -1,6 +1,7 @@ #include "document.hpp" #include #include +#include int main(int argc, char** argv) { if (argc != 3 && argc != 5 && argc != 6 && argc != 8) return EXIT_FAILURE; @@ -9,12 +10,13 @@ int main(int argc, char** argv) { const bool split = argc >= 5 && argv[3][0] == 'S'; const bool cover = argc >= 5 && argv[3][0] == 'C'; const bool uncover = argc >= 5 && argv[3][0] == 'U'; - const bool fly = argc >= 5 && argv[3][0] == 'F'; + const bool fly = argc >= 5 && std::string(argv[3]) == "Fly"; 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 : + const auto type = argc >= 5 && std::string(argv[3]) == "Fade" ? POPPLER_PAGE_TRANSITION_FADE : + split ? POPPLER_PAGE_TRANSITION_SPLIT : cover ? POPPLER_PAGE_TRANSITION_COVER : uncover ? POPPLER_PAGE_TRANSITION_UNCOVER : fly ? POPPLER_PAGE_TRANSITION_FLY : diff --git a/tests/test_transitions.py b/tests/test_transitions.py index d90d4a8..230a382 100644 --- a/tests/test_transitions.py +++ b/tests/test_transitions.py @@ -1,4 +1,6 @@ from pathlib import Path +from io import BytesIO +from PIL import Image import shlex import subprocess import sys @@ -75,6 +77,27 @@ def capture(pdf, progress=None, *extra): if dissolve: assert capture(deck, .5, "--experimental") != middle, \ "experimental glass thickness was not rendered" + if effect == "Fade": + # Compare actual static compositions, including margins, to the + # sRGB transfer function rather than duplicating shader samples. + a, b = (Image.open(BytesIO(data)).convert("RGB") for data in (start, end)) + def linear(c): + c /= 255 + return c / 12.92 if c <= .04045 else ((c + .055) / 1.055) ** 2.4 + def encoded(c): + return 255 * (12.92 * c if c <= .0031308 else 1.055 * c ** (1/2.4) - .055) + for t in (.25, .5, .75): + image = Image.open(BytesIO(capture(deck, t))).convert("RGB") + weight = t*t*(3-2*t) + for y in range(7, image.height, 19): + for x in range(7, image.width, 19): + expected = [encoded((1-weight)*linear(c)+weight*linear(d)) + for c, d in zip(a.getpixel((x,y)), b.getpixel((x,y)))] + assert all(abs(c-d) <= 2 for c,d in zip(image.getpixel((x,y)),expected)), \ + "fade changed brightness, fit, or easing" + identical = root / "identical.pdf" + make_pdf(identical, orientation, sizes, (0, 0), effect="Fade") + assert capture(identical, .5, "--program", "4:fade") == start, "shared artwork changed during fade" if flip and angle == 0: for value in (None, 45, -90): fallback = root / "fallback.pdf" @@ -127,7 +150,7 @@ def capture(pdf, progress=None, *extra): 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: + if not dissolve and effect != "Fade": changed = root / "changed-direction.pdf" if glitter: make_pdf(changed, orientation, sizes, effect=effect, diff --git a/tests/transition_fixture.py b/tests/transition_fixture.py index fb1c586..af6c6cf 100644 --- a/tests/transition_fixture.py +++ b/tests/transition_fixture.py @@ -24,6 +24,7 @@ EXAMPLES += [(f"wipe-{name}", "Wipe", "H", "I", angle) for angle,name in ((0,"right"),(90,"up"),(180,"left"),(270,"down"))] +EXAMPLES += [("fade", "Fade", "H", "I", None)] 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/fade_review.py b/tools/fade_review.py new file mode 100644 index 0000000..50b69bf --- /dev/null +++ b/tools/fade_review.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Generate synthetic Fade review strips and animations from the headless driver. + +Run from the repository root after meson compile -C build. Requires Pillow. +All frames use explicit progress in a single headless context. +""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tests')) +from transition_fixture import make_pdf +from render_client import Frames +from PIL import Image, ImageDraw +root=Path.cwd()/'docs/transitions/fade-review' +root.mkdir(exist_ok=True) +# Each fixture is generated with valid xref offsets after changing its palette. +import transition_fixture as fixture +source=Path('tests/transition_fixture.py').read_text() +frames=Frames('build/tests/render-driver',root) +sheets=[] +for name,old,new in [('white','0.98 0.98 0.98','0.94 0.96 1'),('dark','0.015 0.025 0.06','0.1 0.025 0.015'),('color','0.12 0.32 0.65','0.65 0.23 0.12')]: + scope={'__name__':'preview'} + themed = source.replace('0.12 0.32 0.65',old).replace('0.65 0.23 0.12',new) + if name == 'white': + themed = themed.replace('1 1 1 RG', '0.15 0.15 0.15 RG').replace('1 1 1 rg', '0.15 0.15 0.15 rg') + exec(themed,scope) + pdf=root/f'{name}.pdf' + scope['make_pdf'](pdf,effect='Fade') + sheet=Image.new('RGB',(1600,210),'#202020') + animation=[] + for i in range(31): + output=root/f'{name}-frame.png' + frames.render('--bgcolor','0.04:0.04:0.04','--snapshot',output,'--snapshot-progress',str(i/30),pdf) + image=Image.open(output).convert('RGB'); image.thumbnail((640,360)); animation.append(image.copy()) + for column,t in enumerate((0,.25,.5,.75,1)): + output=root/f'{name}-{column}.png' + frames.render('--bgcolor','0.04:0.04:0.04','--snapshot',output,'--snapshot-progress',str(t),pdf) + image=Image.open(output).convert('RGB');image.thumbnail((320,180)) + sheet.paste(image,(column*320,30)) + ImageDraw.Draw(sheet).text((column*320+8,8),f'{name} {t:.0%}',fill='white') + output.unlink() + sheet.save(root/f'{name}.png'); sheets.append(sheet) + animation[0].save(root/f'{name}.gif',save_all=True,append_images=animation[1:],duration=40,loop=0) + (root/f'{name}-frame.png').unlink() +frames.close() +combined=Image.new('RGB',(1600,630)) +for i,sheet in enumerate(sheets):combined.paste(sheet,(0,i*210)) +combined.save(root/'contact-sheet.png')