From 574eac663a4f4fdc4227d3eb08e6de963658977e Mon Sep 17 00:00:00 2001 From: Jayde Ver Elst <45283009+Basjohn@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:01:30 +0200 Subject: [PATCH 01/11] Port Diffuse to lazy Quick renderer --- .../transitions/implementation_registry.py | 4 + .../transitions/implementations/diffuse.py | 146 ++++++++++++++++++ tests/test_qtquick_diffuse_transition.py | 121 +++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 rendering/quick/transitions/implementations/diffuse.py create mode 100644 tests/test_qtquick_diffuse_transition.py diff --git a/rendering/quick/transitions/implementation_registry.py b/rendering/quick/transitions/implementation_registry.py index 39d1e330..95ccfbe0 100644 --- a/rendering/quick/transitions/implementation_registry.py +++ b/rendering/quick/transitions/implementation_registry.py @@ -54,6 +54,10 @@ class QuickTransitionImplementationDescriptor: transition_id="blinds", module_name="rendering.quick.transitions.implementations.blinds", ), + QuickTransitionImplementationDescriptor( + transition_id="diffuse", + module_name="rendering.quick.transitions.implementations.diffuse", + ), ) _BY_ID = {descriptor.transition_id: descriptor for descriptor in _IMPLEMENTATIONS} diff --git a/rendering/quick/transitions/implementations/diffuse.py b/rendering/quick/transitions/implementations/diffuse.py new file mode 100644 index 00000000..99267234 --- /dev/null +++ b/rendering/quick/transitions/implementations/diffuse.py @@ -0,0 +1,146 @@ +"""Lazy Quick renderer for the canonical authored Diffuse shader.""" + +from __future__ import annotations + +from collections.abc import Mapping +import math + +from OpenGL import GL as gl + +from rendering.gl_programs.diffuse_program import diffuse_program +from rendering.quick.render.gl_resources import compile_program +from ..render_contract import ( + QUICK_TRANSITION_VERTEX_SOURCE, + QuickTransitionRenderFrame, +) + + +_MIN_BLOCK_SIZE = 1 +_SHAPE_MODES = frozenset(range(6)) + + +def _diffuse_block_size(parameters: Mapping[str, object]) -> int: + raw = parameters.get("block_size") + if isinstance(raw, bool) or not isinstance(raw, int): + raise ValueError("Diffuse requires resolved integer parameter 'block_size'") + if raw < _MIN_BLOCK_SIZE: + raise ValueError("Diffuse block_size must be positive") + return raw + + +def _diffuse_shape_mode(parameters: Mapping[str, object]) -> int: + raw = parameters.get("shape_mode") + if isinstance(raw, bool) or not isinstance(raw, int): + raise ValueError("Diffuse requires resolved integer parameter 'shape_mode'") + if raw not in _SHAPE_MODES: + raise ValueError("Diffuse shape_mode must be between 0 and 5") + return raw + + +def _diffuse_grid( + logical_size: tuple[float, float], + block_size: int, +) -> tuple[int, int]: + if len(logical_size) != 2: + raise ValueError("Diffuse logical size must contain width and height") + width, height = (float(value) for value in logical_size) + if not math.isfinite(width) or not math.isfinite(height): + raise ValueError("Diffuse logical size must be finite") + if width <= 0.0 or height <= 0.0: + raise ValueError("Diffuse logical size must be positive") + size = int(block_size) + if size < _MIN_BLOCK_SIZE: + raise ValueError("Diffuse block_size must be positive") + return ( + max(1, math.ceil(width / size)), + max(1, math.ceil(height / size)), + ) + + +class QuickDiffuseRenderer: + transition_id = "diffuse" + + def __init__(self) -> None: + self._program = 0 + self._uniforms: dict[str, int] = {} + + @property + def has_resources(self) -> bool: + return bool(self._program) + + def render(self, frame: QuickTransitionRenderFrame) -> None: + if not self._program: + self._initialize() + parameters = frame.run.request.parameter_dict() + block_size = _diffuse_block_size(parameters) + shape_mode = _diffuse_shape_mode(parameters) + cols, rows = _diffuse_grid(frame.logical_size, block_size) + uniforms = self._uniforms + + gl.glUseProgram(self._program) + gl.glUniformMatrix4fv( + uniforms["uMatrix"], 1, gl.GL_FALSE, frame.matrix_values + ) + gl.glUniform2f(uniforms["uItemSize"], *frame.logical_size) + gl.glUniform1f( + uniforms["u_progress"], float(frame.sample.eased_progress) + ) + gl.glUniform2f(uniforms["u_grid"], float(cols), float(rows)) + gl.glUniform1i(uniforms["u_shapeMode"], shape_mode) + resolution = uniforms.get("u_resolution", -1) + if resolution >= 0: + gl.glUniform2f( + resolution, + float(frame.viewport[2]), + float(frame.viewport[3]), + ) + gl.glActiveTexture(gl.GL_TEXTURE0) + gl.glBindTexture(gl.GL_TEXTURE_2D, frame.source_texture_id) + gl.glUniform1i(uniforms["uOldTex"], 0) + gl.glActiveTexture(gl.GL_TEXTURE1) + gl.glBindTexture(gl.GL_TEXTURE_2D, frame.destination_texture_id) + gl.glUniform1i(uniforms["uNewTex"], 1) + gl.glBindVertexArray(frame.quad_vao) + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4) + + def release_resources(self) -> None: + if not self._program: + return + gl.glDeleteProgram(self._program) + self._program = 0 + self._uniforms.clear() + + def _initialize(self) -> None: + program = compile_program( + QUICK_TRANSITION_VERTEX_SOURCE, + diffuse_program.fragment_source, + label="Quick Diffuse", + ) + self._program = program + try: + required = ( + "uMatrix", + "uItemSize", + "u_progress", + "u_grid", + "u_shapeMode", + "uOldTex", + "uNewTex", + ) + uniforms = { + name: int(gl.glGetUniformLocation(program, name)) + for name in (*required, "u_resolution") + } + missing = [name for name in required if uniforms[name] < 0] + if missing: + raise RuntimeError( + "Quick Diffuse uniforms are incomplete: " + ", ".join(missing) + ) + self._uniforms = uniforms + except Exception: + self.release_resources() + raise + + +def create_transition_renderer() -> QuickDiffuseRenderer: + return QuickDiffuseRenderer() diff --git a/tests/test_qtquick_diffuse_transition.py b/tests/test_qtquick_diffuse_transition.py new file mode 100644 index 00000000..08ec0705 --- /dev/null +++ b/tests/test_qtquick_diffuse_transition.py @@ -0,0 +1,121 @@ +"""Focused Phase-C contract tests for the Quick Diffuse renderer.""" + +from __future__ import annotations + +import json +import subprocess +import sys + +import pytest + +from rendering.gl_programs.diffuse_program import diffuse_program +from rendering.quick.transitions.implementations.diffuse import ( + _diffuse_block_size, + _diffuse_grid, + _diffuse_shape_mode, +) + + +def _probe(source: str) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, "-c", source], + text=True, + capture_output=True, + check=False, + timeout=20, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + return json.loads(completed.stdout) + + +def test_diffuse_resolves_lazily_without_importing_other_transition_surfaces(): + report = _probe( + """ +import json +import sys +from rendering.quick.transitions.implementation_registry import resolve_quick_transition_renderer +renderer = resolve_quick_transition_renderer( + 'diffuse', enabled_transition_ids=frozenset({'diffuse'}) +) +mods = sorted( + name for name in sys.modules + if name.startswith('rendering.quick.transitions.implementations.') +) +shader_mods = sorted( + name for name in sys.modules + if name.startswith('rendering.gl_programs.') and name.endswith('_program') +) +print(json.dumps({ + 'renderer': type(renderer).__name__, + 'mods': mods, + 'shader_mods': shader_mods, +})) +""" + ) + assert report == { + "renderer": "QuickDiffuseRenderer", + "mods": ["rendering.quick.transitions.implementations.diffuse"], + "shader_mods": ["rendering.gl_programs.diffuse_program"], + } + + +def test_diffuse_disabled_resolution_keeps_implementation_dormant(): + report = _probe( + """ +import json +import sys +from rendering.quick.transitions.implementation_registry import resolve_quick_transition_renderer +renderer = resolve_quick_transition_renderer( + 'diffuse', enabled_transition_ids=frozenset() +) +mods = sorted( + name for name in sys.modules + if name.startswith('rendering.quick.transitions.implementations.') +) +print(json.dumps({'resolved': renderer is not None, 'mods': mods})) +""" + ) + assert report == {"resolved": False, "mods": []} + + +def test_diffuse_requires_resolved_integer_block_size_and_shape_mode(): + assert _diffuse_block_size({"block_size": 15}) == 15 + assert _diffuse_shape_mode({"shape_mode": 5}) == 5 + with pytest.raises(ValueError, match="resolved integer parameter 'block_size'"): + _diffuse_block_size({}) + with pytest.raises(ValueError, match="resolved integer parameter 'shape_mode'"): + _diffuse_shape_mode({"shape_mode": "Random"}) + with pytest.raises(ValueError, match="between 0 and 5"): + _diffuse_shape_mode({"shape_mode": 6}) + + +def test_diffuse_grid_preserves_old_block_size_geometry(): + assert _diffuse_grid((1920.0, 1080.0), 50) == (39, 22) + assert _diffuse_grid((2560.0, 1440.0), 15) == (171, 96) + assert _diffuse_grid((10.0, 10.0), 50) == (1, 1) + + +def test_diffuse_reuses_exact_authored_shader_and_shape_stack(): + from rendering.quick.transitions.implementations import diffuse as quick_diffuse + + assert quick_diffuse.diffuse_program.fragment_source == diffuse_program.fragment_source + shader = diffuse_program.fragment_source + assert "u_shapeMode == 1" in shader + assert "u_shapeMode == 2" in shader + assert "u_shapeMode == 3" in shader + assert "u_shapeMode == 4" in shader + assert "u_shapeMode == 5" in shader + assert "smoothstep(0.92, 1.0, t)" in shader + assert "smoothstep(0.88, 1.0, t)" in shader + assert "smoothstep(0.96, 1.0, t)" in shader + + +def test_diffuse_quick_renderer_has_no_old_presenter_dependency(): + from pathlib import Path + + source = Path( + "rendering/quick/transitions/implementations/diffuse.py" + ).read_text(encoding="utf-8") + assert "GLCompositorWidget" not in source + assert "DisplayWidget" not in source + assert "QWidget" not in source From f20636d493199c8824cbf59ee0dd7e3480683cbf Mon Sep 17 00:00:00 2001 From: Jayde Ver Elst <45283009+Basjohn@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:02:39 +0200 Subject: [PATCH 02/11] Port Ripple to lazy Quick renderer --- .../transitions/implementation_registry.py | 4 + .../transitions/implementations/ripple.py | 121 ++++++++++++++++++ tests/test_qtquick_ripple_transition.py | 114 +++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 rendering/quick/transitions/implementations/ripple.py create mode 100644 tests/test_qtquick_ripple_transition.py diff --git a/rendering/quick/transitions/implementation_registry.py b/rendering/quick/transitions/implementation_registry.py index 95ccfbe0..6758417e 100644 --- a/rendering/quick/transitions/implementation_registry.py +++ b/rendering/quick/transitions/implementation_registry.py @@ -58,6 +58,10 @@ class QuickTransitionImplementationDescriptor: transition_id="diffuse", module_name="rendering.quick.transitions.implementations.diffuse", ), + QuickTransitionImplementationDescriptor( + transition_id="ripple", + module_name="rendering.quick.transitions.implementations.ripple", + ), ) _BY_ID = {descriptor.transition_id: descriptor for descriptor in _IMPLEMENTATIONS} diff --git a/rendering/quick/transitions/implementations/ripple.py b/rendering/quick/transitions/implementations/ripple.py new file mode 100644 index 00000000..30383cf9 --- /dev/null +++ b/rendering/quick/transitions/implementations/ripple.py @@ -0,0 +1,121 @@ +"""Lazy Quick renderer for the canonical authored Ripple/Raindrops shader.""" + +from __future__ import annotations + +from collections.abc import Mapping +import math + +from OpenGL import GL as gl + +from rendering.gl_programs.raindrops_program import raindrops_program +from rendering.quick.render.gl_resources import compile_program +from ..render_contract import ( + QUICK_TRANSITION_VERTEX_SOURCE, + QuickTransitionRenderFrame, +) + + +def _ripple_count(parameters: Mapping[str, object]) -> int: + raw = parameters.get("ripple_count") + if isinstance(raw, bool) or not isinstance(raw, int): + raise ValueError("Ripple requires resolved integer parameter 'ripple_count'") + if not 1 <= raw <= 8: + raise ValueError("Ripple ripple_count must be between 1 and 8") + return raw + + +def _ripple_seed(parameters: Mapping[str, object]) -> float: + raw = parameters.get("ripple_seed") + if isinstance(raw, bool) or not isinstance(raw, (int, float)): + raise ValueError("Ripple requires resolved numeric parameter 'ripple_seed'") + value = float(raw) + if not math.isfinite(value): + raise ValueError("Ripple ripple_seed must be finite") + return value + + +class QuickRippleRenderer: + transition_id = "ripple" + + def __init__(self) -> None: + self._program = 0 + self._uniforms: dict[str, int] = {} + + @property + def has_resources(self) -> bool: + return bool(self._program) + + def render(self, frame: QuickTransitionRenderFrame) -> None: + if not self._program: + self._initialize() + parameters = frame.run.request.parameter_dict() + count = _ripple_count(parameters) + seed = _ripple_seed(parameters) + uniforms = self._uniforms + + gl.glUseProgram(self._program) + gl.glUniformMatrix4fv( + uniforms["uMatrix"], 1, gl.GL_FALSE, frame.matrix_values + ) + gl.glUniform2f(uniforms["uItemSize"], *frame.logical_size) + gl.glUniform1f( + uniforms["u_progress"], float(frame.sample.eased_progress) + ) + gl.glUniform2f( + uniforms["u_resolution"], + float(frame.viewport[2]), + float(frame.viewport[3]), + ) + gl.glUniform1i(uniforms["u_ripple_count"], count) + gl.glUniform1f(uniforms["u_ripple_seed"], seed) + gl.glActiveTexture(gl.GL_TEXTURE0) + gl.glBindTexture(gl.GL_TEXTURE_2D, frame.source_texture_id) + gl.glUniform1i(uniforms["uOldTex"], 0) + gl.glActiveTexture(gl.GL_TEXTURE1) + gl.glBindTexture(gl.GL_TEXTURE_2D, frame.destination_texture_id) + gl.glUniform1i(uniforms["uNewTex"], 1) + gl.glBindVertexArray(frame.quad_vao) + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4) + + def release_resources(self) -> None: + if not self._program: + return + gl.glDeleteProgram(self._program) + self._program = 0 + self._uniforms.clear() + + def _initialize(self) -> None: + program = compile_program( + QUICK_TRANSITION_VERTEX_SOURCE, + raindrops_program.fragment_source, + label="Quick Ripple", + ) + self._program = program + try: + required = ( + "uMatrix", + "uItemSize", + "u_progress", + "u_resolution", + "u_ripple_count", + "u_ripple_seed", + "uOldTex", + "uNewTex", + ) + uniforms = { + name: int(gl.glGetUniformLocation(program, name)) + for name in required + } + missing = [name for name in required if uniforms[name] < 0] + if missing: + raise RuntimeError( + "Quick Ripple uniforms are incomplete: " + ", ".join(missing) + ) + self._uniforms = uniforms + except Exception: + self.release_resources() + raise + + +def create_transition_renderer() -> QuickRippleRenderer: + return QuickRippleRenderer() diff --git a/tests/test_qtquick_ripple_transition.py b/tests/test_qtquick_ripple_transition.py new file mode 100644 index 00000000..b0bbefe9 --- /dev/null +++ b/tests/test_qtquick_ripple_transition.py @@ -0,0 +1,114 @@ +"""Focused Phase-C contract tests for the Quick Ripple/Raindrops renderer.""" + +from __future__ import annotations + +import json +import subprocess +import sys + +import pytest + +from rendering.gl_programs.raindrops_program import raindrops_program +from rendering.quick.transitions.implementations.ripple import ( + _ripple_count, + _ripple_seed, +) + + +def _probe(source: str) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, "-c", source], + text=True, + capture_output=True, + check=False, + timeout=20, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + return json.loads(completed.stdout) + + +def test_ripple_resolves_lazily_and_only_imports_its_surface(): + report = _probe( + """ +import json +import sys +from rendering.quick.transitions.implementation_registry import resolve_quick_transition_renderer +renderer = resolve_quick_transition_renderer( + 'ripple', enabled_transition_ids=frozenset({'ripple'}) +) +mods = sorted( + name for name in sys.modules + if name.startswith('rendering.quick.transitions.implementations.') +) +shader_mods = sorted( + name for name in sys.modules + if name.startswith('rendering.gl_programs.') and name.endswith('_program') +) +print(json.dumps({ + 'renderer': type(renderer).__name__, + 'mods': mods, + 'shader_mods': shader_mods, +})) +""" + ) + assert report == { + "renderer": "QuickRippleRenderer", + "mods": ["rendering.quick.transitions.implementations.ripple"], + "shader_mods": ["rendering.gl_programs.raindrops_program"], + } + + +def test_ripple_disabled_resolution_keeps_surface_dormant(): + report = _probe( + """ +import json +import sys +from rendering.quick.transitions.implementation_registry import resolve_quick_transition_renderer +renderer = resolve_quick_transition_renderer( + 'ripple', enabled_transition_ids=frozenset() +) +mods = sorted( + name for name in sys.modules + if name.startswith('rendering.quick.transitions.implementations.') +) +print(json.dumps({'resolved': renderer is not None, 'mods': mods})) +""" + ) + assert report == {"resolved": False, "mods": []} + + +def test_ripple_requires_resolved_count_and_seed(): + assert _ripple_count({"ripple_count": 3}) == 3 + assert _ripple_seed({"ripple_seed": 123.5}) == pytest.approx(123.5) + with pytest.raises(ValueError, match="resolved integer parameter 'ripple_count'"): + _ripple_count({"ripple_count": 3.0}) + with pytest.raises(ValueError, match="between 1 and 8"): + _ripple_count({"ripple_count": 9}) + with pytest.raises(ValueError, match="resolved numeric parameter 'ripple_seed'"): + _ripple_seed({}) + with pytest.raises(ValueError, match="must be finite"): + _ripple_seed({"ripple_seed": float("nan")}) + + +def test_ripple_reuses_exact_authored_shader_and_multi_source_behavior(): + from rendering.quick.transitions.implementations import ripple as quick_ripple + + assert quick_ripple.raindrops_program.fragment_source == raindrops_program.fragment_source + shader = raindrops_program.fragment_source + assert "if (i == 0)" in shader + assert "center = vec2(0.5, 0.5)" in shader + assert "u_ripple_seed" in shader + assert "totalWave += wave" in shader + assert "bestRingMask" in shader + assert "smoothstep(0.78, 0.95, t)" in shader + + +def test_ripple_quick_renderer_has_no_legacy_presenter_fallback(): + from pathlib import Path + + source = Path( + "rendering/quick/transitions/implementations/ripple.py" + ).read_text(encoding="utf-8") + assert "GLCompositorWidget" not in source + assert "DisplayWidget" not in source + assert "QWidget" not in source From 1dc6287057e70ae777717287f68452ffd08354d1 Mon Sep 17 00:00:00 2001 From: Jayde Ver Elst <45283009+Basjohn@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:04:02 +0200 Subject: [PATCH 03/11] Port Crumble to lazy Quick renderer --- .../transitions/implementation_registry.py | 16 +- .../transitions/implementations/crumble.py | 145 ++++++++++++++++++ tests/test_qtquick_crumble_transition.py | 127 +++++++++++++++ 3 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 rendering/quick/transitions/implementations/crumble.py create mode 100644 tests/test_qtquick_crumble_transition.py diff --git a/rendering/quick/transitions/implementation_registry.py b/rendering/quick/transitions/implementation_registry.py index 6758417e..4c9dc84b 100644 --- a/rendering/quick/transitions/implementation_registry.py +++ b/rendering/quick/transitions/implementation_registry.py @@ -22,9 +22,7 @@ class QuickTransitionImplementationDescriptor: _IMPLEMENTATIONS = ( QuickTransitionImplementationDescriptor( transition_id="crossfade", - module_name=( - "rendering.quick.transitions.implementations.crossfade" - ), + module_name="rendering.quick.transitions.implementations.crossfade", ), QuickTransitionImplementationDescriptor( transition_id="slide", @@ -40,15 +38,11 @@ class QuickTransitionImplementationDescriptor: ), QuickTransitionImplementationDescriptor( transition_id="block_flip", - module_name=( - "rendering.quick.transitions.implementations.block_flip" - ), + module_name="rendering.quick.transitions.implementations.block_flip", ), QuickTransitionImplementationDescriptor( transition_id="block_spins", - module_name=( - "rendering.quick.transitions.implementations.block_spins" - ), + module_name="rendering.quick.transitions.implementations.block_spins", ), QuickTransitionImplementationDescriptor( transition_id="blinds", @@ -62,6 +56,10 @@ class QuickTransitionImplementationDescriptor: transition_id="ripple", module_name="rendering.quick.transitions.implementations.ripple", ), + QuickTransitionImplementationDescriptor( + transition_id="crumble", + module_name="rendering.quick.transitions.implementations.crumble", + ), ) _BY_ID = {descriptor.transition_id: descriptor for descriptor in _IMPLEMENTATIONS} diff --git a/rendering/quick/transitions/implementations/crumble.py b/rendering/quick/transitions/implementations/crumble.py new file mode 100644 index 00000000..7cc8723e --- /dev/null +++ b/rendering/quick/transitions/implementations/crumble.py @@ -0,0 +1,145 @@ +"""Lazy Quick renderer for the canonical authored Crumble shader.""" + +from __future__ import annotations + +from collections.abc import Mapping +import math + +from OpenGL import GL as gl + +from rendering.gl_programs.crumble_program import crumble_program +from rendering.quick.render.gl_resources import compile_program +from ..render_contract import ( + QUICK_TRANSITION_VERTEX_SOURCE, + QuickTransitionRenderFrame, +) + + +def _number(parameters: Mapping[str, object], name: str) -> float: + raw = parameters.get(name) + if isinstance(raw, bool) or not isinstance(raw, (int, float)): + raise ValueError(f"Crumble requires resolved numeric parameter {name!r}") + value = float(raw) + if not math.isfinite(value): + raise ValueError(f"Crumble {name} must be finite") + return value + + +def _crumble_parameters(parameters: Mapping[str, object]) -> tuple[float, int, float, bool, float]: + seed = _number(parameters, "seed") + + piece_raw = parameters.get("piece_count") + if isinstance(piece_raw, bool) or not isinstance(piece_raw, int): + raise ValueError("Crumble requires resolved integer parameter 'piece_count'") + if piece_raw < 4: + raise ValueError("Crumble piece_count must be at least 4") + + complexity = _number(parameters, "crack_complexity") + if not 0.5 <= complexity <= 2.0: + raise ValueError("Crumble crack_complexity must be between 0.5 and 2.0") + + mosaic = parameters.get("mosaic_mode") + if not isinstance(mosaic, bool): + raise ValueError("Crumble requires resolved boolean parameter 'mosaic_mode'") + + weight_mode = _number(parameters, "weight_mode") + if weight_mode not in {0.0, 1.0, 2.0, 3.0, 4.0}: + raise ValueError("Crumble weight_mode must be one of 0, 1, 2, 3, 4") + + return seed, piece_raw, complexity, mosaic, weight_mode + + +class QuickCrumbleRenderer: + transition_id = "crumble" + + def __init__(self) -> None: + self._program = 0 + self._uniforms: dict[str, int] = {} + + @property + def has_resources(self) -> bool: + return bool(self._program) + + def render(self, frame: QuickTransitionRenderFrame) -> None: + if not self._program: + self._initialize() + seed, pieces, complexity, mosaic, weight_mode = _crumble_parameters( + frame.run.request.parameter_dict() + ) + uniforms = self._uniforms + + gl.glUseProgram(self._program) + gl.glUniformMatrix4fv( + uniforms["uMatrix"], 1, gl.GL_FALSE, frame.matrix_values + ) + gl.glUniform2f(uniforms["uItemSize"], *frame.logical_size) + gl.glUniform1f( + uniforms["u_progress"], float(frame.sample.eased_progress) + ) + resolution = uniforms.get("u_resolution", -1) + if resolution >= 0: + gl.glUniform2f( + resolution, + float(frame.viewport[2]), + float(frame.viewport[3]), + ) + gl.glUniform1f(uniforms["u_seed"], seed) + gl.glUniform1f(uniforms["u_piece_count"], float(pieces)) + gl.glUniform1f(uniforms["u_crack_complexity"], complexity) + mosaic_location = uniforms.get("u_mosaic_mode", -1) + if mosaic_location >= 0: + gl.glUniform1f(mosaic_location, 1.0 if mosaic else 0.0) + gl.glUniform1f(uniforms["u_weight_mode"], weight_mode) + gl.glActiveTexture(gl.GL_TEXTURE0) + gl.glBindTexture(gl.GL_TEXTURE_2D, frame.source_texture_id) + gl.glUniform1i(uniforms["uOldTex"], 0) + gl.glActiveTexture(gl.GL_TEXTURE1) + gl.glBindTexture(gl.GL_TEXTURE_2D, frame.destination_texture_id) + gl.glUniform1i(uniforms["uNewTex"], 1) + gl.glBindVertexArray(frame.quad_vao) + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4) + + def release_resources(self) -> None: + if not self._program: + return + gl.glDeleteProgram(self._program) + self._program = 0 + self._uniforms.clear() + + def _initialize(self) -> None: + program = compile_program( + QUICK_TRANSITION_VERTEX_SOURCE, + crumble_program.fragment_source, + label="Quick Crumble", + ) + self._program = program + try: + required = ( + "uMatrix", + "uItemSize", + "u_progress", + "u_seed", + "u_piece_count", + "u_crack_complexity", + "u_weight_mode", + "uOldTex", + "uNewTex", + ) + optional = ("u_resolution", "u_mosaic_mode") + uniforms = { + name: int(gl.glGetUniformLocation(program, name)) + for name in (*required, *optional) + } + missing = [name for name in required if uniforms[name] < 0] + if missing: + raise RuntimeError( + "Quick Crumble uniforms are incomplete: " + ", ".join(missing) + ) + self._uniforms = uniforms + except Exception: + self.release_resources() + raise + + +def create_transition_renderer() -> QuickCrumbleRenderer: + return QuickCrumbleRenderer() diff --git a/tests/test_qtquick_crumble_transition.py b/tests/test_qtquick_crumble_transition.py new file mode 100644 index 00000000..039e3153 --- /dev/null +++ b/tests/test_qtquick_crumble_transition.py @@ -0,0 +1,127 @@ +"""Focused Phase-C contract tests for the Quick Crumble renderer.""" + +from __future__ import annotations + +import json +import subprocess +import sys + +import pytest + +from rendering.gl_programs.crumble_program import crumble_program +from rendering.quick.transitions.implementations.crumble import _crumble_parameters + + +def _probe(source: str) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, "-c", source], + text=True, + capture_output=True, + check=False, + timeout=20, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + return json.loads(completed.stdout) + + +def _params(**updates): + values = { + "seed": 123.25, + "piece_count": 14, + "crack_complexity": 1.0, + "mosaic_mode": False, + "weight_mode": 3.0, + } + values.update(updates) + return values + + +def test_crumble_resolves_lazily_and_only_imports_its_surface(): + report = _probe( + """ +import json +import sys +from rendering.quick.transitions.implementation_registry import resolve_quick_transition_renderer +renderer = resolve_quick_transition_renderer( + 'crumble', enabled_transition_ids=frozenset({'crumble'}) +) +mods = sorted( + name for name in sys.modules + if name.startswith('rendering.quick.transitions.implementations.') +) +shader_mods = sorted( + name for name in sys.modules + if name.startswith('rendering.gl_programs.') and name.endswith('_program') +) +print(json.dumps({ + 'renderer': type(renderer).__name__, + 'mods': mods, + 'shader_mods': shader_mods, +})) +""" + ) + assert report == { + "renderer": "QuickCrumbleRenderer", + "mods": ["rendering.quick.transitions.implementations.crumble"], + "shader_mods": ["rendering.gl_programs.crumble_program"], + } + + +def test_crumble_disabled_resolution_keeps_surface_dormant(): + report = _probe( + """ +import json +import sys +from rendering.quick.transitions.implementation_registry import resolve_quick_transition_renderer +renderer = resolve_quick_transition_renderer( + 'crumble', enabled_transition_ids=frozenset() +) +mods = sorted( + name for name in sys.modules + if name.startswith('rendering.quick.transitions.implementations.') +) +print(json.dumps({'resolved': renderer is not None, 'mods': mods})) +""" + ) + assert report == {"resolved": False, "mods": []} + + +def test_crumble_requires_fully_resolved_authored_parameters(): + assert _crumble_parameters(_params()) == (123.25, 14, 1.0, False, 3.0) + with pytest.raises(ValueError, match="resolved numeric parameter 'seed'"): + _crumble_parameters(_params(seed=None)) + with pytest.raises(ValueError, match="resolved integer parameter 'piece_count'"): + _crumble_parameters(_params(piece_count=14.0)) + with pytest.raises(ValueError, match="at least 4"): + _crumble_parameters(_params(piece_count=3)) + with pytest.raises(ValueError, match="between 0.5 and 2.0"): + _crumble_parameters(_params(crack_complexity=2.1)) + with pytest.raises(ValueError, match="resolved boolean parameter 'mosaic_mode'"): + _crumble_parameters(_params(mosaic_mode=0)) + with pytest.raises(ValueError, match="one of 0, 1, 2, 3, 4"): + _crumble_parameters(_params(weight_mode=4.5)) + + +def test_crumble_reuses_exact_authored_shader_and_physics_stack(): + from rendering.quick.transitions.implementations import crumble as quick_crumble + + assert quick_crumble.crumble_program.fragment_source == crumble_program.fragment_source + shader = crumble_program.fragment_source + assert "vec4 voronoi" in shader + assert "getPieceTransform" in shader + assert "pieceFall * pieceFall * pieceFall" in shader + assert "float rotAngle" in shader + assert "if (t < 0.05)" in shader + assert "if (t >= 0.995)" in shader + assert "u_weight_mode" in shader + + +def test_crumble_quick_renderer_has_no_legacy_presenter_dependency(): + from pathlib import Path + + source = Path( + "rendering/quick/transitions/implementations/crumble.py" + ).read_text(encoding="utf-8") + assert "GLCompositorWidget" not in source + assert "DisplayWidget" not in source + assert "QWidget" not in source From f517957d7a94c6a5204e5fc889c16261d3dffb88 Mon Sep 17 00:00:00 2001 From: Jayde Ver Elst <45283009+Basjohn@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:05:21 +0200 Subject: [PATCH 04/11] Port Particle to lazy Quick renderer --- .../transitions/implementation_registry.py | 4 + .../transitions/implementations/particle.py | 213 ++++++++++++++++++ tests/test_qtquick_particle_transition.py | 152 +++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 rendering/quick/transitions/implementations/particle.py create mode 100644 tests/test_qtquick_particle_transition.py diff --git a/rendering/quick/transitions/implementation_registry.py b/rendering/quick/transitions/implementation_registry.py index 4c9dc84b..491cf91f 100644 --- a/rendering/quick/transitions/implementation_registry.py +++ b/rendering/quick/transitions/implementation_registry.py @@ -60,6 +60,10 @@ class QuickTransitionImplementationDescriptor: transition_id="crumble", module_name="rendering.quick.transitions.implementations.crumble", ), + QuickTransitionImplementationDescriptor( + transition_id="particle", + module_name="rendering.quick.transitions.implementations.particle", + ), ) _BY_ID = {descriptor.transition_id: descriptor for descriptor in _IMPLEMENTATIONS} diff --git a/rendering/quick/transitions/implementations/particle.py b/rendering/quick/transitions/implementations/particle.py new file mode 100644 index 00000000..e2737720 --- /dev/null +++ b/rendering/quick/transitions/implementations/particle.py @@ -0,0 +1,213 @@ +"""Lazy Quick renderer for the full authored Particle transition shader.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import math + +from OpenGL import GL as gl + +from rendering.gl_programs.particle_program import particle_program +from rendering.quick.render.gl_resources import compile_program +from ..render_contract import ( + QUICK_TRANSITION_VERTEX_SOURCE, + QuickTransitionRenderFrame, +) + + +@dataclass(frozen=True, slots=True) +class _ParticleParameters: + seed: float + mode: int + direction: int + particle_radius: float + overlap: float + trail_length: float + trail_strength: float + swirl_strength: float + swirl_turns: float + use_3d_shading: bool + texture_mapping: bool + wobble: bool + gloss_size: float + light_direction: int + swirl_order: int + + +def _number(parameters: Mapping[str, object], name: str) -> float: + raw = parameters.get(name) + if isinstance(raw, bool) or not isinstance(raw, (int, float)): + raise ValueError(f"Particle requires resolved numeric parameter {name!r}") + value = float(raw) + if not math.isfinite(value): + raise ValueError(f"Particle {name} must be finite") + return value + + +def _integer(parameters: Mapping[str, object], name: str) -> int: + raw = parameters.get(name) + if isinstance(raw, bool) or not isinstance(raw, int): + raise ValueError(f"Particle requires resolved integer parameter {name!r}") + return raw + + +def _boolean(parameters: Mapping[str, object], name: str) -> bool: + raw = parameters.get(name) + if not isinstance(raw, bool): + raise ValueError(f"Particle requires resolved boolean parameter {name!r}") + return raw + + +def _particle_parameters(parameters: Mapping[str, object]) -> _ParticleParameters: + seed = _number(parameters, "seed") + mode = _integer(parameters, "mode") + if mode not in {0, 1, 2}: + raise ValueError("Particle mode must already be resolved to 0, 1, or 2") + direction = _integer(parameters, "direction") + if not 0 <= direction <= 9: + raise ValueError("Particle direction must be between 0 and 9") + + radius = _number(parameters, "particle_radius") + if radius < 8.0: + raise ValueError("Particle particle_radius must be at least 8") + overlap = _number(parameters, "overlap") + if overlap < 0.0 or overlap >= radius * 2.0: + raise ValueError("Particle overlap must be non-negative and smaller than particle diameter") + + trail_length = _number(parameters, "trail_length") + trail_strength = _number(parameters, "trail_strength") + if not 0.0 <= trail_length <= 1.0: + raise ValueError("Particle trail_length must be between 0 and 1") + if not 0.0 <= trail_strength <= 1.0: + raise ValueError("Particle trail_strength must be between 0 and 1") + + swirl_strength = _number(parameters, "swirl_strength") + if swirl_strength < 0.0: + raise ValueError("Particle swirl_strength must be non-negative") + swirl_turns = _number(parameters, "swirl_turns") + if swirl_turns < 0.5: + raise ValueError("Particle swirl_turns must be at least 0.5") + + gloss_size = _number(parameters, "gloss_size") + if not 16.0 <= gloss_size <= 128.0: + raise ValueError("Particle gloss_size must be between 16 and 128") + light_direction = _integer(parameters, "light_direction") + if not 0 <= light_direction <= 4: + raise ValueError("Particle light_direction must be between 0 and 4") + swirl_order = _integer(parameters, "swirl_order") + if not 0 <= swirl_order <= 2: + raise ValueError("Particle swirl_order must be between 0 and 2") + + return _ParticleParameters( + seed=seed, + mode=mode, + direction=direction, + particle_radius=radius, + overlap=overlap, + trail_length=trail_length, + trail_strength=trail_strength, + swirl_strength=swirl_strength, + swirl_turns=swirl_turns, + use_3d_shading=_boolean(parameters, "use_3d_shading"), + texture_mapping=_boolean(parameters, "texture_mapping"), + wobble=_boolean(parameters, "wobble"), + gloss_size=gloss_size, + light_direction=light_direction, + swirl_order=swirl_order, + ) + + +class QuickParticleRenderer: + transition_id = "particle" + + def __init__(self) -> None: + self._program = 0 + self._uniforms: dict[str, int] = {} + + @property + def has_resources(self) -> bool: + return bool(self._program) + + def render(self, frame: QuickTransitionRenderFrame) -> None: + if not self._program: + self._initialize() + params = _particle_parameters(frame.run.request.parameter_dict()) + uniforms = self._uniforms + + gl.glUseProgram(self._program) + gl.glUniformMatrix4fv( + uniforms["uMatrix"], 1, gl.GL_FALSE, frame.matrix_values + ) + gl.glUniform2f(uniforms["uItemSize"], *frame.logical_size) + gl.glUniform1f(uniforms["u_progress"], float(frame.sample.eased_progress)) + gl.glUniform2f( + uniforms["u_resolution"], + float(frame.viewport[2]), + float(frame.viewport[3]), + ) + gl.glUniform1f(uniforms["u_seed"], params.seed) + gl.glUniform1f(uniforms["u_mode"], float(params.mode)) + gl.glUniform1f(uniforms["u_direction"], float(params.direction)) + gl.glUniform1f(uniforms["u_particle_radius"], params.particle_radius) + gl.glUniform1f(uniforms["u_overlap"], params.overlap) + gl.glUniform1f(uniforms["u_trail_length"], params.trail_length) + gl.glUniform1f(uniforms["u_trail_strength"], params.trail_strength) + gl.glUniform1f(uniforms["u_swirl_strength"], params.swirl_strength) + gl.glUniform1f(uniforms["u_swirl_turns"], params.swirl_turns) + gl.glUniform1f(uniforms["u_use_3d"], 1.0 if params.use_3d_shading else 0.0) + gl.glUniform1f(uniforms["u_texture_map"], 1.0 if params.texture_mapping else 0.0) + gl.glUniform1f(uniforms["u_wobble"], 1.0 if params.wobble else 0.0) + gl.glUniform1f(uniforms["u_gloss_size"], params.gloss_size) + gl.glUniform1f(uniforms["u_light_dir"], float(params.light_direction)) + gl.glUniform1f(uniforms["u_swirl_order"], float(params.swirl_order)) + + gl.glActiveTexture(gl.GL_TEXTURE0) + gl.glBindTexture(gl.GL_TEXTURE_2D, frame.source_texture_id) + gl.glUniform1i(uniforms["uOldTex"], 0) + gl.glActiveTexture(gl.GL_TEXTURE1) + gl.glBindTexture(gl.GL_TEXTURE_2D, frame.destination_texture_id) + gl.glUniform1i(uniforms["uNewTex"], 1) + gl.glBindVertexArray(frame.quad_vao) + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4) + + def release_resources(self) -> None: + if not self._program: + return + gl.glDeleteProgram(self._program) + self._program = 0 + self._uniforms.clear() + + def _initialize(self) -> None: + program = compile_program( + QUICK_TRANSITION_VERTEX_SOURCE, + particle_program.fragment_source, + label="Quick Particle", + ) + self._program = program + try: + required = ( + "uMatrix", "uItemSize", "uOldTex", "uNewTex", "u_progress", + "u_resolution", "u_seed", "u_mode", "u_direction", + "u_particle_radius", "u_overlap", "u_trail_length", + "u_trail_strength", "u_swirl_strength", "u_swirl_turns", + "u_use_3d", "u_texture_map", "u_wobble", "u_gloss_size", + "u_light_dir", "u_swirl_order", + ) + uniforms = { + name: int(gl.glGetUniformLocation(program, name)) + for name in required + } + missing = [name for name in required if uniforms[name] < 0] + if missing: + raise RuntimeError( + "Quick Particle uniforms are incomplete: " + ", ".join(missing) + ) + self._uniforms = uniforms + except Exception: + self.release_resources() + raise + + +def create_transition_renderer() -> QuickParticleRenderer: + return QuickParticleRenderer() diff --git a/tests/test_qtquick_particle_transition.py b/tests/test_qtquick_particle_transition.py new file mode 100644 index 00000000..59c456a0 --- /dev/null +++ b/tests/test_qtquick_particle_transition.py @@ -0,0 +1,152 @@ +"""Focused Phase-C contract tests for the Quick Particle renderer.""" + +from __future__ import annotations + +import json +import subprocess +import sys + +import pytest + +from rendering.gl_programs.particle_program import particle_program +from rendering.quick.transitions.implementations.particle import _particle_parameters + + +def _probe(source: str) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, "-c", source], + text=True, + capture_output=True, + check=False, + timeout=20, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + return json.loads(completed.stdout) + + +def _params(**updates): + values = { + "seed": 12.5, + "mode": 1, + "direction": 8, + "particle_radius": 10.0, + "overlap": 4.0, + "trail_length": 0.15, + "trail_strength": 0.6, + "swirl_strength": 1.0, + "swirl_turns": 3.0, + "use_3d_shading": True, + "texture_mapping": True, + "wobble": True, + "gloss_size": 72.0, + "light_direction": 1, + "swirl_order": 0, + } + values.update(updates) + return values + + +def test_particle_resolves_lazily_and_only_imports_its_surface(): + report = _probe( + """ +import json +import sys +from rendering.quick.transitions.implementation_registry import resolve_quick_transition_renderer +renderer = resolve_quick_transition_renderer( + 'particle', enabled_transition_ids=frozenset({'particle'}) +) +mods = sorted( + name for name in sys.modules + if name.startswith('rendering.quick.transitions.implementations.') +) +shader_mods = sorted( + name for name in sys.modules + if name.startswith('rendering.gl_programs.') and name.endswith('_program') +) +print(json.dumps({ + 'renderer': type(renderer).__name__, + 'mods': mods, + 'shader_mods': shader_mods, +})) +""" + ) + assert report == { + "renderer": "QuickParticleRenderer", + "mods": ["rendering.quick.transitions.implementations.particle"], + "shader_mods": ["rendering.gl_programs.particle_program"], + } + + +def test_particle_disabled_resolution_keeps_surface_dormant(): + report = _probe( + """ +import json +import sys +from rendering.quick.transitions.implementation_registry import resolve_quick_transition_renderer +renderer = resolve_quick_transition_renderer( + 'particle', enabled_transition_ids=frozenset() +) +mods = sorted( + name for name in sys.modules + if name.startswith('rendering.quick.transitions.implementations.') +) +print(json.dumps({'resolved': renderer is not None, 'mods': mods})) +""" + ) + assert report == {"resolved": False, "mods": []} + + +def test_particle_requires_random_mode_to_be_resolved_before_rendering(): + params = _particle_parameters(_params()) + assert params.mode == 1 + assert params.direction == 8 + with pytest.raises(ValueError, match="resolved to 0, 1, or 2"): + _particle_parameters(_params(mode=3)) + + +def test_particle_validates_full_authored_parameter_surface(): + params = _particle_parameters(_params()) + assert params.particle_radius == pytest.approx(10.0) + assert params.trail_strength == pytest.approx(0.6) + assert params.use_3d_shading is True + assert params.texture_mapping is True + assert params.wobble is True + assert params.gloss_size == pytest.approx(72.0) + assert params.light_direction == 1 + with pytest.raises(ValueError, match="smaller than particle diameter"): + _particle_parameters(_params(overlap=20.0)) + with pytest.raises(ValueError, match="between 0 and 1"): + _particle_parameters(_params(trail_length=1.1)) + with pytest.raises(ValueError, match="between 16 and 128"): + _particle_parameters(_params(gloss_size=150.0)) + with pytest.raises(ValueError, match="resolved boolean parameter 'wobble'"): + _particle_parameters(_params(wobble=1)) + + +def test_particle_reuses_full_authored_shader_instead_of_flat_reveal(): + from rendering.quick.transitions.implementations import particle as quick_particle + + assert quick_particle.particle_program.fragment_source == particle_program.fragment_source + shader = particle_program.fragment_source + assert "getSpawnDirection" in shader + assert "getSwirlOrderKey" in shader + assert "getConvergeOrderKey" in shader + assert "shade3DBall" in shader + assert "u_trail_length" in shader + assert "u_wobble" in shader + assert "u_gloss_size" in shader + assert "u_texture_map" in shader + assert "if (t <= 0.0)" in shader + assert "if (t >= FINAL_BLEND_END)" in shader + assert "smoothstep(FINAL_BLEND_START, FINAL_BLEND_END, t)" in shader + + +def test_particle_quick_renderer_has_no_legacy_presenter_dependency(): + from pathlib import Path + + source = Path( + "rendering/quick/transitions/implementations/particle.py" + ).read_text(encoding="utf-8") + assert "GLCompositorWidget" not in source + assert "DisplayWidget" not in source + assert "QWidget" not in source From 3f16500176b294c6fe8b1abaa7f37ddfe79952fc Mon Sep 17 00:00:00 2001 From: Jayde Ver Elst <45283009+Basjohn@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:09:00 +0200 Subject: [PATCH 05/11] Port Burn to lazy Quick renderer --- .../transitions/implementation_registry.py | 4 + .../quick/transitions/implementations/burn.py | 194 ++++++++++++++++++ tests/test_qtquick_burn_transition.py | 171 +++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 rendering/quick/transitions/implementations/burn.py create mode 100644 tests/test_qtquick_burn_transition.py diff --git a/rendering/quick/transitions/implementation_registry.py b/rendering/quick/transitions/implementation_registry.py index 491cf91f..c59438e7 100644 --- a/rendering/quick/transitions/implementation_registry.py +++ b/rendering/quick/transitions/implementation_registry.py @@ -64,6 +64,10 @@ class QuickTransitionImplementationDescriptor: transition_id="particle", module_name="rendering.quick.transitions.implementations.particle", ), + QuickTransitionImplementationDescriptor( + transition_id="burn", + module_name="rendering.quick.transitions.implementations.burn", + ), ) _BY_ID = {descriptor.transition_id: descriptor for descriptor in _IMPLEMENTATIONS} diff --git a/rendering/quick/transitions/implementations/burn.py b/rendering/quick/transitions/implementations/burn.py new file mode 100644 index 00000000..e6aa342c --- /dev/null +++ b/rendering/quick/transitions/implementations/burn.py @@ -0,0 +1,194 @@ +"""Lazy Quick renderer for the full authored Burn transition shader.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import math + +from OpenGL import GL as gl + +from rendering.gl_programs.burn_program import burn_program +from rendering.quick.render.gl_resources import compile_program +from ..render_contract import ( + QUICK_TRANSITION_VERTEX_SOURCE, + QuickTransitionRenderFrame, +) + + +@dataclass(frozen=True, slots=True) +class _BurnParameters: + direction: int + jaggedness: float + glow_intensity: float + glow_color: tuple[float, float, float, float] + char_width: float + smoke_enabled: bool + smoke_density: float + ash_enabled: bool + ash_density: float + seed: float + + +def _number(parameters: Mapping[str, object], name: str) -> float: + raw = parameters.get(name) + if isinstance(raw, bool) or not isinstance(raw, (int, float)): + raise ValueError(f"Burn requires resolved numeric parameter {name!r}") + value = float(raw) + if not math.isfinite(value): + raise ValueError(f"Burn {name} must be finite") + return value + + +def _boolean(parameters: Mapping[str, object], name: str) -> bool: + raw = parameters.get(name) + if not isinstance(raw, bool): + raise ValueError(f"Burn requires resolved boolean parameter {name!r}") + return raw + + +def _glow_color(parameters: Mapping[str, object]) -> tuple[float, float, float, float]: + raw = parameters.get("glow_color") + if not isinstance(raw, tuple) or len(raw) != 4: + raise ValueError("Burn requires resolved normalized RGBA tuple parameter 'glow_color'") + channels: list[float] = [] + for channel in raw: + if isinstance(channel, bool) or not isinstance(channel, (int, float)): + raise ValueError("Burn glow_color channels must be numeric") + value = float(channel) + if not math.isfinite(value) or not 0.0 <= value <= 1.0: + raise ValueError("Burn glow_color channels must be finite and between 0 and 1") + channels.append(value) + return tuple(channels) # type: ignore[return-value] + + +def _burn_parameters(parameters: Mapping[str, object]) -> _BurnParameters: + direction_raw = parameters.get("direction") + if isinstance(direction_raw, bool) or not isinstance(direction_raw, int): + raise ValueError("Burn requires resolved integer parameter 'direction'") + if not 0 <= direction_raw <= 5: + raise ValueError("Burn direction must be between 0 and 5") + + jaggedness = _number(parameters, "jaggedness") + glow_intensity = _number(parameters, "glow_intensity") + char_width = _number(parameters, "char_width") + smoke_density = _number(parameters, "smoke_density") + ash_density = _number(parameters, "ash_density") + seed = _number(parameters, "seed") + + if not 0.0 <= jaggedness <= 1.0: + raise ValueError("Burn jaggedness must be between 0 and 1") + if not 0.0 <= glow_intensity <= 1.0: + raise ValueError("Burn glow_intensity must be between 0 and 1") + if not 0.1 <= char_width <= 1.0: + raise ValueError("Burn char_width must be between 0.1 and 1") + if not 0.0 <= smoke_density <= 1.0: + raise ValueError("Burn smoke_density must be between 0 and 1") + if not 0.0 <= ash_density <= 1.0: + raise ValueError("Burn ash_density must be between 0 and 1") + + return _BurnParameters( + direction=direction_raw, + jaggedness=jaggedness, + glow_intensity=glow_intensity, + glow_color=_glow_color(parameters), + char_width=char_width, + smoke_enabled=_boolean(parameters, "smoke_enabled"), + smoke_density=smoke_density, + ash_enabled=_boolean(parameters, "ash_enabled"), + ash_density=ash_density, + seed=seed, + ) + + +def _burn_effect_time_seconds(frame: QuickTransitionRenderFrame) -> float: + return ( + float(frame.sample.linear_progress) + * float(frame.run.request.duration_ms) + / 1000.0 + ) + + +class QuickBurnRenderer: + transition_id = "burn" + + def __init__(self) -> None: + self._program = 0 + self._uniforms: dict[str, int] = {} + + @property + def has_resources(self) -> bool: + return bool(self._program) + + def render(self, frame: QuickTransitionRenderFrame) -> None: + if not self._program: + self._initialize() + params = _burn_parameters(frame.run.request.parameter_dict()) + uniforms = self._uniforms + + gl.glUseProgram(self._program) + gl.glUniformMatrix4fv( + uniforms["uMatrix"], 1, gl.GL_FALSE, frame.matrix_values + ) + gl.glUniform2f(uniforms["uItemSize"], *frame.logical_size) + gl.glUniform1f(uniforms["u_progress"], float(frame.sample.eased_progress)) + gl.glUniform1i(uniforms["u_direction"], params.direction) + gl.glUniform1f(uniforms["u_jaggedness"], params.jaggedness) + gl.glUniform1f(uniforms["u_glow_intensity"], params.glow_intensity) + gl.glUniform4f(uniforms["u_glow_color"], *params.glow_color) + gl.glUniform1f(uniforms["u_char_width"], params.char_width) + gl.glUniform1i(uniforms["u_smoke_enabled"], 1 if params.smoke_enabled else 0) + gl.glUniform1f(uniforms["u_smoke_density"], params.smoke_density) + gl.glUniform1i(uniforms["u_ash_enabled"], 1 if params.ash_enabled else 0) + gl.glUniform1f(uniforms["u_ash_density"], params.ash_density) + gl.glUniform1f(uniforms["u_time"], _burn_effect_time_seconds(frame)) + gl.glUniform1f(uniforms["u_seed"], params.seed) + + gl.glActiveTexture(gl.GL_TEXTURE0) + gl.glBindTexture(gl.GL_TEXTURE_2D, frame.source_texture_id) + gl.glUniform1i(uniforms["uOldTex"], 0) + gl.glActiveTexture(gl.GL_TEXTURE1) + gl.glBindTexture(gl.GL_TEXTURE_2D, frame.destination_texture_id) + gl.glUniform1i(uniforms["uNewTex"], 1) + gl.glBindVertexArray(frame.quad_vao) + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4) + + def release_resources(self) -> None: + if not self._program: + return + gl.glDeleteProgram(self._program) + self._program = 0 + self._uniforms.clear() + + def _initialize(self) -> None: + program = compile_program( + QUICK_TRANSITION_VERTEX_SOURCE, + burn_program.fragment_source, + label="Quick Burn", + ) + self._program = program + try: + required = ( + "uMatrix", "uItemSize", "u_progress", "uOldTex", "uNewTex", + "u_direction", "u_jaggedness", "u_glow_intensity", + "u_glow_color", "u_char_width", "u_smoke_enabled", + "u_smoke_density", "u_ash_enabled", "u_ash_density", + "u_time", "u_seed", + ) + uniforms = { + name: int(gl.glGetUniformLocation(program, name)) + for name in required + } + missing = [name for name in required if uniforms[name] < 0] + if missing: + raise RuntimeError( + "Quick Burn uniforms are incomplete: " + ", ".join(missing) + ) + self._uniforms = uniforms + except Exception: + self.release_resources() + raise + + +def create_transition_renderer() -> QuickBurnRenderer: + return QuickBurnRenderer() diff --git a/tests/test_qtquick_burn_transition.py b/tests/test_qtquick_burn_transition.py new file mode 100644 index 00000000..7fb870ac --- /dev/null +++ b/tests/test_qtquick_burn_transition.py @@ -0,0 +1,171 @@ +"""Focused Phase-C preservation tests for the Quick Burn renderer.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +import subprocess +import sys + +import pytest + +from rendering.gl_programs.burn_program import burn_program +from rendering.quick.transitions.implementations.burn import ( + _burn_effect_time_seconds, + _burn_parameters, +) + + +def _probe(source: str) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, "-c", source], + text=True, + capture_output=True, + check=False, + timeout=20, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + return json.loads(completed.stdout) + + +def _params(**updates): + values = { + "direction": 4, + "jaggedness": 0.55, + "glow_intensity": 0.72, + "glow_color": (1.0, 140.0 / 255.0, 30.0 / 255.0, 1.0), + "char_width": 0.5, + "smoke_enabled": True, + "smoke_density": 0.5, + "ash_enabled": True, + "ash_density": 0.5, + "seed": 321.25, + } + values.update(updates) + return values + + +def test_burn_resolves_lazily_and_only_imports_its_surface(): + report = _probe( + """ +import json +import sys +from rendering.quick.transitions.implementation_registry import resolve_quick_transition_renderer +renderer = resolve_quick_transition_renderer( + 'burn', enabled_transition_ids=frozenset({'burn'}) +) +mods = sorted( + name for name in sys.modules + if name.startswith('rendering.quick.transitions.implementations.') +) +shader_mods = sorted( + name for name in sys.modules + if name.startswith('rendering.gl_programs.') and name.endswith('_program') +) +print(json.dumps({ + 'renderer': type(renderer).__name__, + 'mods': mods, + 'shader_mods': shader_mods, +})) +""" + ) + assert report == { + "renderer": "QuickBurnRenderer", + "mods": ["rendering.quick.transitions.implementations.burn"], + "shader_mods": [ + "rendering.gl_programs.base_program", + "rendering.gl_programs.burn_program", + ], + } + + +def test_burn_disabled_resolution_keeps_surface_dormant(): + report = _probe( + """ +import json +import sys +from rendering.quick.transitions.implementation_registry import resolve_quick_transition_renderer +renderer = resolve_quick_transition_renderer( + 'burn', enabled_transition_ids=frozenset() +) +mods = sorted( + name for name in sys.modules + if name.startswith('rendering.quick.transitions.implementations.') +) +print(json.dumps({'resolved': renderer is not None, 'mods': mods})) +""" + ) + assert report == {"resolved": False, "mods": []} + + +@pytest.mark.parametrize("direction", range(6)) +def test_burn_preserves_all_six_authored_directions(direction): + assert _burn_parameters(_params(direction=direction)).direction == direction + + +def test_burn_rejects_unresolved_or_invalid_direction(): + with pytest.raises(ValueError, match="resolved integer parameter 'direction'"): + _burn_parameters(_params(direction="Random")) + with pytest.raises(ValueError, match="between 0 and 5"): + _burn_parameters(_params(direction=6)) + + +def test_burn_requires_normalized_user_glow_color_and_full_effect_controls(): + params = _burn_parameters(_params()) + assert params.glow_color == pytest.approx((1.0, 140.0 / 255.0, 30.0 / 255.0, 1.0)) + assert params.smoke_enabled is True + assert params.ash_enabled is True + with pytest.raises(ValueError, match="normalized RGBA tuple"): + _burn_parameters(_params(glow_color=(1.0, 0.5, 0.1))) + with pytest.raises(ValueError, match="between 0 and 1"): + _burn_parameters(_params(glow_color=(255.0, 140.0, 30.0, 255.0))) + with pytest.raises(ValueError, match="resolved boolean parameter 'smoke_enabled'"): + _burn_parameters(_params(smoke_enabled=1)) + with pytest.raises(ValueError, match="char_width must be between 0.1 and 1"): + _burn_parameters(_params(char_width=0.05)) + + +def test_burn_effect_time_is_derived_from_the_authored_run_clock(): + frame = SimpleNamespace( + sample=SimpleNamespace(linear_progress=0.25), + run=SimpleNamespace(request=SimpleNamespace(duration_ms=2400)), + ) + assert _burn_effect_time_seconds(frame) == pytest.approx(0.6) + + +def test_burn_reuses_exact_authored_shader_and_complete_visual_stack(): + from rendering.quick.transitions.implementations import burn as quick_burn + + assert quick_burn.burn_program.fragment_source == burn_program.fragment_source + shader = burn_program.fragment_source + for needle in ( + "if (t <= 0.0)", + "if (t >= 1.0)", + "float ignition = 0.05", + "fbm4", + "warped_fbm", + "distort_offset", + "white-hot burn line", + "Char zone", + "smoulder", + "Sparks / embers", + "Falling ash", + "Smoke wisps", + "tail_fade", + "u_glow_color", + "u_seed", + "u_time", + ): + assert needle in shader + + +def test_burn_quick_renderer_has_no_wall_clock_or_legacy_presenter_dependency(): + from pathlib import Path + + source = Path( + "rendering/quick/transitions/implementations/burn.py" + ).read_text(encoding="utf-8") + assert "time.monotonic" not in source + assert "GLCompositorWidget" not in source + assert "DisplayWidget" not in source + assert "QWidget" not in source From 6ba3e4b043344a7bfc504199e246f6c0616f065c Mon Sep 17 00:00:00 2001 From: Jayde Ver Elst <45283009+Basjohn@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:22:44 +0200 Subject: [PATCH 06/11] Complete Phase C closure plumbing --- .../quick/transitions/parameter_resolution.py | 280 ++++++++++++++++ tests/test_qtquick_crumble_transition.py | 5 +- tests/test_qtquick_diffuse_transition.py | 5 +- tests/test_qtquick_particle_transition.py | 5 +- tests/test_qtquick_phase_c_effect_smoke.py | 65 ++++ tests/test_qtquick_ripple_transition.py | 5 +- ...test_qtquick_transition_implementations.py | 68 ++-- ...qtquick_transition_parameter_resolution.py | 185 +++++++++++ tools/qtquick_phase_c_effect_smoke.py | 301 ++++++++++++++++++ 9 files changed, 878 insertions(+), 41 deletions(-) create mode 100644 rendering/quick/transitions/parameter_resolution.py create mode 100644 tests/test_qtquick_phase_c_effect_smoke.py create mode 100644 tests/test_qtquick_transition_parameter_resolution.py create mode 100644 tools/qtquick_phase_c_effect_smoke.py diff --git a/rendering/quick/transitions/parameter_resolution.py b/rendering/quick/transitions/parameter_resolution.py new file mode 100644 index 00000000..58a5de22 --- /dev/null +++ b/rendering/quick/transitions/parameter_resolution.py @@ -0,0 +1,280 @@ +"""Pure Settings-to-request resolution for parameterized Phase-C Quick effects. + +The render thread accepts only explicit immutable values. This module keeps +Settings spelling, legacy fall-through behaviour, random choice, clamps, and +colour normalization on the GUI/runtime side before TransitionRequest +construction. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import math +import random +from typing import Protocol + +from .state import TransitionParameters, TransitionValue, freeze_transition_parameters + + +class _RandomSource(Protocol): + def random(self) -> float: ... + def randint(self, a: int, b: int) -> int: ... + def choice(self, seq): ... + + +@dataclass(frozen=True, slots=True) +class ResolvedPhaseCInputs: + direction: TransitionValue + parameters: TransitionParameters + + def parameter_dict(self) -> dict[str, TransitionValue]: + return dict(self.parameters) + + +def _mapping(settings: Mapping[str, object], name: str) -> Mapping[str, object]: + value = settings.get(name, {}) + return value if isinstance(value, Mapping) else {} + + +def _number(value: object, default: float) -> float: + try: + result = float(value) + except (TypeError, ValueError): + result = float(default) + return result if math.isfinite(result) else float(default) + + +def _integer(value: object, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return int(default) + + +def _bool(value: object, default: bool) -> bool: + return value if isinstance(value, bool) else bool(default) + + +def _finish(direction: TransitionValue, parameters: Mapping[str, object]) -> ResolvedPhaseCInputs: + return ResolvedPhaseCInputs( + direction=direction, + parameters=freeze_transition_parameters(parameters), + ) + + +def _resolve_blinds(settings: Mapping[str, object], rng: _RandomSource) -> ResolvedPhaseCInputs: + cfg = _mapping(settings, "blinds") + raw_direction = str(cfg.get("direction", "Horizontal") or "Horizontal") + if raw_direction == "Random": + raw_direction = rng.choice(("Horizontal", "Vertical", "Diagonal")) + direction = { + "Horizontal": "horizontal", + "Vertical": "vertical", + "Diagonal": "diagonal", + }.get(raw_direction, "horizontal") + # Preserve TransitionFactory's UI-scale -> shader-scale conversion. + ui_feather = _number(cfg.get("feather", 2), 2.0) + feather = max(0.001, min(0.5, (ui_feather / 25.0) * 0.5)) + return _finish(direction, {"feather": feather}) + + +def _resolve_diffuse(settings: Mapping[str, object], _rng: _RandomSource) -> ResolvedPhaseCInputs: + cfg = _mapping(settings, "diffuse") + block_size = max(1, _integer(cfg.get("block_size", 50), 50)) + shape = str(cfg.get("shape", "Rectangle") or "Rectangle").strip().lower() + shape_mode = { + "rectangle": 0, + "membrane": 1, + "lines": 2, + "diamonds": 3, + "amorph": 4, + "random": 5, + }.get(shape, 0) + return _finish(None, {"block_size": block_size, "shape_mode": shape_mode}) + + +def _resolve_ripple(settings: Mapping[str, object], rng: _RandomSource) -> ResolvedPhaseCInputs: + cfg = _mapping(settings, "ripple") + count = max(1, min(8, _integer(cfg.get("ripple_count", 3), 3))) + return _finish( + None, + { + "ripple_count": count, + "ripple_seed": float(rng.random()) * 1000.0, + }, + ) + + +def _resolve_crumble(settings: Mapping[str, object], rng: _RandomSource) -> ResolvedPhaseCInputs: + cfg = _mapping(settings, "crumble") + piece_count = max(4, _integer(cfg.get("piece_count", 14), 14)) + complexity = max( + 0.5, + min(2.0, _number(cfg.get("crack_complexity", 1.0), 1.0)), + ) + weighting = str(cfg.get("weighting", "Random Choice") or "Random Choice") + # This deliberately preserves the CURRENT old factory semantics. The + # Settings UI also exposes "Bias Old Image" / "Bias New Image", but the + # old factory does not recognize either spelling and therefore falls back + # to 0.0. H0 may deliberately repair/rename that UX; Phase C must not + # silently change the authored presentation while migrating it. + weight_mode = { + "Top Weighted": 0.0, + "Bottom Weighted": 1.0, + "Random Weighted": 2.0, + "Random Choice": 3.0, + "Age Weighted": 4.0, + "Bias Old Image": 0.0, + "Bias New Image": 0.0, + }.get(weighting, 0.0) + return _finish( + None, + { + "seed": float(rng.random()) * 1000.0, + "piece_count": piece_count, + "crack_complexity": complexity, + "mosaic_mode": False, + "weight_mode": weight_mode, + }, + ) + + +def _resolve_particle(settings: Mapping[str, object], rng: _RandomSource) -> ResolvedPhaseCInputs: + cfg = _mapping(settings, "particle") + mode_text = str(cfg.get("mode", "Converge") or "Converge") + mode = { + "Directional": 0, + "Swirl": 1, + "Converge": 2, + "Random": 3, + }.get(mode_text, 0) + + direction_text = str(cfg.get("direction", "Left to Right") or "Left to Right") + # "Random" is intentionally 0: that is the current old factory's + # fall-through for the Settings UI spelling. Legacy "Random Direction" + # and "Random Placement" retain the shader's explicit 8/9 meanings. + direction = { + "Left to Right": 0, + "Right to Left": 1, + "Top to Bottom": 2, + "Bottom to Top": 3, + "Top-Left to Bottom-Right": 4, + "Top-Right to Bottom-Left": 5, + "Bottom-Left to Top-Right": 6, + "Bottom-Right to Top-Left": 7, + "Random Direction": 8, + "Random Placement": 9, + "Random": 0, + }.get(direction_text, 0) + + swirl_order = max(0, min(2, _integer(cfg.get("swirl_order", 0), 0))) + if mode == 3: + mode = int(rng.choice((0, 1, 2))) + if mode == 0: + direction = int(rng.randint(0, 9)) + else: + swirl_order = int(rng.randint(0, 2)) + + radius = max(8.0, _number(cfg.get("particle_radius", 10.0), 10.0)) + overlap = max(0.0, _number(cfg.get("overlap", 4.0), 4.0)) + if overlap >= radius * 2.0: + raise ValueError("resolved Particle overlap must be smaller than particle diameter") + + # Preserve the old runtime's numerical index contract. Current Settings + # labels for light direction / swirl order do not match the shader comments + # one-for-one; changing those meanings belongs to the later settings epoch. + light_direction = max(0, min(4, _integer(cfg.get("light_direction", 0), 0))) + gloss_size = max(16.0, min(128.0, _number(cfg.get("gloss_size", 72.0), 72.0))) + + return _finish( + None, + { + "seed": float(rng.random()) * 1000.0, + "mode": mode, + "direction": direction, + "particle_radius": radius, + "overlap": overlap, + "trail_length": max(0.0, min(1.0, _number(cfg.get("trail_length", 0.15), 0.15))), + "trail_strength": max(0.0, min(1.0, _number(cfg.get("trail_strength", 0.6), 0.6))), + "swirl_strength": max(0.0, _number(cfg.get("swirl_strength", 1.0), 1.0)), + "swirl_turns": max(0.5, _number(cfg.get("swirl_turns", 2.0), 2.0)), + "use_3d_shading": _bool(cfg.get("use_3d_shading", True), True), + "texture_mapping": _bool(cfg.get("texture_mapping", True), True), + "wobble": _bool(cfg.get("wobble", False), False), + "gloss_size": gloss_size, + "light_direction": light_direction, + "swirl_order": swirl_order, + }, + ) + + +def _normalized_glow_color(value: object) -> tuple[float, float, float, float]: + raw = value if isinstance(value, (tuple, list)) and len(value) == 4 else (255, 140, 30, 255) + channels = tuple(_number(channel, 0.0) for channel in raw) + if any(channel < 0.0 for channel in channels): + raise ValueError("Burn glow_color channels must be non-negative") + if max(channels) <= 1.0: + return channels + if max(channels) > 255.0: + raise ValueError("Burn glow_color channels must be <= 255") + return tuple(channel / 255.0 for channel in channels) + + +def _resolve_burn(settings: Mapping[str, object], rng: _RandomSource) -> ResolvedPhaseCInputs: + cfg = _mapping(settings, "burn") + direction_text = str(cfg.get("direction", "Random") or "Random") + direction_map = { + "Left to Right": 0, + "Right to Left": 1, + "Top to Bottom": 2, + "Bottom to Top": 3, + "Diagonal TL-BR": 4, + "Diagonal TR-BL": 5, + } + direction = ( + int(rng.randint(0, 5)) + if direction_text == "Random" + else direction_map.get(direction_text, 0) + ) + return _finish( + None, + { + "direction": direction, + "jaggedness": max(0.0, min(1.0, _number(cfg.get("jaggedness", 0.5), 0.5))), + "glow_intensity": max(0.0, min(1.0, _number(cfg.get("glow_intensity", 0.7), 0.7))), + "glow_color": _normalized_glow_color(cfg.get("glow_color", (255, 140, 30, 255))), + "char_width": max(0.1, min(1.0, _number(cfg.get("char_width", 0.5), 0.5))), + "smoke_enabled": _bool(cfg.get("smoke_enabled", True), True), + "smoke_density": max(0.0, min(1.0, _number(cfg.get("smoke_density", 0.5), 0.5))), + "ash_enabled": _bool(cfg.get("ash_enabled", True), True), + "ash_density": max(0.0, min(1.0, _number(cfg.get("ash_density", 0.5), 0.5))), + "seed": float(rng.random()) * 1000.0, + }, + ) + + +_RESOLVERS = { + "blinds": _resolve_blinds, + "diffuse": _resolve_diffuse, + "ripple": _resolve_ripple, + "crumble": _resolve_crumble, + "particle": _resolve_particle, + "burn": _resolve_burn, +} + + +def resolve_parameterized_phase_c_inputs( + transition_id: str, + transition_settings: Mapping[str, object], + *, + random_source: _RandomSource | None = None, +) -> ResolvedPhaseCInputs: + """Resolve one parameterized Phase-C effect before request admission.""" + + stable_id = str(transition_id).strip().lower() + resolver = _RESOLVERS.get(stable_id) + if resolver is None: + raise ValueError(f"no parameterized Phase-C resolver for {transition_id!r}") + rng = random_source if random_source is not None else random + return resolver(transition_settings, rng) diff --git a/tests/test_qtquick_crumble_transition.py b/tests/test_qtquick_crumble_transition.py index 039e3153..b0007463 100644 --- a/tests/test_qtquick_crumble_transition.py +++ b/tests/test_qtquick_crumble_transition.py @@ -63,7 +63,10 @@ def test_crumble_resolves_lazily_and_only_imports_its_surface(): assert report == { "renderer": "QuickCrumbleRenderer", "mods": ["rendering.quick.transitions.implementations.crumble"], - "shader_mods": ["rendering.gl_programs.crumble_program"], + "shader_mods": [ + "rendering.gl_programs.base_program", + "rendering.gl_programs.crumble_program", + ], } diff --git a/tests/test_qtquick_diffuse_transition.py b/tests/test_qtquick_diffuse_transition.py index 08ec0705..94fab8a2 100644 --- a/tests/test_qtquick_diffuse_transition.py +++ b/tests/test_qtquick_diffuse_transition.py @@ -55,7 +55,10 @@ def test_diffuse_resolves_lazily_without_importing_other_transition_surfaces(): assert report == { "renderer": "QuickDiffuseRenderer", "mods": ["rendering.quick.transitions.implementations.diffuse"], - "shader_mods": ["rendering.gl_programs.diffuse_program"], + "shader_mods": [ + "rendering.gl_programs.base_program", + "rendering.gl_programs.diffuse_program", + ], } diff --git a/tests/test_qtquick_particle_transition.py b/tests/test_qtquick_particle_transition.py index 59c456a0..4cffb4f6 100644 --- a/tests/test_qtquick_particle_transition.py +++ b/tests/test_qtquick_particle_transition.py @@ -73,7 +73,10 @@ def test_particle_resolves_lazily_and_only_imports_its_surface(): assert report == { "renderer": "QuickParticleRenderer", "mods": ["rendering.quick.transitions.implementations.particle"], - "shader_mods": ["rendering.gl_programs.particle_program"], + "shader_mods": [ + "rendering.gl_programs.base_program", + "rendering.gl_programs.particle_program", + ], } diff --git a/tests/test_qtquick_phase_c_effect_smoke.py b/tests/test_qtquick_phase_c_effect_smoke.py new file mode 100644 index 00000000..2aa87f88 --- /dev/null +++ b/tests/test_qtquick_phase_c_effect_smoke.py @@ -0,0 +1,65 @@ +"""Static gates for the focused Phase-C real-GL effect smoke wrapper.""" + +from __future__ import annotations + +import pytest + +from tools import qtquick_phase_c_effect_smoke as phase_c_smoke + + +def test_phase_c_smoke_exposes_all_remaining_parameterized_effects(): + assert tuple(phase_c_smoke._CASES) == ( + "diffuse", + "ripple", + "crumble", + "particle", + "burn", + ) + assert len(phase_c_smoke._CASES["diffuse"]) == 6 + assert len(phase_c_smoke._CASES["burn"]) == 6 + assert phase_c_smoke._CASES["particle"] == ( + "directional", + "swirl", + "converge", + ) + + +def test_phase_c_smoke_parameters_are_fully_resolved_and_deterministic(): + assert phase_c_smoke._parameters("diffuse", "membrane") == { + "block_size": 48, + "shape_mode": 1, + } + assert phase_c_smoke._parameters("ripple", "count8") == { + "ripple_count": 8, + "ripple_seed": 123.5, + } + assert phase_c_smoke._parameters("crumble", "age-weighted")["weight_mode"] == 4.0 + particle = phase_c_smoke._parameters("particle", "converge") + assert particle["mode"] == 2 + assert particle["use_3d_shading"] is True + assert particle["texture_mapping"] is True + assert particle["wobble"] is True + burn = phase_c_smoke._parameters("burn", "diag-tr-bl") + assert burn["direction"] == 5 + assert burn["smoke_enabled"] is True + assert burn["ash_enabled"] is True + assert burn["glow_color"] == pytest.approx((1.0, 140.0 / 255.0, 30.0 / 255.0, 1.0)) + + +def test_phase_c_smoke_domain_classifier_distinguishes_fixture_ownership(): + assert phase_c_smoke._domain("#ff0c2078") == "source" + assert phase_c_smoke._domain("#ff78180c") == "destination" + assert phase_c_smoke._domain("#ff101010") == "dark-effect" + + +def test_phase_c_smoke_install_is_scoped_to_requested_effect_and_case(): + phase_c_smoke._install_contract("burn", "diag-tl-br") + assert "burn" in phase_c_smoke.smoke._TRANSITION_IDS + assert phase_c_smoke.smoke._TRANSITION_SMOKE_PARAMETERS["burn"]["direction"] == 4 + assert phase_c_smoke.smoke._TRANSITION_SMOKE_DURATIONS_MS["burn"] == 900 + assert phase_c_smoke.smoke._TRANSITION_MIDPOINT_ORACLES["burn"] is phase_c_smoke._matches_burn + + +def test_phase_c_smoke_rejects_unknown_effect_parameters(): + with pytest.raises(ValueError, match="unknown Phase-C smoke effect"): + phase_c_smoke._parameters("unknown", "default") diff --git a/tests/test_qtquick_ripple_transition.py b/tests/test_qtquick_ripple_transition.py index b0bbefe9..32deb3e5 100644 --- a/tests/test_qtquick_ripple_transition.py +++ b/tests/test_qtquick_ripple_transition.py @@ -54,7 +54,10 @@ def test_ripple_resolves_lazily_and_only_imports_its_surface(): assert report == { "renderer": "QuickRippleRenderer", "mods": ["rendering.quick.transitions.implementations.ripple"], - "shader_mods": ["rendering.gl_programs.raindrops_program"], + "shader_mods": [ + "rendering.gl_programs.base_program", + "rendering.gl_programs.raindrops_program", + ], } diff --git a/tests/test_qtquick_transition_implementations.py b/tests/test_qtquick_transition_implementations.py index f9c44f55..bcb0e894 100644 --- a/tests/test_qtquick_transition_implementations.py +++ b/tests/test_qtquick_transition_implementations.py @@ -39,6 +39,20 @@ ROOT = Path(__file__).resolve().parents[1] +_ALL_QUICK_TRANSITION_IDS = ( + "crossfade", + "slide", + "wipe", + "warp_dissolve", + "block_flip", + "block_spins", + "blinds", + "diffuse", + "ripple", + "crumble", + "particle", + "burn", +) def _probe(source: str) -> dict[str, object]: @@ -81,15 +95,7 @@ def test_quick_implementation_catalog_does_not_import_renderer_modules(): ) assert report == { - "ids": [ - "crossfade", - "slide", - "wipe", - "warp_dissolve", - "block_flip", - "block_spins", - "blinds", - ], + "ids": list(_ALL_QUICK_TRANSITION_IDS), "loaded": [], "shader_modules": [], } @@ -134,45 +140,33 @@ def test_disabled_resolution_keeps_transition_implementations_dormant(): resolve_quick_transition_renderer, ) -renderer = resolve_quick_transition_renderer( +transition_ids = ( "crossfade", - enabled_transition_ids=frozenset(), -) -slide = resolve_quick_transition_renderer( "slide", - enabled_transition_ids=frozenset(), -) -wipe = resolve_quick_transition_renderer( "wipe", - enabled_transition_ids=frozenset(), -) -warp = resolve_quick_transition_renderer( "warp_dissolve", - enabled_transition_ids=frozenset(), -) -block_flip = resolve_quick_transition_renderer( "block_flip", - enabled_transition_ids=frozenset(), -) -block_spins = resolve_quick_transition_renderer( "block_spins", - enabled_transition_ids=frozenset(), -) + "blinds", + "diffuse", + "ripple", + "crumble", + "particle", + "burn", +) +resolved = [ + resolve_quick_transition_renderer( + transition_id, + enabled_transition_ids=frozenset(), + ) + for transition_id in transition_ids +] loaded = sorted( name for name in sys.modules if name.startswith("rendering.quick.transitions.implementations.") ) print(json.dumps({ - "resolved": any( - item is not None for item in ( - renderer, - slide, - wipe, - warp, - block_flip, - block_spins, - ) - ), + "resolved": any(item is not None for item in resolved), "loaded": loaded, })) """ diff --git a/tests/test_qtquick_transition_parameter_resolution.py b/tests/test_qtquick_transition_parameter_resolution.py new file mode 100644 index 00000000..bcd7213b --- /dev/null +++ b/tests/test_qtquick_transition_parameter_resolution.py @@ -0,0 +1,185 @@ +"""Phase-C request-boundary resolution tests for parameterized effects.""" + +from __future__ import annotations + +import pytest + +from rendering.quick.transitions.parameter_resolution import ( + resolve_parameterized_phase_c_inputs, +) + + +class _Rng: + def __init__(self) -> None: + self.random_values = iter((0.125, 0.25, 0.375, 0.5, 0.625)) + self.choice_values: list[object] = [] + self.randint_values: list[int] = [] + + def random(self) -> float: + return next(self.random_values) + + def choice(self, seq): + if self.choice_values: + return self.choice_values.pop(0) + return seq[0] + + def randint(self, a: int, b: int) -> int: + if self.randint_values: + return self.randint_values.pop(0) + return a + + +def test_blinds_resolves_random_direction_and_ui_feather_before_request(): + rng = _Rng() + rng.choice_values = ["Diagonal"] + resolved = resolve_parameterized_phase_c_inputs( + "blinds", + {"blinds": {"direction": "Random", "feather": 2}}, + random_source=rng, + ) + assert resolved.direction == "diagonal" + assert resolved.parameter_dict() == {"feather": pytest.approx(0.04)} + + +def test_diffuse_resolves_shape_name_and_block_size(): + resolved = resolve_parameterized_phase_c_inputs( + "diffuse", + {"diffuse": {"block_size": 15, "shape": "Membrane"}}, + random_source=_Rng(), + ) + assert resolved.direction is None + assert resolved.parameter_dict() == {"block_size": 15, "shape_mode": 1} + + +def test_ripple_generates_seed_once_before_render_ownership(): + resolved = resolve_parameterized_phase_c_inputs( + "ripple", + {"ripple": {"ripple_count": 3}}, + random_source=_Rng(), + ) + assert resolved.parameter_dict() == { + "ripple_count": 3, + "ripple_seed": pytest.approx(125.0), + } + + +def test_crumble_preserves_current_factory_weighting_fallthroughs(): + for label, expected in ( + ("Random Choice", 3.0), + ("Bias Old Image", 0.0), + ("Bias New Image", 0.0), + ("Top Weighted", 0.0), + ("Bottom Weighted", 1.0), + ("Random Weighted", 2.0), + ("Age Weighted", 4.0), + ): + resolved = resolve_parameterized_phase_c_inputs( + "crumble", + {"crumble": {"piece_count": 16, "crack_complexity": 1.0, "weighting": label}}, + random_source=_Rng(), + ) + params = resolved.parameter_dict() + assert params["weight_mode"] == expected + assert params["mosaic_mode"] is False + + +def test_particle_preserves_current_numeric_semantics_for_ui_indices(): + resolved = resolve_parameterized_phase_c_inputs( + "particle", + { + "particle": { + "mode": "Swirl", + "direction": "Random", + "particle_radius": 9.0, + "overlap": 4.0, + "trail_length": 0.15, + "trail_strength": 0.6, + "swirl_strength": 1.0, + "swirl_turns": 3.0, + "use_3d_shading": True, + "texture_mapping": True, + "wobble": True, + "gloss_size": 72.0, + "light_direction": 4, + "swirl_order": 2, + } + }, + random_source=_Rng(), + ) + params = resolved.parameter_dict() + assert params["mode"] == 1 + # Current old factory does not recognize the UI spelling "Random" and + # therefore feeds directional value 0. Preserve that until H0 deliberately fixes it. + assert params["direction"] == 0 + assert params["light_direction"] == 4 + assert params["swirl_order"] == 2 + assert params["particle_radius"] == pytest.approx(9.0) + + +def test_particle_random_mode_is_fully_resolved_before_request(): + rng = _Rng() + rng.choice_values = [0] + rng.randint_values = [9] + resolved = resolve_parameterized_phase_c_inputs( + "particle", + {"particle": {"mode": "Random", "direction": "Left to Right"}}, + random_source=rng, + ) + params = resolved.parameter_dict() + assert params["mode"] == 0 + assert params["direction"] == 9 + assert params["seed"] == pytest.approx(125.0) + + +def test_particle_resolution_rejects_grid_destroying_overlap(): + with pytest.raises(ValueError, match="smaller than particle diameter"): + resolve_parameterized_phase_c_inputs( + "particle", + {"particle": {"particle_radius": 8.0, "overlap": 16.0}}, + random_source=_Rng(), + ) + + +def test_burn_normalizes_user_rgba_and_resolves_random_direction_and_seed(): + rng = _Rng() + rng.randint_values = [5] + resolved = resolve_parameterized_phase_c_inputs( + "burn", + { + "burn": { + "direction": "Random", + "jaggedness": 1.0, + "glow_intensity": 1.0, + "glow_color": [255, 162, 0, 255], + "char_width": 0.1, + "smoke_enabled": True, + "smoke_density": 0.8, + "ash_enabled": True, + "ash_density": 0.8, + } + }, + random_source=rng, + ) + params = resolved.parameter_dict() + assert params["direction"] == 5 + assert params["glow_color"] == pytest.approx((1.0, 162.0 / 255.0, 0.0, 1.0)) + assert params["seed"] == pytest.approx(125.0) + + +def test_resolution_outputs_are_deep_frozen_for_transition_request_admission(): + resolved = resolve_parameterized_phase_c_inputs( + "burn", + {"burn": {"glow_color": [255, 140, 30, 255]}}, + random_source=_Rng(), + ) + assert isinstance(resolved.parameters, tuple) + assert isinstance(resolved.parameter_dict()["glow_color"], tuple) + + +def test_unknown_or_unparameterized_effect_is_not_silently_defaulted(): + with pytest.raises(ValueError, match="no parameterized Phase-C resolver"): + resolve_parameterized_phase_c_inputs( + "crossfade", + {}, + random_source=_Rng(), + ) diff --git a/tools/qtquick_phase_c_effect_smoke.py b/tools/qtquick_phase_c_effect_smoke.py new file mode 100644 index 00000000..ba92cf6b --- /dev/null +++ b/tools/qtquick_phase_c_effect_smoke.py @@ -0,0 +1,301 @@ +"""Focused real-GL wrapper for the remaining parameterized Phase-C effects. + +The preserved qtquick_render_node_smoke lifecycle harness stays generic. This +wrapper admits Diffuse, Ripple, Crumble, Particle, and Burn with deterministic +resolved request parameters plus effect-shaped midpoint pixel gates. +""" + +from __future__ import annotations + +import argparse +import sys + +try: + from tools import qtquick_render_node_smoke as smoke +except ModuleNotFoundError: # direct ``python tools/qtquick_phase_c_effect_smoke.py`` + import qtquick_render_node_smoke as smoke + + +_CASES = { + "diffuse": ( + "rectangle", + "membrane", + "lines", + "diamonds", + "amorph", + "random", + ), + "ripple": ("count1", "count3", "count8"), + "crumble": ( + "top", + "bottom", + "random-weighted", + "random-choice", + "age-weighted", + ), + "particle": ("directional", "swirl", "converge"), + "burn": ( + "left-to-right", + "right-to-left", + "top-to-bottom", + "bottom-to-top", + "diag-tl-br", + "diag-tr-bl", + ), +} + + +def _parameters(effect: str, case: str) -> dict[str, object]: + if effect == "diffuse": + shape_mode = _CASES["diffuse"].index(case) + return {"block_size": 48, "shape_mode": shape_mode} + if effect == "ripple": + count = {"count1": 1, "count3": 3, "count8": 8}[case] + return {"ripple_count": count, "ripple_seed": 123.5} + if effect == "crumble": + weight_mode = { + "top": 0.0, + "bottom": 1.0, + "random-weighted": 2.0, + "random-choice": 3.0, + "age-weighted": 4.0, + }[case] + return { + "seed": 123.25, + "piece_count": 14, + "crack_complexity": 1.0, + "mosaic_mode": False, + "weight_mode": weight_mode, + } + if effect == "particle": + mode = {"directional": 0, "swirl": 1, "converge": 2}[case] + return { + "seed": 12.5, + "mode": mode, + "direction": 0, + "particle_radius": 16.0, + "overlap": 4.0, + "trail_length": 0.15, + "trail_strength": 0.6, + "swirl_strength": 1.0, + "swirl_turns": 3.0, + "use_3d_shading": True, + "texture_mapping": True, + "wobble": True, + "gloss_size": 72.0, + "light_direction": 1, + "swirl_order": 0, + } + if effect == "burn": + direction = _CASES["burn"].index(case) + return { + "direction": direction, + "jaggedness": 0.55, + "glow_intensity": 0.72, + "glow_color": (1.0, 140.0 / 255.0, 30.0 / 255.0, 1.0), + "char_width": 0.5, + "smoke_enabled": True, + "smoke_density": 0.5, + "ash_enabled": True, + "ash_density": 0.5, + "seed": 321.25, + } + raise ValueError(f"unknown Phase-C smoke effect: {effect}") + + +def _domain(color: object) -> str: + _alpha, red, green, blue = smoke._argb_components(color) + score = red - blue + if score <= -12: + return "source" + if score >= 12: + return "destination" + if max(red, green, blue) <= 30: + return "dark-effect" + return "mixed-effect" + + +def _domain_counts(colors: object) -> dict[str, int] | None: + if not isinstance(colors, (tuple, list)): + return None + counts = { + "source": 0, + "destination": 0, + "dark-effect": 0, + "mixed-effect": 0, + } + for color in colors: + counts[_domain(color)] += 1 + return counts + + +def _basic_effect_midpoint( + source: object, + destination: object, + midpoint: object, + progress: float, + _case: object, +) -> bool: + if not all(isinstance(value, (tuple, list)) for value in (source, destination, midpoint)): + return False + if ( + len(source) != len(smoke._TRANSITION_SAMPLE_COORDINATES) + or len(destination) != len(smoke._TRANSITION_SAMPLE_COORDINATES) + or len(midpoint) != len(smoke._TRANSITION_SAMPLE_COORDINATES) + or not 0.20 <= float(progress) <= 0.80 + ): + return False + if {_domain(color) for color in source} != {"source"}: + return False + if {_domain(color) for color in destination} != {"destination"}: + return False + counts = _domain_counts(midpoint) + if counts is None: + return False + return bool( + counts["source"] >= 2 + and counts["destination"] >= 2 + and tuple(midpoint) != tuple(source) + and tuple(midpoint) != tuple(destination) + ) + + +def _matches_diffuse( + source: object, + destination: object, + midpoint: object, + progress: float, + case: object, +) -> bool: + if not _basic_effect_midpoint(source, destination, midpoint, progress, case): + return False + # Diffuse must be spatial rather than a uniform crossfade. A 5x5 sample + # set at midpoint should contain at least three distinct ownership/effect domains. + return len({_domain(color) for color in midpoint}) >= 2 + + +def _matches_ripple( + source: object, + destination: object, + midpoint: object, + progress: float, + case: object, +) -> bool: + if not _basic_effect_midpoint(source, destination, midpoint, progress, case): + return False + # The first authored ripple is always centred. At a controlled midpoint + # the centre should be in the arriving-image domain while outer samples + # still prove old-image ownership. + centre = len(smoke._TRANSITION_SAMPLE_COORDINATES) // 2 + return _domain(midpoint[centre]) == "destination" + + +def _matches_crumble( + source: object, + destination: object, + midpoint: object, + progress: float, + case: object, +) -> bool: + if not _basic_effect_midpoint(source, destination, midpoint, progress, case): + return False + counts = _domain_counts(midpoint) + return bool(counts and (counts["dark-effect"] + counts["mixed-effect"] >= 1)) + + +def _matches_particle( + source: object, + destination: object, + midpoint: object, + progress: float, + case: object, +) -> bool: + if not _basic_effect_midpoint(source, destination, midpoint, progress, case): + return False + # 3D shading/trails usually create non-endpoint pixels. Do not require a + # fixed count because the mode changes spatial arrival order. + counts = _domain_counts(midpoint) + return bool(counts and (counts["mixed-effect"] + counts["dark-effect"] >= 1)) + + +def _matches_burn( + source: object, + destination: object, + midpoint: object, + progress: float, + case: object, +) -> bool: + if not _basic_effect_midpoint(source, destination, midpoint, progress, case): + return False + counts = _domain_counts(midpoint) + if counts is None: + return False + # Char/core/smoke/ash should ensure the authored transition is not merely + # a two-domain wipe. The exact spatial front depends on direction/noise. + return (counts["dark-effect"] + counts["mixed-effect"]) >= 1 + + +_ORACLES = { + "diffuse": _matches_diffuse, + "ripple": _matches_ripple, + "crumble": _matches_crumble, + "particle": _matches_particle, + "burn": _matches_burn, +} + + +def _install_contract(effect: str, case: str) -> None: + if effect not in smoke._TRANSITION_IDS: + smoke._TRANSITION_IDS = (*smoke._TRANSITION_IDS, effect) + smoke._TRANSITION_SMOKE_DIRECTIONS[effect] = _CASES[effect] + smoke._TRANSITION_DIRECTION_CHOICES = tuple( + sorted(set(smoke._TRANSITION_DIRECTION_CHOICES) | set(_CASES[effect])) + ) + smoke._TRANSITION_PALETTE_RGB[effect] = smoke._DIRECTIONAL_PALETTE_RGB + smoke._TRANSITION_SMOKE_PARAMETERS[effect] = _parameters(effect, case) + smoke._TRANSITION_SMOKE_DURATIONS_MS[effect] = 900 + smoke._TRANSITION_MIDPOINT_ORACLES[effect] = _ORACLES[effect] + + +def _arguments(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--effect", choices=tuple(_CASES), required=True) + parser.add_argument("--case") + parser.add_argument("--windows", type=int, choices=(1, 2), default=1) + parser.add_argument("--output") + args = parser.parse_args(argv) + cases = _CASES[args.effect] + if args.case is None: + args.case = cases[0] + elif args.case not in cases: + parser.error( + f"--case {args.case!r} is invalid for {args.effect}; " + f"choose one of {', '.join(cases)}" + ) + return args + + +def main(argv: list[str] | None = None) -> int: + args = _arguments(sys.argv[1:] if argv is None else argv) + _install_contract(args.effect, args.case) + forwarded = [ + "--windows", + str(args.windows), + "--generations", + "1", + "--size", + "480x270", + "--phase-delay-ms", + "500", + "--transition-id", + args.effect, + "--transition-direction", + args.case, + ] + if args.output: + forwarded.extend(("--output", args.output)) + return smoke.main(forwarded) + + +if __name__ == "__main__": + raise SystemExit(main()) From cda37206b8c7ca79b92bb0ac63c78a51e4433812 Mon Sep 17 00:00:00 2001 From: Jayde Ver Elst <45283009+Basjohn@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:26:16 +0200 Subject: [PATCH 07/11] Gate Phase C on canonical Quick registry parity --- tests/test_qtquick_phase_c_registry_parity.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/test_qtquick_phase_c_registry_parity.py diff --git a/tests/test_qtquick_phase_c_registry_parity.py b/tests/test_qtquick_phase_c_registry_parity.py new file mode 100644 index 00000000..1145067c --- /dev/null +++ b/tests/test_qtquick_phase_c_registry_parity.py @@ -0,0 +1,20 @@ +"""Phase-C closure gate: canonical production transitions must all have Quick renderers.""" + +from rendering.transition_registry import iter_transition_descriptors +from rendering.quick.transitions.implementation_registry import ( + iter_quick_transition_implementations, +) + + +def test_phase_c_quick_catalog_exactly_matches_canonical_transition_registry(): + canonical_ids = tuple( + descriptor.stable_id for descriptor in iter_transition_descriptors() + ) + quick_ids = tuple( + descriptor.transition_id + for descriptor in iter_quick_transition_implementations() + ) + + assert len(canonical_ids) == len(set(canonical_ids)) + assert len(quick_ids) == len(set(quick_ids)) + assert set(quick_ids) == set(canonical_ids) From f54ba2c6e947d5db1fb5c687c6b0e4bf154d4613 Mon Sep 17 00:00:00 2001 From: Jayde Ver Elst <45283009+Basjohn@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:30:32 +0200 Subject: [PATCH 08/11] Resolve Phase C parameters from canonical defaults --- .../quick/transitions/parameter_resolution.py | 369 +++++++++++++++--- ...t_qtquick_transition_parameter_defaults.py | 103 +++++ 2 files changed, 417 insertions(+), 55 deletions(-) create mode 100644 tests/test_qtquick_transition_parameter_defaults.py diff --git a/rendering/quick/transitions/parameter_resolution.py b/rendering/quick/transitions/parameter_resolution.py index 58a5de22..f4bfdf21 100644 --- a/rendering/quick/transitions/parameter_resolution.py +++ b/rendering/quick/transitions/parameter_resolution.py @@ -1,9 +1,9 @@ """Pure Settings-to-request resolution for parameterized Phase-C Quick effects. -The render thread accepts only explicit immutable values. This module keeps +The render thread accepts only explicit immutable values. This module keeps Settings spelling, legacy fall-through behaviour, random choice, clamps, and colour normalization on the GUI/runtime side before TransitionRequest -construction. +construction. Canonical Settings defaults remain the single fallback authority. """ from __future__ import annotations @@ -14,6 +14,7 @@ import random from typing import Protocol +from core.settings.defaults import get_default_settings from .state import TransitionParameters, TransitionValue, freeze_transition_parameters @@ -37,6 +38,24 @@ def _mapping(settings: Mapping[str, object], name: str) -> Mapping[str, object]: return value if isinstance(value, Mapping) else {} +def _canonical(name: str) -> Mapping[str, object]: + all_defaults = get_default_settings() + transitions = all_defaults.get("transitions", {}) + if not isinstance(transitions, Mapping): + return {} + value = transitions.get(name, {}) + return value if isinstance(value, Mapping) else {} + + +def _value( + config: Mapping[str, object], + defaults: Mapping[str, object], + name: str, + fallback: object, +) -> object: + return config.get(name, defaults.get(name, fallback)) + + def _number(value: object, default: float) -> float: try: result = float(value) @@ -53,19 +72,41 @@ def _integer(value: object, default: int) -> int: def _bool(value: object, default: bool) -> bool: - return value if isinstance(value, bool) else bool(default) - - -def _finish(direction: TransitionValue, parameters: Mapping[str, object]) -> ResolvedPhaseCInputs: + """Preserve the Settings layer's legacy bool coercion at request admission.""" + + if isinstance(value, bool): + return value + if value is None: + return bool(default) + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "yes", "1", "on", "enabled"}: + return True + if normalized in {"false", "no", "0", "off", "disabled"}: + return False + return bool(default) + + +def _finish( + direction: TransitionValue, + parameters: Mapping[str, object], +) -> ResolvedPhaseCInputs: return ResolvedPhaseCInputs( direction=direction, parameters=freeze_transition_parameters(parameters), ) -def _resolve_blinds(settings: Mapping[str, object], rng: _RandomSource) -> ResolvedPhaseCInputs: +def _resolve_blinds( + settings: Mapping[str, object], + rng: _RandomSource, +) -> ResolvedPhaseCInputs: cfg = _mapping(settings, "blinds") - raw_direction = str(cfg.get("direction", "Horizontal") or "Horizontal") + defaults = _canonical("blinds") + default_direction = str(defaults.get("direction", "Horizontal") or "Horizontal") + raw_direction = str(_value(cfg, defaults, "direction", default_direction) or default_direction) if raw_direction == "Random": raw_direction = rng.choice(("Horizontal", "Vertical", "Diagonal")) direction = { @@ -73,16 +114,30 @@ def _resolve_blinds(settings: Mapping[str, object], rng: _RandomSource) -> Resol "Vertical": "vertical", "Diagonal": "diagonal", }.get(raw_direction, "horizontal") + + default_feather = _number(defaults.get("feather", 2), 2.0) + ui_feather = _number(_value(cfg, defaults, "feather", default_feather), default_feather) # Preserve TransitionFactory's UI-scale -> shader-scale conversion. - ui_feather = _number(cfg.get("feather", 2), 2.0) feather = max(0.001, min(0.5, (ui_feather / 25.0) * 0.5)) return _finish(direction, {"feather": feather}) -def _resolve_diffuse(settings: Mapping[str, object], _rng: _RandomSource) -> ResolvedPhaseCInputs: +def _resolve_diffuse( + settings: Mapping[str, object], + _rng: _RandomSource, +) -> ResolvedPhaseCInputs: cfg = _mapping(settings, "diffuse") - block_size = max(1, _integer(cfg.get("block_size", 50), 50)) - shape = str(cfg.get("shape", "Rectangle") or "Rectangle").strip().lower() + defaults = _canonical("diffuse") + default_block_size = max(1, _integer(defaults.get("block_size", 50), 50)) + block_size = max( + 1, + _integer( + _value(cfg, defaults, "block_size", default_block_size), + default_block_size, + ), + ) + default_shape = str(defaults.get("shape", "Rectangle") or "Rectangle") + shape = str(_value(cfg, defaults, "shape", default_shape) or default_shape).strip().lower() shape_mode = { "rectangle": 0, "membrane": 1, @@ -94,9 +149,23 @@ def _resolve_diffuse(settings: Mapping[str, object], _rng: _RandomSource) -> Res return _finish(None, {"block_size": block_size, "shape_mode": shape_mode}) -def _resolve_ripple(settings: Mapping[str, object], rng: _RandomSource) -> ResolvedPhaseCInputs: +def _resolve_ripple( + settings: Mapping[str, object], + rng: _RandomSource, +) -> ResolvedPhaseCInputs: cfg = _mapping(settings, "ripple") - count = max(1, min(8, _integer(cfg.get("ripple_count", 3), 3))) + defaults = _canonical("ripple") + default_count = _integer(defaults.get("ripple_count", 3), 3) + count = max( + 1, + min( + 8, + _integer( + _value(cfg, defaults, "ripple_count", default_count), + default_count, + ), + ), + ) return _finish( None, { @@ -106,19 +175,37 @@ def _resolve_ripple(settings: Mapping[str, object], rng: _RandomSource) -> Resol ) -def _resolve_crumble(settings: Mapping[str, object], rng: _RandomSource) -> ResolvedPhaseCInputs: +def _resolve_crumble( + settings: Mapping[str, object], + rng: _RandomSource, +) -> ResolvedPhaseCInputs: cfg = _mapping(settings, "crumble") - piece_count = max(4, _integer(cfg.get("piece_count", 14), 14)) + defaults = _canonical("crumble") + default_pieces = max(4, _integer(defaults.get("piece_count", 14), 14)) + piece_count = max( + 4, + _integer( + _value(cfg, defaults, "piece_count", default_pieces), + default_pieces, + ), + ) + default_complexity = _number(defaults.get("crack_complexity", 1.0), 1.0) complexity = max( 0.5, - min(2.0, _number(cfg.get("crack_complexity", 1.0), 1.0)), + min( + 2.0, + _number( + _value(cfg, defaults, "crack_complexity", default_complexity), + default_complexity, + ), + ), ) - weighting = str(cfg.get("weighting", "Random Choice") or "Random Choice") - # This deliberately preserves the CURRENT old factory semantics. The - # Settings UI also exposes "Bias Old Image" / "Bias New Image", but the - # old factory does not recognize either spelling and therefore falls back - # to 0.0. H0 may deliberately repair/rename that UX; Phase C must not - # silently change the authored presentation while migrating it. + default_weighting = str(defaults.get("weighting", "Random Choice") or "Random Choice") + weighting = str(_value(cfg, defaults, "weighting", default_weighting) or default_weighting) + # Deliberately preserve CURRENT old factory semantics. The Settings UI + # exposes "Bias Old Image" / "Bias New Image", but the old factory does + # not recognize either spelling and falls through to 0.0. H0 may repair + # that UX deliberately; Phase C must not silently change presentation. weight_mode = { "Top Weighted": 0.0, "Bottom Weighted": 1.0, @@ -140,9 +227,15 @@ def _resolve_crumble(settings: Mapping[str, object], rng: _RandomSource) -> Reso ) -def _resolve_particle(settings: Mapping[str, object], rng: _RandomSource) -> ResolvedPhaseCInputs: +def _resolve_particle( + settings: Mapping[str, object], + rng: _RandomSource, +) -> ResolvedPhaseCInputs: cfg = _mapping(settings, "particle") - mode_text = str(cfg.get("mode", "Converge") or "Converge") + defaults = _canonical("particle") + + default_mode = str(defaults.get("mode", "Converge") or "Converge") + mode_text = str(_value(cfg, defaults, "mode", default_mode) or default_mode) mode = { "Directional": 0, "Swirl": 1, @@ -150,9 +243,12 @@ def _resolve_particle(settings: Mapping[str, object], rng: _RandomSource) -> Res "Random": 3, }.get(mode_text, 0) - direction_text = str(cfg.get("direction", "Left to Right") or "Left to Right") + default_direction = str(defaults.get("direction", "Left to Right") or "Left to Right") + direction_text = str( + _value(cfg, defaults, "direction", default_direction) or default_direction + ) # "Random" is intentionally 0: that is the current old factory's - # fall-through for the Settings UI spelling. Legacy "Random Direction" + # fall-through for the Settings UI spelling. Legacy "Random Direction" # and "Random Placement" retain the shader's explicit 8/9 meanings. direction = { "Left to Right": 0, @@ -168,7 +264,17 @@ def _resolve_particle(settings: Mapping[str, object], rng: _RandomSource) -> Res "Random": 0, }.get(direction_text, 0) - swirl_order = max(0, min(2, _integer(cfg.get("swirl_order", 0), 0))) + default_swirl_order = _integer(defaults.get("swirl_order", 0), 0) + swirl_order = max( + 0, + min( + 2, + _integer( + _value(cfg, defaults, "swirl_order", default_swirl_order), + default_swirl_order, + ), + ), + ) if mode == 3: mode = int(rng.choice((0, 1, 2))) if mode == 0: @@ -176,16 +282,52 @@ def _resolve_particle(settings: Mapping[str, object], rng: _RandomSource) -> Res else: swirl_order = int(rng.randint(0, 2)) - radius = max(8.0, _number(cfg.get("particle_radius", 10.0), 10.0)) - overlap = max(0.0, _number(cfg.get("overlap", 4.0), 4.0)) + default_radius = _number(defaults.get("particle_radius", 10.0), 10.0) + radius = max( + 8.0, + _number( + _value(cfg, defaults, "particle_radius", default_radius), + default_radius, + ), + ) + default_overlap = _number(defaults.get("overlap", 4.0), 4.0) + overlap = max( + 0.0, + _number(_value(cfg, defaults, "overlap", default_overlap), default_overlap), + ) if overlap >= radius * 2.0: raise ValueError("resolved Particle overlap must be smaller than particle diameter") - # Preserve the old runtime's numerical index contract. Current Settings - # labels for light direction / swirl order do not match the shader comments - # one-for-one; changing those meanings belongs to the later settings epoch. - light_direction = max(0, min(4, _integer(cfg.get("light_direction", 0), 0))) - gloss_size = max(16.0, min(128.0, _number(cfg.get("gloss_size", 72.0), 72.0))) + # Preserve the old runtime's numerical index contract. Current Settings + # labels for light direction / swirl order do not match shader comments + # one-for-one; changing meanings belongs to the later settings epoch. + default_light = _integer(defaults.get("light_direction", 0), 0) + light_direction = max( + 0, + min( + 4, + _integer( + _value(cfg, defaults, "light_direction", default_light), + default_light, + ), + ), + ) + default_gloss = _number(defaults.get("gloss_size", 72.0), 72.0) + gloss_size = max( + 16.0, + min( + 128.0, + _number(_value(cfg, defaults, "gloss_size", default_gloss), default_gloss), + ), + ) + + default_trail_length = _number(defaults.get("trail_length", 0.15), 0.15) + default_trail_strength = _number(defaults.get("trail_strength", 0.6), 0.6) + default_swirl_strength = _number(defaults.get("swirl_strength", 1.0), 1.0) + default_swirl_turns = _number(defaults.get("swirl_turns", 3.0), 3.0) + default_3d = _bool(defaults.get("use_3d_shading", True), True) + default_texture = _bool(defaults.get("texture_mapping", True), True) + default_wobble = _bool(defaults.get("wobble", True), True) return _finish( None, @@ -195,13 +337,52 @@ def _resolve_particle(settings: Mapping[str, object], rng: _RandomSource) -> Res "direction": direction, "particle_radius": radius, "overlap": overlap, - "trail_length": max(0.0, min(1.0, _number(cfg.get("trail_length", 0.15), 0.15))), - "trail_strength": max(0.0, min(1.0, _number(cfg.get("trail_strength", 0.6), 0.6))), - "swirl_strength": max(0.0, _number(cfg.get("swirl_strength", 1.0), 1.0)), - "swirl_turns": max(0.5, _number(cfg.get("swirl_turns", 2.0), 2.0)), - "use_3d_shading": _bool(cfg.get("use_3d_shading", True), True), - "texture_mapping": _bool(cfg.get("texture_mapping", True), True), - "wobble": _bool(cfg.get("wobble", False), False), + "trail_length": max( + 0.0, + min( + 1.0, + _number( + _value(cfg, defaults, "trail_length", default_trail_length), + default_trail_length, + ), + ), + ), + "trail_strength": max( + 0.0, + min( + 1.0, + _number( + _value(cfg, defaults, "trail_strength", default_trail_strength), + default_trail_strength, + ), + ), + ), + "swirl_strength": max( + 0.0, + _number( + _value(cfg, defaults, "swirl_strength", default_swirl_strength), + default_swirl_strength, + ), + ), + "swirl_turns": max( + 0.5, + _number( + _value(cfg, defaults, "swirl_turns", default_swirl_turns), + default_swirl_turns, + ), + ), + "use_3d_shading": _bool( + _value(cfg, defaults, "use_3d_shading", default_3d), + default_3d, + ), + "texture_mapping": _bool( + _value(cfg, defaults, "texture_mapping", default_texture), + default_texture, + ), + "wobble": _bool( + _value(cfg, defaults, "wobble", default_wobble), + default_wobble, + ), "gloss_size": gloss_size, "light_direction": light_direction, "swirl_order": swirl_order, @@ -209,9 +390,16 @@ def _resolve_particle(settings: Mapping[str, object], rng: _RandomSource) -> Res ) -def _normalized_glow_color(value: object) -> tuple[float, float, float, float]: - raw = value if isinstance(value, (tuple, list)) and len(value) == 4 else (255, 140, 30, 255) - channels = tuple(_number(channel, 0.0) for channel in raw) +def _normalized_glow_color( + value: object, + fallback: object = (255, 140, 30, 255), +) -> tuple[float, float, float, float]: + candidate = value + if not isinstance(candidate, (tuple, list)) or len(candidate) != 4: + candidate = fallback + if not isinstance(candidate, (tuple, list)) or len(candidate) != 4: + candidate = (255, 140, 30, 255) + channels = tuple(_number(channel, 0.0) for channel in candidate) if any(channel < 0.0 for channel in channels): raise ValueError("Burn glow_color channels must be non-negative") if max(channels) <= 1.0: @@ -221,9 +409,16 @@ def _normalized_glow_color(value: object) -> tuple[float, float, float, float]: return tuple(channel / 255.0 for channel in channels) -def _resolve_burn(settings: Mapping[str, object], rng: _RandomSource) -> ResolvedPhaseCInputs: +def _resolve_burn( + settings: Mapping[str, object], + rng: _RandomSource, +) -> ResolvedPhaseCInputs: cfg = _mapping(settings, "burn") - direction_text = str(cfg.get("direction", "Random") or "Random") + defaults = _canonical("burn") + default_direction = str(defaults.get("direction", "Random") or "Random") + direction_text = str( + _value(cfg, defaults, "direction", default_direction) or default_direction + ) direction_map = { "Left to Right": 0, "Right to Left": 1, @@ -237,18 +432,82 @@ def _resolve_burn(settings: Mapping[str, object], rng: _RandomSource) -> Resolve if direction_text == "Random" else direction_map.get(direction_text, 0) ) + + default_jagged = _number(defaults.get("jaggedness", 0.5), 0.5) + default_glow = _number(defaults.get("glow_intensity", 0.7), 0.7) + default_char = _number(defaults.get("char_width", 0.5), 0.5) + default_smoke_enabled = _bool(defaults.get("smoke_enabled", True), True) + default_smoke_density = _number(defaults.get("smoke_density", 0.5), 0.5) + default_ash_enabled = _bool(defaults.get("ash_enabled", True), True) + default_ash_density = _number(defaults.get("ash_density", 0.5), 0.5) + default_glow_color = defaults.get("glow_color", (255, 140, 30, 255)) + return _finish( None, { "direction": direction, - "jaggedness": max(0.0, min(1.0, _number(cfg.get("jaggedness", 0.5), 0.5))), - "glow_intensity": max(0.0, min(1.0, _number(cfg.get("glow_intensity", 0.7), 0.7))), - "glow_color": _normalized_glow_color(cfg.get("glow_color", (255, 140, 30, 255))), - "char_width": max(0.1, min(1.0, _number(cfg.get("char_width", 0.5), 0.5))), - "smoke_enabled": _bool(cfg.get("smoke_enabled", True), True), - "smoke_density": max(0.0, min(1.0, _number(cfg.get("smoke_density", 0.5), 0.5))), - "ash_enabled": _bool(cfg.get("ash_enabled", True), True), - "ash_density": max(0.0, min(1.0, _number(cfg.get("ash_density", 0.5), 0.5))), + "jaggedness": max( + 0.0, + min( + 1.0, + _number( + _value(cfg, defaults, "jaggedness", default_jagged), + default_jagged, + ), + ), + ), + "glow_intensity": max( + 0.0, + min( + 1.0, + _number( + _value(cfg, defaults, "glow_intensity", default_glow), + default_glow, + ), + ), + ), + "glow_color": _normalized_glow_color( + _value(cfg, defaults, "glow_color", default_glow_color), + default_glow_color, + ), + "char_width": max( + 0.1, + min( + 1.0, + _number( + _value(cfg, defaults, "char_width", default_char), + default_char, + ), + ), + ), + "smoke_enabled": _bool( + _value(cfg, defaults, "smoke_enabled", default_smoke_enabled), + default_smoke_enabled, + ), + "smoke_density": max( + 0.0, + min( + 1.0, + _number( + _value(cfg, defaults, "smoke_density", default_smoke_density), + default_smoke_density, + ), + ), + ), + "ash_enabled": _bool( + _value(cfg, defaults, "ash_enabled", default_ash_enabled), + default_ash_enabled, + ), + "ash_density": max( + 0.0, + min( + 1.0, + _number( + _value(cfg, defaults, "ash_density", default_ash_density), + default_ash_density, + ), + ), + ), "seed": float(rng.random()) * 1000.0, }, ) diff --git a/tests/test_qtquick_transition_parameter_defaults.py b/tests/test_qtquick_transition_parameter_defaults.py new file mode 100644 index 00000000..e316ff5c --- /dev/null +++ b/tests/test_qtquick_transition_parameter_defaults.py @@ -0,0 +1,103 @@ +"""Phase-C resolver must inherit sparse values from canonical Settings defaults.""" + +import pytest + +from core.settings.defaults import get_default_settings +from rendering.quick.transitions.parameter_resolution import ( + resolve_parameterized_phase_c_inputs, +) + + +class _Rng: + def random(self) -> float: + return 0.25 + + def choice(self, seq): + return seq[0] + + def randint(self, a: int, b: int) -> int: + return a + + +def _defaults(name: str) -> dict[str, object]: + value = get_default_settings().get("transitions", {}).get(name, {}) + assert isinstance(value, dict) + return value + + +def test_sparse_diffuse_uses_canonical_block_size_and_shape(): + defaults = _defaults("diffuse") + resolved = resolve_parameterized_phase_c_inputs( + "diffuse", {}, random_source=_Rng() + ).parameter_dict() + expected_shape = { + "rectangle": 0, + "membrane": 1, + "lines": 2, + "diamonds": 3, + "amorph": 4, + "random": 5, + }.get(str(defaults.get("shape", "Rectangle")).strip().lower(), 0) + assert resolved["block_size"] == int(defaults.get("block_size", 50)) + assert resolved["shape_mode"] == expected_shape + + +def test_sparse_crumble_uses_canonical_piece_count_and_complexity(): + defaults = _defaults("crumble") + resolved = resolve_parameterized_phase_c_inputs( + "crumble", {}, random_source=_Rng() + ).parameter_dict() + assert resolved["piece_count"] == int(defaults.get("piece_count", 14)) + assert resolved["crack_complexity"] == pytest.approx( + float(defaults.get("crack_complexity", 1.0)) + ) + + +def test_sparse_particle_uses_canonical_authored_defaults(): + defaults = _defaults("particle") + resolved = resolve_parameterized_phase_c_inputs( + "particle", {}, random_source=_Rng() + ).parameter_dict() + assert resolved["particle_radius"] == pytest.approx( + max(8.0, float(defaults.get("particle_radius", 10.0))) + ) + assert resolved["overlap"] == pytest.approx( + max(0.0, float(defaults.get("overlap", 4.0))) + ) + assert resolved["swirl_turns"] == pytest.approx( + max(0.5, float(defaults.get("swirl_turns", 3.0))) + ) + assert resolved["wobble"] is bool(defaults.get("wobble", True)) + assert resolved["use_3d_shading"] is bool( + defaults.get("use_3d_shading", True) + ) + assert resolved["texture_mapping"] is bool( + defaults.get("texture_mapping", True) + ) + + +def test_sparse_burn_uses_canonical_glow_colour_and_controls(): + defaults = _defaults("burn") + resolved = resolve_parameterized_phase_c_inputs( + "burn", {}, random_source=_Rng() + ).parameter_dict() + glow = tuple(float(value) for value in defaults.get("glow_color", [255, 140, 30, 255])) + expected_glow = glow if max(glow) <= 1.0 else tuple(value / 255.0 for value in glow) + assert resolved["glow_color"] == pytest.approx(expected_glow) + assert resolved["jaggedness"] == pytest.approx( + float(defaults.get("jaggedness", 0.5)) + ) + assert resolved["char_width"] == pytest.approx( + max(0.1, float(defaults.get("char_width", 0.5))) + ) + + +def test_legacy_boolean_spellings_are_resolved_before_render_ownership(): + resolved = resolve_parameterized_phase_c_inputs( + "particle", + {"particle": {"wobble": "false", "use_3d_shading": "0", "texture_mapping": "yes"}}, + random_source=_Rng(), + ).parameter_dict() + assert resolved["wobble"] is False + assert resolved["use_3d_shading"] is False + assert resolved["texture_mapping"] is True From 7c4871016464c4a82cf19af6f113bcb21153a483 Mon Sep 17 00:00:00 2001 From: Jayde Ver Elst <45283009+Basjohn@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:41:20 +0200 Subject: [PATCH 09/11] Expand Phase C real-GL sign-off matrix --- tests/test_qtquick_phase_c_effect_smoke.py | 29 +++++++--- tools/qtquick_phase_c_effect_smoke.py | 66 +++++++++++++++------- 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/tests/test_qtquick_phase_c_effect_smoke.py b/tests/test_qtquick_phase_c_effect_smoke.py index 2aa87f88..8f31aa44 100644 --- a/tests/test_qtquick_phase_c_effect_smoke.py +++ b/tests/test_qtquick_phase_c_effect_smoke.py @@ -16,12 +16,11 @@ def test_phase_c_smoke_exposes_all_remaining_parameterized_effects(): "burn", ) assert len(phase_c_smoke._CASES["diffuse"]) == 6 - assert len(phase_c_smoke._CASES["burn"]) == 6 - assert phase_c_smoke._CASES["particle"] == ( - "directional", - "swirl", - "converge", - ) + assert len(phase_c_smoke._PARTICLE_DIRECTIONS) == 10 + assert len(phase_c_smoke._CASES["particle"]) == 12 + assert len(phase_c_smoke._BURN_DIRECTIONS) == 6 + assert len(phase_c_smoke._CASES["burn"]) == 9 + assert phase_c_smoke._CASES["particle"][-2:] == ("swirl", "converge") def test_phase_c_smoke_parameters_are_fully_resolved_and_deterministic(): @@ -34,16 +33,30 @@ def test_phase_c_smoke_parameters_are_fully_resolved_and_deterministic(): "ripple_seed": 123.5, } assert phase_c_smoke._parameters("crumble", "age-weighted")["weight_mode"] == 4.0 - particle = phase_c_smoke._parameters("particle", "converge") - assert particle["mode"] == 2 + + particle = phase_c_smoke._parameters("particle", "diag-br-tl") + assert particle["mode"] == 0 + assert particle["direction"] == 7 assert particle["use_3d_shading"] is True assert particle["texture_mapping"] is True assert particle["wobble"] is True + assert phase_c_smoke._parameters("particle", "random-direction")["direction"] == 8 + assert phase_c_smoke._parameters("particle", "random-placement")["direction"] == 9 + assert phase_c_smoke._parameters("particle", "swirl")["mode"] == 1 + assert phase_c_smoke._parameters("particle", "converge")["mode"] == 2 + burn = phase_c_smoke._parameters("burn", "diag-tr-bl") assert burn["direction"] == 5 assert burn["smoke_enabled"] is True assert burn["ash_enabled"] is True assert burn["glow_color"] == pytest.approx((1.0, 140.0 / 255.0, 30.0 / 255.0, 1.0)) + assert phase_c_smoke._parameters("burn", "no-smoke")["smoke_enabled"] is False + assert phase_c_smoke._parameters("burn", "no-smoke")["ash_enabled"] is True + assert phase_c_smoke._parameters("burn", "no-ash")["smoke_enabled"] is True + assert phase_c_smoke._parameters("burn", "no-ash")["ash_enabled"] is False + neither = phase_c_smoke._parameters("burn", "no-smoke-or-ash") + assert neither["smoke_enabled"] is False + assert neither["ash_enabled"] is False def test_phase_c_smoke_domain_classifier_distinguishes_fixture_ownership(): diff --git a/tools/qtquick_phase_c_effect_smoke.py b/tools/qtquick_phase_c_effect_smoke.py index ba92cf6b..9acf514b 100644 --- a/tools/qtquick_phase_c_effect_smoke.py +++ b/tools/qtquick_phase_c_effect_smoke.py @@ -1,6 +1,6 @@ """Focused real-GL wrapper for the remaining parameterized Phase-C effects. -The preserved qtquick_render_node_smoke lifecycle harness stays generic. This +The preserved qtquick_render_node_smoke lifecycle harness stays generic. This wrapper admits Diffuse, Ripple, Crumble, Particle, and Burn with deterministic resolved request parameters plus effect-shaped midpoint pixel gates. """ @@ -16,6 +16,27 @@ import qtquick_render_node_smoke as smoke +_PARTICLE_DIRECTIONS = { + "left-to-right": 0, + "right-to-left": 1, + "top-to-bottom": 2, + "bottom-to-top": 3, + "diag-tl-br": 4, + "diag-tr-bl": 5, + "diag-bl-tr": 6, + "diag-br-tl": 7, + "random-direction": 8, + "random-placement": 9, +} +_BURN_DIRECTIONS = { + "left-to-right": 0, + "right-to-left": 1, + "top-to-bottom": 2, + "bottom-to-top": 3, + "diag-tl-br": 4, + "diag-tr-bl": 5, +} + _CASES = { "diffuse": ( "rectangle", @@ -33,14 +54,12 @@ "random-choice", "age-weighted", ), - "particle": ("directional", "swirl", "converge"), + "particle": (*_PARTICLE_DIRECTIONS, "swirl", "converge"), "burn": ( - "left-to-right", - "right-to-left", - "top-to-bottom", - "bottom-to-top", - "diag-tl-br", - "diag-tr-bl", + *_BURN_DIRECTIONS, + "no-smoke", + "no-ash", + "no-smoke-or-ash", ), } @@ -68,11 +87,16 @@ def _parameters(effect: str, case: str) -> dict[str, object]: "weight_mode": weight_mode, } if effect == "particle": - mode = {"directional": 0, "swirl": 1, "converge": 2}[case] + if case in _PARTICLE_DIRECTIONS: + mode = 0 + direction = _PARTICLE_DIRECTIONS[case] + else: + mode = {"swirl": 1, "converge": 2}[case] + direction = 0 return { "seed": 12.5, "mode": mode, - "direction": 0, + "direction": direction, "particle_radius": 16.0, "overlap": 4.0, "trail_length": 0.15, @@ -87,16 +111,18 @@ def _parameters(effect: str, case: str) -> dict[str, object]: "swirl_order": 0, } if effect == "burn": - direction = _CASES["burn"].index(case) + direction = _BURN_DIRECTIONS.get(case, 0) + smoke_enabled = case not in {"no-smoke", "no-smoke-or-ash"} + ash_enabled = case not in {"no-ash", "no-smoke-or-ash"} return { "direction": direction, "jaggedness": 0.55, "glow_intensity": 0.72, "glow_color": (1.0, 140.0 / 255.0, 30.0 / 255.0, 1.0), "char_width": 0.5, - "smoke_enabled": True, + "smoke_enabled": smoke_enabled, "smoke_density": 0.5, - "ash_enabled": True, + "ash_enabled": ash_enabled, "ash_density": 0.5, "seed": 321.25, } @@ -169,8 +195,8 @@ def _matches_diffuse( ) -> bool: if not _basic_effect_midpoint(source, destination, midpoint, progress, case): return False - # Diffuse must be spatial rather than a uniform crossfade. A 5x5 sample - # set at midpoint should contain at least three distinct ownership/effect domains. + # Diffuse must be spatial rather than a uniform crossfade. A 5x5 sample + # set must contain more than one ownership/effect domain at midpoint. return len({_domain(color) for color in midpoint}) >= 2 @@ -183,7 +209,7 @@ def _matches_ripple( ) -> bool: if not _basic_effect_midpoint(source, destination, midpoint, progress, case): return False - # The first authored ripple is always centred. At a controlled midpoint + # The first authored ripple is always centred. At a controlled midpoint # the centre should be in the arriving-image domain while outer samples # still prove old-image ownership. centre = len(smoke._TRANSITION_SAMPLE_COORDINATES) // 2 @@ -212,8 +238,8 @@ def _matches_particle( ) -> bool: if not _basic_effect_midpoint(source, destination, midpoint, progress, case): return False - # 3D shading/trails usually create non-endpoint pixels. Do not require a - # fixed count because the mode changes spatial arrival order. + # 3D shading/trails usually create non-endpoint pixels. Do not require a + # fixed count because direction/mode changes spatial arrival order. counts = _domain_counts(midpoint) return bool(counts and (counts["mixed-effect"] + counts["dark-effect"] >= 1)) @@ -230,8 +256,8 @@ def _matches_burn( counts = _domain_counts(midpoint) if counts is None: return False - # Char/core/smoke/ash should ensure the authored transition is not merely - # a two-domain wipe. The exact spatial front depends on direction/noise. + # Char/core survives even when smoke/ash are disabled, so every Burn case + # remains distinguishable from a plain two-domain wipe. return (counts["dark-effect"] + counts["mixed-effect"]) >= 1 From f8fd8db6952f7d383d3daa09b80fdeb2bc39ba3a Mon Sep 17 00:00:00 2001 From: Jayde Ver Elst <45283009+Basjohn@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:02:29 +0200 Subject: [PATCH 10/11] Finalize Quick transition authoring guide --- Docs/Transition_Change_Checklist.md | 192 ++++++++++++++++++++-------- 1 file changed, 141 insertions(+), 51 deletions(-) diff --git a/Docs/Transition_Change_Checklist.md b/Docs/Transition_Change_Checklist.md index 6b23debc..da688a0f 100644 --- a/Docs/Transition_Change_Checklist.md +++ b/Docs/Transition_Change_Checklist.md @@ -1,87 +1,177 @@ # Transition Change Checklist -Last updated: 2026-08-20 +Last updated: 2026-08-21 -Use when adding/removing/renaming/retuning a transition or while executing the Qt Quick transition -migration. +Use this checklist when adding, removing, renaming, retuning, enabling, disabling, or diagnosing a transition. -Active migration sequencing is in `Current_Plan.md`. +Active migration sequence is owned by `Current_Plan.md`. The landed Qt Quick transition architecture is described in `Docs/QtQuick_Migration/02_Scene_Renderer_Transitions.md`. -Technical migration detail: +## 1. Canonical identity -`Docs/QtQuick_Migration/02_Scene_Renderer_Transitions.md` +- Canonical transition identity remains in `rendering/transition_registry.py`. +- Stable ids are the runtime/render boundary; Settings labels and legacy aliases remain registry/settings concerns. +- Random/Cycle eligibility is registry-owned. +- A transition may remain visible in Settings while disabled from runtime selection. +- The permanent Phase-C registry-parity gate must continue to prove that every canonical production transition has exactly one Quick implementation entry and that ids are unique. -## 1. Registry / identity +Do not create a second transition catalogue in Quick code. -- canonical id/name stays in `rendering/transition_registry.py`; -- legacy settings aliases remain registry/settings concerns; -- random/cycle eligibility remains registry-owned; -- defaults reference valid canonical ids. +## 2. Production presentation contract -## 2. Presentation owner - -Destination production owner: +The target/landed renderer path is: ```text -Quick transition run --> display QSGRenderNode --> display QQuickWindow +Settings / transition selection + -> canonical descriptor + -> GUI/runtime-side immutable request resolution + -> TransitionRequest / TransitionRun + -> lazy Quick renderer implementation + -> display QSGRenderNode + -> display QQuickWindow ``` -During migration old `gl_compositor_*` classes may still be current production reference code. +Until Phase H production cutover, the old compositor may remain in the repository as the current production/reference implementation. New Quick code must not call back into `GLCompositorWidget`, QWidget presentation, or a compatibility presenter. + +No transition may fall back from Quick to the old compositor. + +## 3. Lazy implementation boundary + +Each transition implementation owns its own: + +- authored shader/math; +- transition-specific validation; +- uniforms; +- optional mesh/buffers; +- context-local GL resources; +- resource release. + +The common host owns: + +- old/new immutable image textures; +- request/run lifecycle; +- monotonic progress sampling; +- generation/run fencing; +- completion/cancellation; +- shared GL-state fencing; +- presentation frame demand. + +Disabled means dormant: no transition implementation import, shader compile, GPU object, timer, or transition-specific runtime state merely because the code is installed. + +Internal modularity is static and plugin-shaped. Do not add dynamic discovery, manifests, hot loading, API versioning, dependency resolution, or a third-party transition SDK. + +## 4. Request admission and settings resolution + +Resolve Settings spelling, random choices, clamps, colors, and legacy fall-through behavior before render ownership. + +Parameterized Quick renderers should reject missing/unresolved values instead of silently inventing renderer defaults. + +Canonical Settings defaults are the fallback authority. Do not duplicate constructor/default magic numbers in the Quick resolver when the current Settings schema already owns them. + +Random/per-run values such as seeds are resolved once into the immutable request/run. Do not re-randomize per rendered frame. + +## 5. Timing + +Preserve authored timing rather than applying one global easing policy. + +- Slide: `SINE_IN_OUT`. +- Staged shader/physics transitions normally receive a linear outer run when they already shape time internally. +- 3D Block Spins keeps its authored cubic internal spin timing over a linear outer run. + +Physical frame pacing is presentation-only. Missed display opportunities advance to the current monotonic sample; they are never replayed. -Do not add new dependencies from Quick code back to `GLCompositorWidget`. +Never add: -Do not add a fallback that sends an unsupported Quick transition to the old compositor. +- catch-up queues; +- paint acknowledgement; +- producer/display divisors; +- per-transition frame timers; +- easing used to disguise a coverage/cadence defect. -## 3. Timing +## 6. Image ownership and endpoints -Preserve: +Source/destination images crossing into render ownership must be immutable/detached from QWidget/QPixmap state. -- duration; -- easing; -- direction; -- authored random parameters; -- exactly-once completion; -- interruption semantics. +Every transition must prove exact source and destination endpoints. -Physical frame pacing is display presentation only. +For Slide, source and destination sampling and the sole pixel owner come from one immutable progress sample in one draw. The four product directions are left/right/up/down. Do not restore diagonal full-frame Slide without separately authored corner coverage. -No catch-up, paint acknowledgement, or transition-specific cadence hacks. +## 7. Authored-rich effects -## 4. Renderer +Do not replace a rich existing effect with a conceptually similar simplified port. -Reuse existing shader/math when valid. +### 3D Block Spins -Render-thread state is immutable/synchronized. +Preserve the real thin 36-vertex rectangular-prism slab, depth-tested faces, black void, four authored axes/directions, destination-face UV orientation, dark sides, moving direction-sensitive specular band, edge-on rim, and context-local mesh/program teardown. No flat-quad fallback. -No live QWidget/QPixmap access on render thread. +### Particle -Resource create/use/delete follows the Quick render-node owner. +Preserve the canonical particle shader, Directional/Swirl/Converge behavior, all directional/random-placement shader modes, trail behavior, swirl strength/turns/order, wobble, texture mapping, 3D shading, gloss size, light direction, seed, and physical-framebuffer resolution semantics. -## 5. UI/settings +### Burn -Settings UI remains unchanged unless transition controls/identity genuinely change. +Preserve the canonical Burn shader and its ignition delay, six directions, four-octave/domain-warped noise, jagged front, heat distortion, warm glow, white-hot core, char progression/crackle/smoulder, sparks/embers, smoke wisps, falling ash, density controls, per-run seed, animated effect time, and delayed clean-destination tail fade. -Do not rewrite settings tab because the runtime renderer changed. +## 8. GL state/resource hygiene -## 6. Tests +A transition may use shaders, depth, meshes, VAOs/VBOs, textures, and other GL state inside the Quick render node, but it must leave the scene graph safe for subsequent nodes. -Cover: +Keep the common state fence current for any state introduced by new effects, including viewport/scissor, program, VAO/VBO, active textures/bindings, blend, cull, depth enable/write/function/clear, stencil, and other modified state. -- registry; -- factory/request mapping; -- transition run parameters; -- start/mid/end rendering; -- direction/easing; -- interruption; +Create and delete context-local resources on the legal render owner. Resource deletion failures remain accounted/loud; do not silently leak ownership. + +## 9. Tests and evidence + +Use the smallest gate that can falsify the change: + +- registry/catalog parity; +- lazy import/dormancy; +- request/settings resolution; +- parameter validation; +- shader/source reuse where required; +- start/mid/end behavior; +- authored direction/mode variants; +- interruption/exactly-once completion; - generation fencing; - resource cleanup; -- high-refresh installed motion. +- GL-state restoration; +- focused real-GL smoke where useful. + +A deterministic source/contract test is not a visual-parity claim. A hosted CI VM is not authoritative evidence for physical-display cadence, mixed-refresh behavior, subjective motion feel, GPU utilization, or real multi-monitor topology. + +Manual/agent sign-off commands belong in `Docs/Harness_Index.md` and the Phase-C closure ledger in `Current_Plan.md`. + +## 10. Git/checkpoint discipline + +For normal local work: + +```text +focused gate +-> inspect diff/status +-> commit intended paths only +-> push +-> independent audit +``` + +For connector/API edits, especially whole-file reconstruction: + +```text +fetch authoritative parent +-> create candidate blobs/tree +-> create UNATTACHED candidate commit +-> compare parent..candidate +-> spot-fetch reconstructed file boundaries/suspicious sections +-> only then move branch ref +-> fetch/compare pushed commit again +``` + +Creating a blob or tree is not a checkpoint. A checkpoint is not usable until a commit is reachable from the intended branch and pushed. + +If a candidate diff is unexpectedly broad or malformed, abandon the unattached commit instead of repairing it in place on the branch. + +## 11. Migration closure -## 7. Migration closure +Phase C implementation is structurally complete once the canonical registry and Quick implementation registry are in exact parity and each renderer is isolated from the old compositor. -After all active transitions use Quick and production cutover is green, remove old compositor-only -transition classes through `Future_Cleanup.md`. +Physical/eyes-on acceptance may remain as explicit deferred sign-off while later migration phases proceed. Failing deferred evidence reopens only the smallest demonstrated transition/runtime defect; it does not authorize a second presentation architecture. -Commit and push each landed transition batch. +Old compositor-only transition classes are removed after production cutover through Phase I / `Future_Cleanup.md`. \ No newline at end of file From ab5a414f44327a0d0813e7e170518246b0864716 Mon Sep 17 00:00:00 2001 From: Jayde Ver Elst <45283009+Basjohn@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:06:13 +0200 Subject: [PATCH 11/11] Close Phase C documentation and activate Phase D --- .github/workflows/windows-ci.yml | 5 + Current_Plan.md | 1477 +++++------------ Docs/Compositor_Architecture.md | 106 +- Docs/Contracts.md | 48 +- Docs/Harness_Index.md | 133 +- .../02_Scene_Renderer_Transitions.md | 537 +++--- Docs/QtQuick_Migration/03_Visualizer.md | 363 ++-- Docs/QtQuick_Migration/README.md | 57 +- Docs/TestSuite.md | 94 +- Spec.md | 36 +- 10 files changed, 1242 insertions(+), 1614 deletions(-) diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index 0b6e5298..d6fcd968 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -25,6 +25,11 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + # Some permanent regression guards inspect historical source via + # `git show :`. The default depth=1 checkout cannot + # satisfy those tests. + fetch-depth: 0 - name: Set up Python 3.11.9 uses: actions/setup-python@v5 diff --git a/Current_Plan.md b/Current_Plan.md index cbe343d7..5d5f403e 100644 --- a/Current_Plan.md +++ b/Current_Plan.md @@ -2,338 +2,280 @@ Last updated: 2026-08-21 -## Source / decision checkpoint +## Source / reviewed checkpoints -The documentation/decision checkpoint reviewed for this migration remains: +Original architecture/decision orientation anchor: ```text 18c8f26756df83bd0d8828becc740c72d5526b21 4.7.2 - Pre-Quick Migration Docs v1 ``` -This SHA is an orientation anchor, **not** a required current HEAD. +That SHA is historical orientation, not a required current HEAD. -The latest Phase-C implementation checkpoint explicitly reviewed while producing this revision is: +Latest Phase-C implementation checkpoint reviewed for this revision: ```text -6ced08b2431d6e8995b402b1e2431833cdaf9da0 -Make Blinds smoke wrapper import-safe +7c4871016464c4a82cf19af6f113bcb21153a483 +Expand Phase C real-GL sign-off matrix ``` -The Blinds slice spans the immediately preceding small commits that added the renderer, focused tests, -lazy registry entry, catalog expectation update, and real-GL smoke wrapper. Those commits are a landed -implementation checkpoint; this plan revision may itself be one documentation commit later. - -At this reviewed checkpoint: - -- Phase A/B foundation and topology work are complete enough for migration to remain in Phase C; -- the Phase-B topology-displacement audit issue is closed; -- Quick transition implementations exist for: - - Crossfade; - - Slide; - - Wipe; - - Warp Dissolve; - - Block Puzzle Flip; - - 3D Block Spins; - - Blinds; -- the remaining canonical transition set is: - - Diffuse; - - Ripple / Raindrops; - - Crumble; - - Particle; - - Burn. - -Before active work: - -1. inspect current `HEAD` and the working tree; -2. preserve unrelated user work; -3. inspect code changes after the relevant checkpoint only far enough to update assumptions they actually invalidate; -4. never reset, clean, checkout, stash, or revert merely to manufacture equality with an orientation SHA; -5. trust repository state and focused tests over an agent's prose claim about what it completed. - -The Qt Quick architecture decision is closed by the P0 evidence. Do not reopen the presenter comparison. - -## Active execution window — read before the roadmap - -This file deliberately contains the **entire migration** so later contracts remain visible. That does -not make every phase below simultaneously active. Treat later phases as reference material until the -active phase exits. +Immediately preceding closure checkpoints include registry parity and canonical Settings-to-request parameter resolution. Documentation closure commits follow that implementation checkpoint. + +The Qt Quick presenter decision and inline custom-GL primitive are closed by the P0/Phase-A evidence. Do not reopen the presenter or `QSGRenderNode` selection without concrete contradictory implementation evidence. + +## Required routing before active work + +Do not treat this plan as a substitute for durable architecture/guardrail docs. For a migration slice use: + +```text +exact current source / pushed diff + ↓ +Current_Plan.md + ↓ +Spec.md + Docs/Compositor_Architecture.md + Docs/Contracts.md + ↓ +Docs/Guardrails.md + relevant focused guardrail + ↓ +ONLY the active Docs/QtQuick_Migration decomposition + ↓ +focused tests / current evidence +``` + +`Index.md` is the routing authority when unsure where a contract lives. + +`Future_Work.md` is deferred new-feature/experiment scope, not migration work admission. Do not implement from it while the active plan or `Future_Cleanup.md` contains important work unless the operator explicitly selects an item. + +--- + +# Active execution window + +This file owns migration sequence and work admission. The technical decompositions under `Docs/QtQuick_Migration/` explain how to execute admitted work; they do not create parallel phases. | Phase | Current status | Implementation permission | | --- | --- | --- | -| A — bootstrap/render-node proof | Structurally complete | Do not reopen; A4 compiled smoke remains operator-scheduled later | -| B — runtime-host decomposition | Structurally complete | Do not reopen without concrete contradictory evidence | -| **C — base image + transitions** | **ACTIVE** | **Normal implementation work belongs here now** | -| D — visualizer | Waiting for Phase C exit | Reference only | -| E — widget presentation foundation | Waiting for earlier phase exits | Reference only | -| F — widget families | Waiting for earlier phase exits | Reference only | -| G — CUSTOM/input/auxiliary pixels | Waiting for earlier phase exits | Reference only | -| H — settings epoch/cutover | Waiting for earlier phase exits | Reference only | -| I — legacy removal | Waiting for production cutover | Reference only | -| J — final tooling/validation/closure | Waiting for migration implementation | Reference only | - -While Phase C is active: - -1. refresh the exact remaining transition list from current `HEAD` and the canonical registry; -2. implement/audit **one remaining transition slice at a time** through the existing lazy Quick - transition boundary; -3. inspect the old authored implementation/settings before porting a transition and preserve its real - visual/timing/resource contract rather than producing a merely similar effect; -4. use focused tests and the appropriate Quick/GL smoke for the slice, then commit + push it before - moving on; -5. if a transition exposes a defect in already-landed A/B/C infrastructure, repair only the smallest - prerequisite needed to restore the active Phase-C contract, then return to the transition sequence; -6. do **not** opportunistically start Phase D–J work, `Future_Work.md`, broad cleanup, H0 settings - migration, Defaults Foundry retargeting, or production cutover merely because those details are - visible later in this file; -7. after the last canonical transition is accepted, run the Phase C exit gate and then perform the - specifically scheduled post-C transition-authoring documentation update. - -**Phase promotion is explicit.** A later phase becomes normal implementation work only after the -current phase's exit gate is satisfied and this active-status section is updated, or the operator -explicitly overrides the sequence. Reading a later phase is never permission to begin it. +| A — bootstrap/render-node proof | Structurally complete | Do not reopen; compiled smoke remains operator-scheduled later | +| B — runtime-host decomposition | Structurally complete | Do not reopen without contradictory evidence | +| C — base image + transitions | **IMPLEMENTATION COMPLETE; acceptance debt explicit** | No new C code unless deferred evidence demonstrates a defect | +| **D — visualizer** | **ACTIVE** | **Normal implementation work belongs here now** | +| E — widget presentation foundation | Waiting for D implementation exit | Reference only | +| F — widget families | Waiting for E | Reference only | +| G — CUSTOM/input/auxiliary pixels | Waiting for F | Reference only | +| H — settings epoch + production cutover | Waiting for A–G implementation | Reference only | +| I — legacy presenter deletion | Waiting for H cutover | Reference only | +| J — tooling/final validation/docs closure | Waiting for migration implementation | Reference only | + +## Phase promotion rule + +A phase may move forward when its **implementation dependencies** are structurally closed even if hardware-dependent/eyes-on acceptance remains explicitly deferred. + +Deferred evidence must be listed with runnable commands/criteria. A later failure reopens the smallest demonstrated owner/phase defect; it does not automatically roll the migration sequence backward or authorize a compatibility architecture. + +Production cutover in Phase H still requires the full Quick implementation surface to exist. Final release acceptance in Phase J still requires the scheduled physical/compiled evidence. --- # 0. Mission -Perform **one** production presentation migration: +Perform one production presentation migration: ```text current QWidget / QRhiWidget runtime presentation ↓ one standalone threaded QQuickWindow per physical display ↓ -Qt Quick scene + inline custom GL render nodes +Qt Quick retained scene + inline custom GL render nodes ``` Do not plan a second presenter migration afterward. -Do not rewrite unaffected product systems. +Keep unaffected product systems unless a later phase explicitly replaces a presentation-coupled part: -Keep, unless a later phase explicitly replaces a presentation-coupled part: - -- `ScreensaverEngine` orchestration except where display-runtime calls must change; +- `ScreensaverEngine` orchestration except display-runtime calls that must change; - image source/provider backends; - SettingsManager and persistence infrastructure; - source/account/credential ownership; - QWidget Settings UI; - RSS/folder/media/GSMTC/provider logic; -- ProcessSupervisor / ThreadManager ownership where still appropriate; +- ProcessSupervisor / ThreadManager where still appropriate; - `VisualizerLogicalRuntime`; -- visualizer authored algorithms and mode personality; -- custom-layout math/behavior contracts; +- authored visualizer algorithms/mode personality; +- useful CUSTOM layout math/behavior; - transition registry/settings identity; -- product features and customization. - -Important distinction: - -- keeping SettingsManager/persistence infrastructure does **not** require preserving old presentation-setting values through cutover; -- keeping custom-layout math/behavior does **not** require translating pre-Quick widget geometry; -- Phase H0 intentionally creates a new Qt Quick settings epoch and resets migration-sensitive state. +- product features/customization. -Replace/refactor what is coupled to the old runtime-pixel owner. +Backward compatibility with pre-Quick **presentation state** is deliberately not a migration goal; Phase H0 creates a new settings epoch. --- -# 1. Hard operating rules +# 1. Hard architecture rules -## 1.1 No runtime compatibility architecture +## 1.1 One production presenter -Do **not** add: +Do not add or preserve as final architecture: -- a production setting/env switch selecting QRhiWidget vs Quick; -- a permanent facade that makes `QQuickWindow` pretend to be `DisplayWidget`; -- a QWidget presenter embedded over/under the Quick runtime; -- a second accelerated visualizer/widget surface; +- a QRhiWidget-vs-Quick runtime setting/env switch; - `QQuickWidget`; -- a QRhiWidget fallback if Quick rendering fails; -- a transition-by-transition fallback to the old compositor; +- a permanent facade making QQuickWindow pretend to be DisplayWidget; +- QWidget presentation embedded above/below the Quick runtime; +- a second accelerated visualizer/transition window; +- QRhiWidget fallback when Quick rendering fails; +- transition-by-transition fallback to the old compositor; +- screenshot-to-texture QWidget wrappers as final widgets; - duplicated legacy and Quick widget presentation pipelines after cutover. -During development, the old production runtime and the not-yet-active Quick implementation may coexist -in the repository. Only one is the normal production path at a time. Migration harnesses may exercise -the Quick path before cutover. +During migration, old production code may coexist in the repository as reference/current production until Phase H. Once production cuts over, Phase I deletion begins immediately. -Once production cuts over, legacy presentation removal begins immediately. +The selected custom-GL seam is: -## 1.2 Refactor overloaded presentation modules while migrating +```text +QQuickItem(ItemHasContents) +-> updatePaintNode() +-> QSGRenderNode +-> direct OpenGL inside the owning Quick scene +``` -Refactor when overload is directly caused by the old presentation boundary. +`QQuickRhiItem` is not the normal SRPSS custom-render path. If the selected `QSGRenderNode` seam is proven fundamentally unusable in pinned PySide/compiled product, stop and revise the **single** primitive deliberately; do not keep competing product primitives. -Required examples: +## 1.2 Refactor only presentation overload that migration exposes + +Expected decomposition: ```text DisplayWidget - -> runtime/window owner - -> input owner - -> scene/presentation owner - -> widget/model owner - -> CUSTOM/edit owner - -WidgetManager - -> widget/provider/model lifecycle owner - -> layout/visibility owner - -> Quick presentation item owner + -> QuickDisplayRuntime/window owner + -> RuntimeInputController + -> QuickSceneController + -> WidgetRuntimeManager + -> CustomLayoutSession GLCompositorWidget - -> transition renderer/resource owner - -> visualizer renderer/resource owner - -> presentation pacing owner + -> transition renderer/resource implementations + -> visualizer renderer/resources + -> presentation pacer ownership ``` -Do **not** use the migration as permission to refactor unrelated source/provider/backend systems. +Do not use the migration to rewrite unrelated provider/backend systems. -## 1.3 Preserve full runtime visual capability +## 1.3 Preserve visual capability -Migration parity includes, where currently supported: +Migration parity includes current supported presentation capabilities such as opacity, backgrounds/cards, borders/radius, fonts/colors, shadows, artwork, separators/icons, progress controls, stacking, monitor routing, pixel shift, dimming, CUSTOM geometry/edit, context interaction, visualizer all five modes, transitions, and Media Center interaction. -- per-widget opacity; -- card/background opacity; -- text opacity/color; -- shadows; -- text/header shadows; -- borders and border opacity; -- rounded corners; -- fonts and font sizes; -- margins; -- artwork sizes/shapes/rounding; -- separators/icons/header chrome; -- progress bars/glow/shadow; -- widget fades; -- stacking; -- monitor routing; -- pixel shift; -- dimming; -- CUSTOM position/resize; -- multi-monitor transfer during edit; -- context interaction; -- visualizer card + all five visualizer modes; -- all supported transitions; -- Media Center interaction behaviour. +Do not solve migration defects by flattening/removing authored effects. -Do not solve a migration bug by deleting or flattening a visual feature. +## 1.4 No premature full/compiled builds -Where an existing effect is unusually authored or visually rich, preserve its real effect contract rather -than replacing it with a conceptually similar but cheaper-looking approximation. +During Phases C–G, normal gates are focused Python/static/runtime harnesses. -## 1.4 Frequent Git checkpoints are mandatory +Do not run Nuitka/full installed builds merely as routine migration validation. Keep packaging inputs current. Compiled/installed validation remains operator-scheduled unless the operator explicitly requests it earlier. -After **every landed slice**: +--- -1. run the focused gate for the slice; -2. inspect `git diff` / `git status`; -3. commit only the intended slice; -4. `git push`; -5. continue immediately to the next slice when gates pass. +# 2. Git / agent / connector workflow -A normal migration session should produce many small pushed commits, not one giant migration commit. +## 2.1 Checkpoints are mandatory -Good checkpoint scale: +For normal local-agent work: ```text -Quick bootstrap + first render node -frame pacer extraction -Quick runtime host -one transition or tightly related transition batch -visualizer immutable bridge -Bubble render node -shared Quick card/shadow primitive -clock family -weather -media -reddit -gmail -Steam family -CUSTOM session -settings epoch reset -production cutover -legacy deletion batch -Defaults Foundry retarget -build/tooling closure +inspect exact current source +-> implement narrow slice +-> focused gate +-> inspect diff/status +-> commit intended paths only +-> push +-> independent audit of actual pushed source/diff +-> continue ``` -Do not pause after a successful checkpoint to ask permission to continue. +Do not stop after a successful checkpoint merely to ask permission to continue. -For a high-risk effect such as BlockSpin, Burn, Particle, or Bubble, prefer one effect per checkpoint. +High-risk effects/owners such as BlockSpin, Burn, Particle, Bubble, settings epoch, and production cutover deserve dedicated checkpoints. -## 1.5 Do not stop unless an actual blocker is hit +## 2.2 Connector/API write discipline -A failing test, compile error, visual bug, missing import, wrong geometry, or one difficult widget is -not by itself a blocker. Diagnose, correct, re-run, checkpoint, continue. +Repository connector writes are allowed when practical, but their editing ergonomics are weaker than a local worktree. -An **actual blocker** is something such as: +For risky whole-file/chunk reconstruction: -- the selected Quick custom-render primitive is fundamentally unusable in pinned PySide 6.9.1 or - the compiled product after a focused proof; -- required one-window-per-display semantics cannot be preserved; -- a required product visual/interaction capability cannot be represented without a prohibited - second presentation architecture; -- lifecycle/resource ownership cannot be made deterministic after focused correction; -- essential external information/credential/device access genuinely unavailable to the agent is - required to proceed. +```text +fetch authoritative parent +-> build candidate blobs/tree +-> create UNATTACHED candidate commit +-> compare parent..candidate +-> confirm only intended files changed +-> spot-fetch beginning/end and suspicious reconstructed sections +-> move branch ref only after candidate audit +-> fetch/compare the branch-reachable commit again +``` -If a blocker is hit: +Creating a blob/tree is not a checkpoint. A checkpoint must be a commit reachable from the intended pushed branch. -- stop broad code churn; -- record exact evidence; -- name the blocked owner and smallest decision required; -- do not invent a compatibility layer to go around it. +If an API/chunk edit produces an unexpectedly broad/malformed diff, abandon the unattached candidate. Do not let corruption become branch history and repair it afterward. -## 1.6 Support docs do not own sequence +For large changes where direct connector reconstruction becomes unreliable, fall back to whole replacement files or a narrow paste-ready coding-agent prompt, then audit the actual pushed result. -The technical decomposition docs under: +## 2.3 Trust evidence, not agent prose -```text -Docs/QtQuick_Migration/ -``` +An agent saying tests passed or code was implemented is not evidence. Inspect current repository state, pushed commit, diff, relevant source, and independent CI/harness evidence when available. + +Repository state outranks stale orientation prose. + +--- + +# 3. CI evidence rules and known 2026-08-21 failure + +GitHub Actions is an independent execution environment, not ChatGPT's runtime and not the operator's physical RTX/multi-display environment. -are subordinate to this file. +Good hosted-CI evidence: -They explain **how** to perform a named slice. They may not: +- deterministic Python/unit/source contracts; +- registry/settings tests; +- lifecycle logic where headless execution is representative; +- import/dormancy tests; +- shader/source contract tests; +- later packaging sanity when build policy permits it. -- reorder the phases; -- create a parallel roadmap; -- authorize work not admitted by this plan; -- keep completed work active after this plan removes it. +Hosted CI is not authoritative for: -Deferred post-cutover deletion is cross-linked to `Future_Cleanup.md`. +- actual 165 Hz/60 Hz physical cadence; +- PresentMon/display occupancy; +- subjective Bubble/effect smoothness; +- physical multi-monitor topology; +- real GPU utilization/performance. -## 1.7 Developer documentation migrates with proven contracts +### Windows CI run `32436553793` -Do not rewrite presentation-feature authoring/development guidance before its owning implementation -phase has proved the final contract. +The email saying "all jobs failed" did **not** mean GitHub abruptly killed the hosted runner. -During pre-cutover rewrites, label new guidance as the Qt Quick target architecture while the old -production presenter still exists. +Observed facts from the archived Actions logs: -- [ ] After Phase C exits, rewrite transition-authoring guidance for the canonical registry and lazy - Quick renderer contract. -- [ ] After Phase D exits, update visualizer/preset-authoring guidance for the final Quick visualizer - boundary while preserving preset and logical-runtime instructions that remain valid. -- [ ] After Phase F exits, rewrite widget-authoring guidance for the presentation-neutral - descriptor/model/family registry and retained Quick component contract. -- [ ] After Phase H cutover and Phase I deletion, remove or archive obsolete QWidget, QRhiWidget and - compositor authoring instructions and make the Quick guides the sole current authority. -- [ ] In Phase J, audit README/project overview, architecture docs, contracts, indexes, cross-links, - examples, troubleshooting/build guidance, Defaults Foundry guidance, and references to deleted - presentation code. +- setup/dependency installation completed; +- chunk 1 ran ~130 s and returned ordinary test failures; +- chunk 2 printed a complete pytest summary (`3 failed, 1219 passed, 67 skipped`) in ~24.76 s but the Python process did not exit; `tests/run_chunked.py` killed the still-live process at its own 900-second timeout (`exit 124`); +- chunk 3 stopped around 50% test execution progress with no pytest summary and hit the same wrapper timeout; +- chunk 4 ran ~180 s and returned ordinary test failures; +- the outer Actions job had `timeout-minutes: 70` and completed normally after the wrapper returned failure; +- logs/artifacts uploaded successfully. -Preserve historical bug/evidence documents as history; only repair links or add context needed to -keep that history intelligible. +Interpretation: -## 1.8 No premature compiled/full builds +- chunk 2 strongly suggests leaked shutdown ownership/non-daemon thread/background process after pytest finished, rather than a 15-minute test body; the exact owner remains to be isolated; +- chunk 3 requires verbose/smaller isolation because it appears to hang during test execution; +- completed chunks contain several failures in old/default/UI/doc/visualizer areas, so broad-suite red status is currently noisy and must be inspected rather than attributed wholesale to the active migration slice; +- an uncompleted/timed-out chunk is **not** assumed clean; Phase-C focused tests still require explicit sign-off. -During migration implementation, do not run compiled/full builds merely as routine validation. +That CI run also exposed a deterministic workflow configuration defect: `actions/checkout` used its default `fetch-depth: 1`, while an existing Bubble guardrail executes `git show 510520e:...` and therefore cannot see the historical object. -Keep build scripts and packaging inputs compatible, but compiled/installed validation is operator -scheduled after implementation is complete unless the operator explicitly requests an earlier build. +The Phase-C documentation closure changes the workflow to `fetch-depth: 0`. That removes the known shallow-history failure on future runs, but it does not claim to fix the separate shutdown/hang or unrelated test failures. -Focused script/static/runtime harness tests are the normal migration gates. +Do not fix the remaining timeout behavior by merely increasing 900 seconds. Isolate the actual owner/test with smaller/verbose chunks or explicit thread/process diagnostics. + +This broad-suite CI debt does not block Phase-D implementation by itself. --- -# 2. Destination architecture +# 4. Destination runtime architecture ```text ScreensaverEngine @@ -355,524 +297,266 @@ ScreensaverEngine | +-- RuntimeInputController +-- WidgetRuntimeManager - +-- CustomLayoutSession (when active) + +-- CustomLayoutSession ``` -Visualizer: +Feature activation target: ```text -audio / analysis - -> VisualizerLogicalRuntime - -> immutable latest visualizer snapshot - -> Quick visualizer item sync - -> render-thread GL node -``` - -Transition: - -```text -image pipeline - -> presentation image state - -> TransitionRun (monotonic time + parameters) - -> display presentation pacer - -> full-screen Quick render node -``` - -Ordinary widget: - -```text -existing provider/business logic - -> small Python runtime model - -> retained Quick component -``` - -Feature activation: - -```text -cheap catalog metadata +cheap descriptor/catalog metadata ↓ enabled? yes no ↓ ↓ -resolve implementation stays dormant -runtime no provider/model/resource solely for feature +resolve implementation/provider/resources stay dormant +runtime ``` --- -# 3. Selected technical direction - -## 3.1 Graphics API - -Keep the successful P0 conditions: - -- `QSG_RENDER_LOOP=threaded`; -- Qt Quick graphics API explicitly OpenGL; -- current OpenGL 4.1/core profile requirements unless source proves a transition/visualizer requires - another exact format; -- one top-level `QQuickWindow` per physical display. - -Bootstrap must happen before the first Quick window/scene graph is created. - -## 3.2 Custom GL integration - -Preferred production primitive: - -```text -QQuickItem(ItemHasContents) - -> updatePaintNode() - -> QSGRenderNode - -> direct OpenGL inside the Quick scene -``` +# 5. Phase A — bootstrap/render-node proof -Reasons: +Structurally complete for forward migration. -- inline in the scene; -- correct stacking relative to retained Quick items; -- no extra offscreen texture pass solely to re-composite custom rendering; -- matches the one-physical-surface target; -- PySide exposes `QSGRenderNode`; -- the P0 benchmark proved Python render-thread OpenGL inside `QQuickWindow`. +Settled: -The selected primitive is not limited to flat fragment-shader effects. A transition/visualizer renderer -may own context-local meshes, VAOs/VBOs, depth state, textures, shader programs, and other GL -resources when its visual contract requires them, provided all state/resource ownership remains -properly fenced and restored. +- standalone QQuickWindow path; +- threaded scene graph; +- explicit OpenGL bootstrap; +- inline Python QSGRenderNode OpenGL proof; +- presentation pacer foundation; +- lifecycle/teardown proof. -If this primitive itself becomes an actual binding/runtime blocker, stop and revise the **single -chosen Quick custom-render primitive**. Do not keep two product primitives as fallbacks. +Deferred A4 compiled smoke remains operator-scheduled after implementation unless explicitly requested earlier. -## 3.3 Presentation pacing - -Use the production presentation-only frame pacer derived from the proven P0 target-pacing semantics. - -Properties: - -- one pacer per display; -- target based on that display's refresh; -- starts only while custom dynamic content requires continuous presentation; -- transition and visible visualizer are independent frame-demand reasons; -- missed deadlines are skipped, not replayed; -- no `afterRendering -> update()` self-loop; -- no paint acknowledgement; -- no logical visualizer cadence ownership. - -Retained Quick animations may dirty the scene normally. The custom GL pacer exists for -transition/visualizer content that needs continuous render opportunities. +Do not reopen Phase A merely to reconfirm the already selected architecture. --- -# 4. Phase A — bootstrap and render-node proof +# 6. Phase B — runtime-host decomposition -Read: - -- `Docs/QtQuick_Migration/01_Runtime_Host_Lifecycle.md` -- `Docs/QtQuick_Migration/02_Scene_Renderer_Transitions.md` -- `Docs/QtQuick_Migration/06_Build_Tooling_Validation.md` +Structurally complete for forward migration. -Phase A/B foundation has already produced the standalone Quick runtime, inline render-node proof, -presentation pacing/lifecycle work, safe queued teardown, and topology reconstruction proofs. +Settled properties: -### A4 — deferred operator-only compiled smoke +- one `QuickDisplayRuntime` per selected physical display; +- per-runtime window/scene/pacer/input ownership; +- generation-scoped lifecycle including generation 0; +- queued Qt/C++ meta-call teardown for hide/release/close rather than blocking Python render-thread inversion; +- hide/wake behavior; +- coordinated one-shot exit; +- deterministic destruction barriers; +- topology replacement harnesses; +- unexpected QWindow screen displacement does not silently adopt a fallback display; +- binding loss preserves original physical identity/pacer target, quiesces presentation/input, and emits one-shot topology/binding loss. -- [ ] After migration implementation is complete, and only when the operator explicitly schedules - a build window, run the focused compiled smoke and retain the executable result. +Production `DisplayManager -> QuickDisplayRuntime` ownership still waits for Phase H. -During Phases C–G, keep build scripts, packaging inputs, and `build_runner.py` compatible and use -focused static/script tests, but do not initiate a compiled or full build. +--- -Exit concept: +# 7. Phase C — base image + transitions -```text -threaded standalone Quick -+ inline GL render node -+ clean teardown -+ production-shaped lifecycle -+ compiled-smoke inputs ready -``` +**Implementation status: complete.** -The explicit operator-run executable validation remains a final scheduled validation, not an -admission gate for Phases C–G. +Read `Docs/QtQuick_Migration/02_Scene_Renderer_Transitions.md` and `Docs/Transition_Change_Checklist.md` for the landed authoring/runtime contract. ---- +## 7.1 Transition-neutral foundation -# 5. Phase B — runtime-host decomposition +Completed: -Phase B is considered structurally complete for forward migration. +- immutable/detached presentation image boundary; +- `TransitionRequest` / `TransitionRun` lifecycle; +- monotonic progress sampling; +- generation/run fencing; +- exactly-once completion/cancel; +- shared image texture ownership; +- common GL-state fence; +- lazy static implementation registry; +- GUI/runtime-side parameter/random/default resolution; +- permanent canonical-registry ↔ Quick-registry parity gate. -Settled properties include: +## 7.2 Canonical renderer inventory -- one `QuickDisplayRuntime` per selected physical display; -- per-runtime scene/window/pacer/input ownership; -- generation-scoped lifecycle; -- queued Qt/C++ meta-call teardown for `hide`, `releaseResources`, and `close`; -- no blocking Python close/release path that can invert the GIL against the render thread; -- hide/wake preserves the runtime where intended; -- coordinated exit is one-shot; -- runtime-root/window destruction barriers complete deterministically; -- topology replacement is proven through migration harnesses; -- unexpected QWindow screen displacement does **not** silently adopt the fallback screen; -- displacement quiesces presentation and emits one-shot topology/binding loss while preserving the - runtime's original physical-display identity and pacer target. - -Do not reopen Phase B merely because later phases exercise these owners. +All 12 canonical production transitions have Quick renderers: -Production `DisplayManager -> QuickDisplayRuntime` ownership still waits for Phase H. +- Crossfade +- Slide +- Wipe +- Warp Dissolve +- Block Puzzle Flip +- 3D Block Spins +- Blinds +- Diffuse +- Ripple / Raindrops +- Crumble +- Particle +- Burn ---- +No Quick renderer depends on `GLCompositorWidget`. -# 6. Phase C — base image and all transitions +Disabled transitions remain Settings/catalog-visible as appropriate while implementation/shader/resource ownership stays dormant. -Read `Docs/QtQuick_Migration/02_Scene_Renderer_Transitions.md`. +## 7.3 Critical preserved contracts -Phase C guardrails that remain active through the renderer port: +### Slide -- preserve the completed transition-neutral `TransitionRequest` / `TransitionRun` lifecycle, - monotonic timing, generation/run fencing, and exactly-once completion/cancellation; -- resolve statically registered transition implementations lazily from lightweight canonical - catalog metadata; -- keep disabled transitions Settings-visible but out of Random/Cycle selection and do not resolve - their implementations, shaders, or transition-specific resources; -- keep transition-specific shader/math/resource behaviour in each implementation and out of the - common host/controller; -- do not create a central per-transition dispatcher or a dynamic/external plugin system; -- retire user-selectable transition easing: each canonical descriptor owns the internal authored - curve/timing authority; -- use linear timeline input for staged/physics/shader effects that already author their own timing; -- Slide uses `SINE_IN_OUT`; -- easing must never compensate for coverage or presentation-cadence defects; -- keep Slide to the four cardinal product directions and derive both image samples and their sole - viewport owner from the same immutable eased progress in one draw; -- preserve the common GL-state fence: viewport, program, VAO/VBO bindings, active texture/bindings, - blend, cull, depth enable/write/function/clear state, stencil, and any later state introduced by - migrated effects must not leak into subsequent Quick scene rendering. +Four cardinal product directions only. Source/destination sampling and pixel ownership derive from one immutable eased sample in one draw, so missed physical frames may cause a larger positional jump but never a seam/gap. -## C1/C2 — completed foundation +### 3D Block Spins -The Quick image boundary and transition-neutral run/controller work are already landed. +Real thin 36-vertex rectangular-prism slab, depth-tested front/back/sides, black void, four axes/directions, correct destination UV orientation, cubic internal spin, dark sides, moving specular band, white edge rim, context-local resource teardown. No flat fallback. -Presentation images crossing into render ownership are immutable/detached; live QPixmap/QWidget state -must not cross the render-thread boundary. +### Particle -## C3 — renderer port +Canonical shader preserved with Directional/Swirl/Converge, directional/random-placement modes, trails, swirl settings/order, wobble, texture mapping, 3D shading, gloss/light controls, seed, and physical-framebuffer resolution semantics. -Quick implementations landed through the Blinds checkpoint `6ced08b2`: +### Burn -- [x] Crossfade -- [x] Slide -- [x] Wipe -- [x] Warp Dissolve -- [x] Block Puzzle Flip -- [x] 3D Block Spins -- [x] Blinds +Canonical rich shader preserved: ignition, six directions, 4-octave/domain-warped noise, jagged front, heat distortion, glow, white-hot core, char/crackle/smoulder progression, sparks, smoke, ash, densities/toggles, per-run seed, run-clock animation time, delayed destination tail. -Remaining after that checkpoint: +## 7.4 Phase-C acceptance debt -- [ ] Diffuse -- [ ] Ripple / Raindrops -- [ ] Crumble -- [ ] Particle -- [ ] Burn -- [ ] any additional transition still active in the canonical registry when this phase is executed +The following are **sign-off**, not missing architecture/implementation: -Always inspect current HEAD before using the checklist; a later pushed checkpoint may have advanced it. +- execute focused deterministic Phase-C tests on a capable clean checkout; +- isolate current CI shutdown/hang behavior enough to obtain meaningful independent broad-suite evidence; +- run Blinds real-GL directions; +- run `tools/qtquick_phase_c_effect_smoke.py` cases for Diffuse/Ripple/Crumble/Particle/Burn; +- scheduled physical two-display variants where required; +- eyes-on old-vs-Quick authored-effect comparison; +- normal/high-refresh continuity and physical cadence only where it answers an unresolved question. -Reuse existing shader sources/program math wherever possible. +Phase D may proceed while these remain open. -Commit/push in small transition batches; use one transition per checkpoint for visually complex -effects. +If sign-off later fails, repair the smallest demonstrated Phase-C defect, checkpoint/push/audit it, then continue the active migration phase. -Do not tune transitions individually to compensate for presentation cadence. - -Use deterministic captures/tests where practical, but do not mistake a weak pixel-change assertion -for proof of a visually authored effect. - -### C3a — Slide contract - -Quick Slide is cardinal only: - -- left; -- right; -- up; -- down. - -Both source and destination coverage must be derived from the same immutable sample so the viewport -has exactly one owner at every pixel. Missed presentation intervals may advance motion farther on the -next rendered frame but must never expose a seam/microgap. - -Do not re-add diagonal Slide merely because the underlying renderer could draw it. A diagonal -full-frame translation creates exposed corner space unless a deliberately authored effect fills or -deforms that space. - -### C3b — 3D Block Spins preservation contract - -3D Block Spins is a real 3D transition, not a 2D narrowing approximation. - -Preserve: - -- one thin 36-vertex rectangular-prism slab; -- front/back/side geometry with thickness; -- depth-tested face ordering over an opaque black void; -- horizontal, vertical, and both diagonal rotation axes; -- correct spin sign for opposite directions; -- correct back-face UV orientation for each axis so the arriving image is upright rather than - mirrored/rotated; -- authored cubic internal spin timing fed from the canonical linear outer run; -- dark side-face core; -- moving direction-sensitive specular band; -- edge-on white rim treatment; -- exact source/destination endpoints; -- context-local mesh/program ownership and render-thread teardown. - -Do not add a flat-quad fallback. - -### C3c — Blinds landed; execution/physical acceptance remains - -The Blinds Quick renderer is implemented and registered. Do not treat the unchecked validation items -below as permission to rewrite the effect before running them; reopen implementation only if evidence -fails. - -Preserved implementation contract: - -- exact existing `blinds_program.fragment_source` rather than a replacement lookalike shader; -- canonical linear transition timeline; -- old effective slat grid: default seven authored columns doubled to fourteen, with rows derived from - target aspect ratio; -- Horizontal, Vertical, and Diagonal shader modes; -- resolved shader-space feather rather than raw UI-scale values or renderer-side defaults; -- authored centre-out band growth and the existing late global destination tail; -- lazy implementation/shader resolution and release through the common Quick transition host. - -Renderer admission is intentionally strict: `Random` must already be resolved to one of the three -canonical Blinds directions and `feather` must already be converted to the finite shader-space value -before the immutable request reaches render ownership. - -Implementation/test assets landed: - -- [x] `rendering/quick/transitions/implementations/blinds.py`; -- [x] lazy registry/catalog admission; -- [x] focused Blinds contract tests covering dormancy, isolated lazy resolution, authored directions, - resolved feather validation, old effective grid mapping, and exact legacy shader reuse; -- [x] `tools/qtquick_blinds_smoke.py` real-GL wrapper with a shader-math midpoint oracle for all three - authored directions. - -Validation still to execute on a capable checkout/runtime: - -- [ ] run `pytest tests/test_qtquick_blinds_transition.py tests/test_qtquick_transition_implementations.py -q --tb=short`; -- [ ] confirm the GitHub Actions Windows CI run for the Blinds checkpoint is green; the connected - Actions status did not surface a run/check during the authoring session, so this is not claimed; -- [ ] on real Windows/OpenGL run `python tools/qtquick_blinds_smoke.py --direction horizontal --windows 1`; -- [ ] repeat the real-GL smoke for `--direction vertical` and `--direction diagonal`; -- [ ] on a physical two-display system repeat the three smoke directions with `--windows 2`; -- [ ] eyes-on compare Blinds against the authored old effect and check continuity at normal and high - refresh before Phase C final acceptance. - -### C3d — Burn preservation contract - -Burn is a high-risk visual-preservation transition. Treat it as a BlockSpin-class authored effect, -even though it is implemented primarily as a full-screen shader. - -The existing effect is not merely "a noisy wipe." Preserve its actual visual stack: - -- exact old/new image endpoints; -- all currently supported burn directions, including the two diagonal directions; -- initial ignition phase before the front begins moving; -- domain-warped multi-octave noisy/jagged paper-like burn edge; -- authored jaggedness control; -- heat distortion close to the burn front; -- warm glow bleed into the old image; -- white-hot/thermite core line; -- char width and the hot-ember -> cooling ember -> dark char -> new-image progression; -- crackle/detail variation in the char zone; -- smouldering/pulsing glow; -- user/authored glow colour and glow intensity; -- sparks/embers when enabled; -- smoke wisps when enabled; -- falling ash when enabled; -- smoke/ash density controls; -- per-run seed behaviour; -- animated effect time; -- delayed near-completion tail fade that guarantees a clean final destination frame. - -Prefer reusing/extracting the existing Burn shader source and authored math rather than rewriting the -look from memory. - -Quick ownership may change; the effect's appearance contract may not be silently simplified. - -Burn-specific gates should prove at least: - -- implementation remains lazy/dormant while disabled; -- only Burn-specific implementation/resources resolve when enabled; -- required parameters are resolved before render admission rather than silently defaulted in the - renderer; -- all supported directions map correctly; -- deterministic probes demonstrate distinct unburned / glow-core-char / destination regions at - controlled progress; -- smoke/ash enablement genuinely changes their intended regions without changing core burn ownership; -- exact endpoints are clean; -- resource teardown leaves no Burn-specific GL ownership; -- common GL state is restored after Burn rendering. - -Subjective eyes-on comparison against the authored old effect remains required later when the full -Quick runtime is convenient to inspect visually. - -## Phase C exit gate - -- all registry-eligible production transitions render through Quick; -- disabled transitions remain renderer/resource dormant; -- old/new image ownership is correct; -- completion/cancel/interruption correct; -- transition-specific authored parameters and visual contracts are preserved; -- 60 Hz/high-refresh pacing remains healthy; -- no old compositor dependency exists inside the new Quick renderer implementations; -- transition-authoring documentation can now be rewritten against the final contract. +Do not broadly retune effects or revisit presenter architecture absent evidence. --- -# 7. Phase D — visualizer +# 8. Phase D — visualizer — ACTIVE Read: - `Docs/QtQuick_Migration/03_Visualizer.md` - `Docs/Guardrails/Visualizer_Presentation.md` -- Bubble BTF/guardrail documentation. +- `Docs/Guardrails/Bubble_Temporal_Fidelity.md` +- `Docs/Visualizer_Reference.md` + +## D1 — presentation-neutral runtime/controller -## D1 — separate logical controller from QWidget presentation +Separate the non-pixel visualizer owner from QWidget presentation without rewriting provider/business logic. -Do not instantiate a hidden QWidget merely to host the Quick visualizer. +Retain ownership for settings/mode/preset activation, playback state, BeatEngine/source, `VisualizerLogicalRuntime`, and latest logical publication. -Extract/retain the non-pixel visualizer controller/state needed by: +Do not instantiate a hidden QWidget merely to keep those owners alive. -- settings activation; -- playback state; -- logical runtime; -- source/BeatEngine; -- preset state; -- CUSTOM participation. +Checkpoint/push/audit this split. -## D2 — immutable render snapshot +## D2 — immutable latest-state render bridge -The old compositor layer's live-owner handle is not render-thread safe. +Publish bounded immutable current visualizer snapshots containing generation/activation identity, mode/playback identity, logical timestamp, geometry/fade/style, and mode-specific render data. -The Quick path uses immutable/current snapshots containing generation/activation identity, geometry, -fade/style, and mode-specific render data. +No render-thread reads from live QWidget/QObject/provider/Settings state. -No render-thread reads from live QWidget/QObject presentation state. +Latest state wins; no FIFO/catch-up replay. Protect short-lived authored edges explicitly. -## D3 — Quick visualizer render item +Checkpoint/push/audit the bridge separately. -Render all five modes through the Quick render node using existing authored shaders/helpers where -practical. +## D3 — Quick visualizer item/node + geometry/card foundation -Preserve: +Use one sub-rect custom Quick item/QSGRenderNode inside the display QQuickWindow. -- Spectrum; -- Oscilloscope; -- Sine; -- Bubble; -- DevCurve; -- ghosting; -- borders/masks; -- card geometry; -- fades; -- Pause/Play; -- paused Spectrum idle; -- BTF. +One committed geometry authority feeds retained card chrome and GL viewport/scissor/shader geometry/CUSTOM seam. -### Visualizer authored-clock guardrail +Preserve DPR from the owning display/window. No separate native visualizer window, QPainter fallback, or QWidget texture wrapper. -`VisualizerLogicalRuntime` remains the sole authored logical clock. +Retained Quick card presentation must preserve background/border/radius/shadow/color/fade/customization. -Preserve: +## D4 — sole authored logical clock -- every authored logical step; +`VisualizerLogicalRuntime` remains the sole mode-general authored logical clock. + +Non-negotiable: + +- every authored logical step survives; - latest-state semantics; -- no FIFO/catch-up replay; +- no FIFO/catch-up; - no paint acknowledgement; - no producer/display divisor; - no source/event decimation; - no display-refresh logical cap; -- no physical render cadence becoming simulation cadence; -- nonblocking GSMTC/media interaction; -- generation fencing/stale rejection; -- clean worker join; -- no separate visualizer native window; -- no QPainter fallback. +- render cadence never becomes simulation cadence; +- nonblocking media/GSMTC interaction; +- generation/stale fencing; +- clean worker join. -Bubble especially depends on continuous positional evolution. Do not "optimize" it by throwing away -authored steps. +## D5 — five mode ports -Commit/push at the bridge, renderer foundation, and all-five-modes milestones. +Preserve all current authored behavior for: -Exit gate includes BTF and later real eyes-on validation. +1. Spectrum — bars/peaks/ghosting, paused idle visibility, source freshness; +2. Oscilloscope — waveform/line persistence/idle behavior; +3. Sine — authored idle/layers/reactivity, no separate timer; +4. Bubble — dedicated high-risk checkpoint with BTF, continuous positional evolution, collisions, trails/tails, ghosts/pop/transients/protected edges, authored logical Hz; +5. DevCurve — active layers/order/alpha/offsets/outline/ghosting/tuning. -After Phase D exits, update visualizer/preset authoring documentation against the landed contract. +Do not retune modes to hide presentation problems. ---- +The observation that unrelated widgets can materially change measured Bubble-era GPU load supports retained-scene efficiency and true feature dormancy. It does not implicate Bubble collision logic by itself. -# 8. Phase E — widget presentation foundation +## D6 — Pause/Play and lifecycle -Read `Docs/QtQuick_Migration/04_Widget_Runtime_Presentation.md`. +Preserve warm-source/expected-state behavior without recreating the window/item or inventing a second playback authority. -Do this before porting families. +Retirement must close publication, stop/join the logical runtime, invalidate activation/generation, remove snapshot admission, release GL resources on the render owner, and destroy roots cleanly. -## E1 — descriptor cleanup +A background owner that prevents process/test shutdown is a defect. -Make canonical widget identity/settings metadata presentation-neutral. +## D7 — checkpoint cadence -Move QWidget-factory-only creation details out of the canonical descriptor authority rather than -teaching new Quick code to depend on QWidget factories. +Prefer pushed/audited checkpoints for: -## E2 — split WidgetManager ownership +1. runtime/controller split; +2. immutable bridge; +3. item/node + geometry/card foundation; +4. Spectrum; +5. Oscilloscope; +6. Sine; +7. Bubble + BTF; +8. DevCurve; +9. all-five-mode lifecycle/source/pause closure; +10. Phase-D documentation closure. -Create/rename the future `WidgetRuntimeManager` around: +## D exit -- provider/model lifecycle; -- visibility/enabled state; -- monitor participation; -- stacking inputs; -- live settings updates; -- fade intent; -- generation ownership. +All five modes use the Quick visualizer boundary with the authored logical runtime intact, immutable latest-state publication, clean lifecycle/resources, and no old compositor/QWidget presentation dependency inside the new renderer. -Move pixel/QWidget operations out as families migrate. +After implementation exit, rewrite visualizer/preset authoring guidance against the landed Quick contract. Explicit physical/eyes-on items may remain as scheduled acceptance debt if they cannot be meaningfully executed by hosted agents. -Resolve only enabled families through the static family registry. +--- -A disabled family must not own feature-specific runtime work merely because its files are installed. -Where solely owned by that family, disabled means no: +# 9. Phase E — widget presentation foundation -- model; -- provider/service/process; -- polling/timer; -- refresh callback; -- Quick component; -- family-specific presentation resource. +Read `Docs/QtQuick_Migration/04_Widget_Runtime_Presentation.md`. -Shared infrastructure remains alive only when another enabled capability still requires it. +## E1 — presentation-neutral descriptor/runtime ownership -Do not create a giant `QuickBaseOverlayWidget` Python god object. +Make canonical widget identity/settings metadata independent of QWidget factories. -## E3 — shared retained Quick visual primitives +Create/rename the future `WidgetRuntimeManager` around provider/model lifecycle, enabled/visible state, monitor participation, stacking inputs, settings updates, fade intent, generation/model registration, and actions. -Build small reusable Quick components/primitives for: +A disabled family must not own feature-specific provider/model/process/poll/timer/Quick component/resource solely because its files are installed. Shared infrastructure remains only while another enabled capability needs it. -- card background; -- border/radius; -- foreground opacity; -- card shadow; -- text/header shadow; -- image/artwork; -- separators; -- common text; -- fade/visibility; -- click targets. +Do not create a giant Python `QuickBaseOverlayWidget` god object. -## E4 — recovered eight-direction shadow feature +## E2 — shared retained Quick primitives -This is an active migration deliverable. +Build small reusable primitives for cards/backgrounds, border/radius, foreground opacity, shadows, text/header shadow, image/artwork, separators, text, fades/visibility, click targets, controls. -Add a global General-setting selector with: +## E3 — eight-direction shadow authority + +Add one global presentation-neutral direction setting: ```text NW N NE @@ -880,88 +564,23 @@ NW N NE SW S SE ``` -- eight selectable outer directions; -- selected/inset indication; -- default `SE`, matching current authored appearance; -- center is not a ninth shadow mode unless a separate product decision explicitly adds one. - -Use one canonical presentation-neutral direction authority such as: - -```text -nw, n, ne, w, e, sw, s, se -``` +Eight outer directions; default `SE`; center is not a ninth mode. -Do not keep the old ineffective `widgets.shadows.offset` as a competing authority. - -Direction changes signs; they do not flatten each shadow family's authored magnitude: - -```text -card (4, 6), SE -> (+4, +6) -card (4, 6), NW -> (-4, -6) -text (3, 3), N -> ( 0, -3) -icon (3, 4), W -> (-3, 0) -``` - -Preserve each family's authored magnitude, blur, spread, opacity and color. - -Required coverage includes: - -- cards; -- text; -- headers; -- icons/artwork; -- controls; -- volume slider; -- visualizer card; -- digital/analogue Clock details; -- Weather; -- Media; -- Reddit/Gmail; -- Steam families; -- multiple DPRs; -- CUSTOM geometry; -- no outer/content drift when direction alone changes. +Direction changes signs while preserving each family’s authored magnitude/blur/spread/opacity/color. Cover cards, text, headers, icons/artwork, controls, volume slider, visualizer, clocks, Weather, Media, Reddit/Gmail, Steam families, multiple DPRs, and CUSTOM geometry. Do not reintroduce QWidget `QGraphicsDropShadowEffect`. -Prefer bounded Quick/shader shadow primitives and keep effect topology stable during fades. - -Exit gate: - -- shared style represents current opacity/border/radius/shadow requirements; -- eight-direction selector drives every migrated shadow family correctly; -- default SE matches authored appearance; -- all signed directions have sufficient four-sided padding; -- no focus/menu/display corruption; -- no whole-screen effect layer for ordinary cards. - --- -# 9. Phase F — widget families +# 10. Phase F — widget families Port runtime pixels, not Settings GUI/backends. -Each family is its own landed checkpoint unless tiny and inseparable. - ## F0 — remove deprecated Imgur instead of porting it -Imgur is deprecated and not worth repairing. - -Remove its live product surface end to end: +Remove its live gate/defaults/settings controls/descriptor/runtime/provider/CUSTOM/tests/package/current-authority docs/Foundry metadata. Do not build compatibility around stale Imgur presentation keys. -- dev/runtime gate; -- defaults/settings model and Settings controls; -- descriptor/factory/runtime widget; -- provider/direct-network fallback; -- CUSTOM payload/support; -- tests whose only purpose is keeping Imgur alive; -- build/package references; -- current-authority documentation references; -- Defaults Foundry option metadata that refers only to Imgur. - -Do not build compatibility around stale persisted Imgur keys. - -Recommended family order after that: +Recommended family order: 1. Clock / Clock2 / Clock3 2. Weather @@ -973,31 +592,17 @@ Recommended family order after that: 8. Achievement Pulse 9. Abandonment Issues 10. Friend Pulse -11. other deliberately supported canonical runtime families - -Per family: +11. other deliberately supported canonical families -1. identify provider/model/business logic; -2. extract non-pixel logic trapped in QWidget; -3. expose a compact runtime model; -4. implement retained Quick presentation; -5. preserve current customization controls that remain part of the new product; -6. add/update deterministic model/presentation tests; -7. exercise CUSTOM geometry expectations; -8. run Quick widget gallery; -9. commit + push; -10. continue. +Per family: identify provider/business logic → compact runtime model → retained Quick presentation → preserve customization → deterministic tests/gallery → CUSTOM expectations → commit/push/audit. -Do not create screenshot-to-texture wrappers of the old QWidget as the final implementation. +Do not rewrite provider/network logic into QML or use QWidget screenshots as final presentation. -Do not rewrite provider/network logic into QML. - -After Phase F exits, rewrite widget-authoring guidance against the final family/descriptor/model/Quick -presentation contract. +After F implementation exits, rewrite widget authoring guidance for the final descriptor/model/family/Quick component contract. --- -# 10. Phase G — CUSTOM, input, interaction and auxiliary runtime pixels +# 11. Phase G — CUSTOM, input, interaction, auxiliary pixels Read `Docs/QtQuick_Migration/05_Custom_Layout_Input_Interaction.md`. @@ -1005,198 +610,82 @@ Read `Docs/QtQuick_Migration/05_Custom_Layout_Input_Interaction.md`. Refactor `CustomLayoutManager` into presentation-neutral session/state + Quick edit presentation. -Keep the useful layout math/behavior contract. - -Preferred Quick edit behaviour: - -- edit the real retained Quick widget item; -- maintain uncommitted session geometry separately from persisted settings; -- Save commits; -- Cancel restores session baseline; -- outline/handles/grid are separate Quick edit items; -- no duplicate raster snapshot shell for ordinary widgets. +Edit the real retained Quick item. Keep uncommitted session geometry separate from persisted settings. Save commits; Cancel restores baseline. Grid/outline/handles are separate Quick edit items. -For cross-monitor transfer, one presentation instance moves/recreates on the target scene; do not keep -simultaneous duplicate live pixel owners. +Cross-monitor transfer moves/recreates one presentation instance on the target scene; no simultaneous duplicate live pixel owners. -Do not spend migration effort translating pre-Quick saved widget/CUSTOM geometry. Phase H0 resets it. +Do not spend migration effort translating old QWidget geometry; H0 resets it. -## G2 — input +## G2 — input/interaction -Refactor `InputHandler` away from `DisplayWidget` type assumptions. +Refactor `InputHandler` away from DisplayWidget assumptions and route QQuickWindow events to existing actions. -Route QQuickWindow events into the same product actions. +Preserve exit gestures, hotkeys/media keys, Ctrl interaction mode, layout slots under the new schema, clicks, right-click context menu, Media Center behavior. -Preserve: +Transient QWidget control UI/settings dialog may remain if decoupled from DisplayWidget and not used as accelerated presentation. -- exit gestures; -- hotkeys; -- media keys; -- Ctrl interaction mode; -- layout slots as a new-schema feature; -- click behaviour; -- right-click context menu; -- Media Center behaviour. +## G3 — auxiliary runtime pixels -## G3 — auxiliary pixels - -Port: - -- cursor halo; -- dimming; -- pixel shift scene transform/offset; -- error/fallback display where still product-required; -- edit grid/handles; -- any remaining runtime overlay pixel owner. - -The existing QWidget context menu/settings dialog may remain if it is transient control UI, but it -must be decoupled from `DisplayWidget` parent assumptions and must not become an accelerated -presentation surface. - -Commit/push each owner slice. +Port cursor halo, dimming, pixel-shift scene transform, required error/fallback display, edit grid/handles, and any remaining runtime overlay pixel owner. --- -# 11. Phase H — settings epoch + production cutover - -No production-owner cutover until the Quick migration harness has: +# 12. Phase H — settings epoch + production cutover -- base images; -- all active transitions; -- visualizer all modes; -- all runtime widget families; -- CUSTOM; -- input/context; -- dimming/pixel shift/halo; -- multi-display; -- lifecycle; -- build/packaging inputs ready for deferred compiled validation. +No production-owner cutover until Quick implementation contains base images, all active transitions, all five visualizer modes, runtime widget families, CUSTOM, input/context, dimming/pixel shift/halo, multi-display/lifecycle, and packaging inputs ready for later compiled validation. -Compiled/installed product validation is not a cutover admission item unless the operator explicitly -schedules it. +## H0 — one-time Qt Quick settings epoch -## H0 — one-time Qt Quick settings epoch reset +Do not accumulate a museum of per-feature pre-Quick presentation migrations. -The Qt Quick production cutover is also a deliberate settings-contract epoch change. +### Preserve only an explicit durable whitelist -Backward compatibility with pre-Quick **presentation settings** is not a product requirement. +Intended durable categories: -Do **not** accumulate a museum of per-feature migration functions for easing, widget geometry, -shadows, visualizer presentation state, CUSTOM coordinates, and every other old presentation leaf. +- image/source configuration and configured locations/selections; +- credentials/tokens/secrets; +- account identities/slots/auth data; +- genuinely presentation-neutral provider/backend connection information; +- any other leaf only after inspection proves its meaning/schema survives unchanged. -At H0, create one explicit settings schema/epoch boundary. +Do not preserve an entire old subtree merely because it contains one durable leaf. -### H0.1 Preserve only a verified durable whitelist +### Reset migration-sensitive presentation state to final Quick defaults -Preserve the smallest inspected set whose meaning genuinely survives the migration. +Reset, where present: -The intended durable categories are: - -- image/source configuration, including configured local source locations/selections and other - source backend configuration still valid in the new product; -- credentials, tokens, secrets, account slots/identities and authentication data required by - retained providers; -- provider/backend connection information whose schema is demonstrably presentation-neutral; -- other data only when inspection proves it is both durable and structurally unchanged. - -The whitelist must be explicit in code/tests. Do not preserve an entire old subtree merely because it -contains one durable leaf. - -### H0.2 Reset migration-sensitive state to final Qt Quick defaults - -Everything else is reset to the then-current canonical Qt Quick defaults unless inspection proves it -belongs on the durable whitelist. - -This deliberately includes, where present: - -- transition selection; -- transition pools; -- transition durations/directions/parameters; -- removed transition easing state; -- widget enablement/presentation/style settings; -- widget positions; -- monitor routing where it is presentation state rather than account/source identity; -- widget dimensions; -- CUSTOM geometry; -- CUSTOM restore payloads; -- saved layout slots; -- presentation/display geometry assumptions; +- transition selection/pools/durations/directions/parameters/easing debris; +- widget enablement/presentation/style/position/dimensions; +- presentation monitor routing; +- CUSTOM geometry/restore payloads/layout slots; +- display geometry assumptions; - old shadow/effect settings; -- visualizer presentation settings; -- visualizer geometry; -- pre-Quick user-authored visualizer presentation presets/configuration when their schema is not - deliberately retained; -- other QWidget/QRhiWidget/compositor-era presentation state. - -Do not perform heroic coordinate conversion. If a geometry value belonged to the old presentation -space, reset it. - -For visualizers, the required post-reset product baseline is: +- visualizer presentation/geometry; +- old user visualizer presentation presets unless deliberately retained under a new-schema decision; +- other QWidget/QRhi/compositor-era presentation state. -- curated built-in presets remain valid; -- every visualizer mode still has its intended defaults/presets; -- users can edit visualizer settings; -- users can create/save new presets under the new schema. +No heroic coordinate translation. -Old user presentation presets do not need migration merely to avoid resetting them. +Built-in visualizer presets remain product baseline; users can edit/create/save new presets in the new schema. -### H0.3 Epoch operation - -Conceptually: +### Epoch operation ```text pre-Quick settings detected - ↓ -read/copy durable whitelist - ↓ -construct fresh final Qt Quick defaults - ↓ -restore durable whitelist - ↓ -atomically persist new settings epoch/version - ↓ -future starts see current epoch and do nothing +-> copy explicit durable whitelist +-> construct fresh final Quick defaults +-> restore whitelist +-> atomically persist new epoch/version through normal durability boundary +-> future current-epoch starts do nothing ``` -The operation must be safe enough that an error cannot silently destroy the preserved -credentials/source configuration. - -Use the normal ordered settings durability boundary rather than inventing a competing writer. - -### H0.4 No permanent migration archaeology - -Existing generic obsolete-key cleanup may remain while migration work is in flight. - -After the epoch reset is established and legacy presentation is deleted: - -- remove one-off migration code whose sole remaining purpose is understanding pre-Quick presentation - keys; -- remove obsolete pre-Quick presentation keys from current defaults/schema/presets; -- remove old compatibility payloads that no supported product path requires; -- retain generic settings-normalization machinery only when it has an ongoing current-schema purpose. - -The final settings implementation should understand the current Qt Quick schema, not every historical -presentation schema. - -### H0.5 Gate +Prove reset exactly once, durable source/auth data survives, presentation state resets, malformed old presentation state cannot leak through, second startup does not reset again, and persistence reaches normal durability boundary. -Before H1: +Checkpoint/push H0 before H1. -- prove a representative pre-Quick settings file resets exactly once; -- prove configured image/source data on the explicit whitelist survives; -- prove credentials/account/auth data on the explicit whitelist survives; -- prove migration-sensitive presentation state returns to current defaults; -- prove old widget/CUSTOM geometry does not leak into the new runtime; -- prove built-in visualizer presets/defaults remain usable after reset; -- prove a second startup does not reset the already-current settings; -- prove malformed/partial old presentation state cannot leak into current runtime state; -- prove persistence reaches the normal durability boundary. +## H1 — production-owner switch -**Checkpoint + push H0 before H1.** - -## H1 — explicit production-owner switch - -Make one production-owner switch: +Make one explicit switch: ```text DisplayManager @@ -1204,121 +693,66 @@ DisplayManager to QuickDisplayRuntime ``` -Change callers to the real new API. - -Do **not** preserve a `DisplayWidget` compatibility facade. +Change callers to the real new API. No DisplayWidget compatibility facade and no production flag back to QRhiWidget. -Do **not** keep a production flag to return to QRhiWidget. +Run focused/chunked gates as meaningful. Do not initiate installed/full build unless operator scheduled. -Run focused + chunked tests. Do not initiate installed/compiled smoke unless explicitly scheduled by -the operator. - -**Commit + push the cutover immediately when green.** +Checkpoint/push cutover immediately when accepted. --- -# 12. Phase I — immediate legacy removal - -This is part of migration completion, not optional someday cleanup. +# 13. Phase I — immediate legacy removal -Use `Future_Cleanup.md` as the deletion ledger. +Use `Future_Cleanup.md` as deletion ledger. -After production cutover is stable, remove in small proven batches: +After cutover, remove in small proven batches: - QRhiWidget physical presenter; - `GLCompositorWidget` scheduling/presentation ownership; -- old GL RHI surface helpers with no remaining caller; +- old GL RHI surface helpers without callers; - compositor visualizer layer; - old GUI `present_tick` paths; -- old QWidget runtime widget presentation classes once no settings/test owner requires them; -- old QWidget CUSTOM edit-shell/grid presentation if fully replaced; -- dead transition classes whose only purpose was `GLCompositorWidget`; +- old QWidget runtime widget presentation classes after settings/test consumers move; +- old QWidget CUSTOM edit shell/grid presentation; +- dead transition controller classes whose only purpose was old compositor presentation; - obsolete effect/cache-busting presentation code; -- legacy transition presenter/factory consumers; -- per-feature pre-Quick presentation-setting migration helpers no longer required after H0; +- legacy presenter/factory consumers; +- one-off pre-Quick presentation migration helpers obsolete after H0; - migration-only scaffolding. -Do not delete presentation-neutral authored shader/math assets merely because the old compositor also -used them. Shared authored effect assets may survive when the Quick implementation is their real -consumer. +Do not delete presentation-neutral authored shaders/math merely because the old compositor also used them; shared assets survive when Quick is their real consumer. -For every deletion batch: +For every deletion batch: caller proof → focused tests → commit → push → audit → continue. -```text -rg/caller proof --> focused tests --> git commit --> git push --> continue -``` - -Do not leave both presenter architectures "for safety." +Do not leave both presenter architectures “for safety.” --- -# 13. Phase J — final tooling, build, lifecycle, performance and beyond-parity close +# 14. Phase J — Defaults Foundry, final validation, documentation closure Read `Docs/QtQuick_Migration/06_Build_Tooling_Validation.md`. -Compiled/full-build items below are operator scheduled after migration implementation is complete. -Reaching this phase alone does not authorize an agent to initiate them. +## J0 — retarget Defaults Foundry -## J0 — retarget Defaults Foundry to the final Qt Quick settings/defaults schema +Current tool: `tools/default_settings_editor.py`. -Defaults Foundry is an essential project tool and must remain usable after the migration. +It currently reads canonical `DEFAULT_SETTINGS` directly via AST/literal, recursively edits leaves, writes Normal base + MC differential, and regenerates snapshot/SST artifacts. -Current tool: +After H0/H1/I establish final schema: -```text -tools/default_settings_editor.py -``` +- keep direct literal-reading if `core/settings/default_settings.py` remains canonical; +- otherwise retarget explicitly; +- remove deleted metadata such as Imgur; +- add finite-value metadata for new canonical settings such as shadow direction; +- remove retired compatibility-preservation behavior; +- align import/filter rules with H0 durable-data policy; +- regenerate default snapshots and Normal/MC SSTs; +- update parity tests and `Docs/Defaults_Guide.md`; +- keep the standalone Foundry QWidget UI unless a separate tooling decision changes it. + +## J1 — operator-scheduled final validation -It currently reads the canonical `DEFAULT_SETTINGS` Python literal directly using AST/literal -inspection, derives the editable tree recursively, writes the canonical Normal base and MC -differential, and regenerates snapshot/SST artifacts. - -Do not accidentally strand it on pre-Quick schema assumptions. - -After H0/H1/I have made the final settings/defaults shape clear: - -- inspect whether `core/settings/default_settings.py` remains the canonical literal authority; -- if it remains authoritative, preserve the useful direct literal-reading design and update the - Foundry for the final schema rather than rewriting its loader unnecessarily; -- if canonical default authority moved for a justified reason, retarget the Foundry explicitly; -- remove hard-coded option metadata for deleted settings/families such as Imgur; -- add/update finite-value metadata for new canonical settings such as the eight-direction shadow - authority where appropriate; -- remove Foundry behavior whose only purpose was preserving retired pre-Quick compatibility payloads; -- make import filtering/preservation rules agree with the H0 durable-data policy and the final - current-schema reset/import contracts; -- ensure new canonical settings appear in the recursive tree; -- ensure the Foundry does not inspect, migrate, or rewrite installed user settings merely because - defaults are edited; -- regenerate: - - defaults snapshot artifacts; - - Normal SST defaults; - - MC SST defaults; -- update defaults parity tests and `Docs/Defaults_Guide.md`. - -The Foundry's standalone QWidget control UI does **not** need to be rewritten into Qt Quick merely -because the screensaver runtime migrated. Its required migration is schema/tooling correctness unless -a separate product/tooling decision explicitly chooses a UI rewrite. - -Gate: - -- Foundry loads the final canonical defaults without retired presentation debris; -- Save and Regenerate is transactional; -- MC remains a compact differential over Normal; -- generated JSON/SST artifacts exactly match canonical defaults APIs; -- two unchanged SST regeneration runs remain deterministic; -- no credential/private installation data leaks into checked-in defaults artifacts; -- Foundry can edit every intended current default leaf. - -**Checkpoint + push the Foundry retarget before final documentation closure.** - -## J1 — final validation - -Required, when operator scheduled: +When explicitly scheduled, validate: - script RUN; - normal compiled `.scr`; @@ -1333,48 +767,47 @@ Required, when operator scheduled: - monitor off/wake/topology recreation; - clean shutdown; - resource baseline; -- PresentMon cadence check; -- external heavy-load resilience check; -- long-soak on final architecture. +- PresentMon cadence where useful; +- external heavy-load resilience; +- long soak. + +Do not rerun obsolete manual worker-heavy baselines merely out of habit. -Do not rerun the old manual worker heavy baseline. +Beyond-parity closure should show no QWidget effect-cache shadow architecture, no per-widget accelerated surfaces, retained Quick widgets not rebuilding stable content every physical frame, clean render-thread ownership, true disabled-feature dormancy, and decomposition of overloaded old presentation modules. -Beyond-parity acceptance should show at least: +## J2 — documentation closure -- no QWidget effect-cache shadow architecture; -- fewer presentation-specific GUI callbacks than the old path; -- no per-widget accelerated surfaces; -- retained Quick widgets do not repaint/rebuild stable content every physical frame; -- transition/visualizer renderer uses render-thread ownership cleanly; -- disabled transition/widget families have no feature-specific runtime/resource ownership; -- overloaded old presentation modules have been decomposed rather than renamed wholesale. +Update current-authority docs to landed class/file names; make Quick transition/widget/visualizer authoring guides sole current implementation authority; update Defaults guide; remove current instructions that teach dead QWidget/QRhi/compositor owners. + +Preserve historical bug/evidence documents as history rather than rewriting them as current architecture. --- -# 14. Documentation closure +# 15. Current next work + +Normal implementation work is now **Phase D**. -When migration lands: +Start by inspecting the exact current visualizer ownership/source before changing it, then execute: -1. update `Spec.md`, `Index.md`, `Docs/Contracts.md`, architecture/guardrails to landed class/file - names; -2. mark/remove completed migration decomposition docs according to - `Docs/Documentation_Maintenance.md`; -3. retain P0 evidence; -4. update `Future_Cleanup.md` to contain only genuinely deferred debt; -5. ensure no current-authority doc calls QRhiWidget the production runtime owner; -6. make Quick transition/widget/visualizer authoring guides the sole current implementation authority; -7. update `Docs/Defaults_Guide.md` for the H0 settings epoch and final Defaults Foundry behavior; -8. remove current-authority instructions that tell agents to preserve deleted pre-Quick presentation - keys/owners. +```text +D1 runtime/controller split +-> checkpoint/push/audit +D2 immutable latest-state bridge +-> checkpoint/push/audit +D3 Quick item/node + geometry/card foundation +-> checkpoint/push/audit +mode ports, with Bubble dedicated +-> all-five-mode lifecycle/source audit +-> Phase-D docs closure +``` -Migration is not complete while docs still teach agents to preserve dead presentation owners. +Do not wait for Phase-C eyes-on/hardware sign-off unless the Phase-D change directly depends on the unresolved evidence. -Historical evidence remains historical evidence; do not rewrite old bug records as though they were -authored under the new architecture. +Do not start E–J or `Future_Work.md` opportunistically while D is active. --- -# 15. Cross-links +# 16. Cross-links Technical decompositions: @@ -1386,6 +819,23 @@ Technical decompositions: - `Docs/QtQuick_Migration/05_Custom_Layout_Input_Interaction.md` - `Docs/QtQuick_Migration/06_Build_Tooling_Validation.md` +Durable routing/guardrails: + +- `Index.md` +- `Spec.md` +- `Docs/Contracts.md` +- `Docs/Compositor_Architecture.md` +- `Docs/Guardrails.md` +- `Docs/Guardrails/Visualizer_Presentation.md` +- `Docs/Guardrails/Bubble_Temporal_Fidelity.md` + +Current transition/visualizer references: + +- `Docs/Transition_Change_Checklist.md` +- `Docs/Harness_Index.md` +- `Docs/TestSuite.md` +- `Docs/Visualizer_Reference.md` + Defaults/tooling: - `Docs/Defaults_Guide.md` @@ -1393,12 +843,7 @@ Defaults/tooling: - `tools/regenerate_defaults_snapshot_artifacts.py` - `tools/regenerate_sst_defaults.py` -Deletion ledger: +Deletion/deferred scope: - `Future_Cleanup.md` - -Durable architecture/current-authority docs: - -- `Spec.md` -- `Docs/Compositor_Architecture.md` -- `Docs/Guardrails.md` +- `Future_Work.md` diff --git a/Docs/Compositor_Architecture.md b/Docs/Compositor_Architecture.md index 7b8a49f9..11c29979 100644 --- a/Docs/Compositor_Architecture.md +++ b/Docs/Compositor_Architecture.md @@ -1,6 +1,6 @@ # Runtime Presentation Architecture -Last updated: 2026-08-20 +Last updated: 2026-08-21 ## 1. Decision @@ -33,16 +33,17 @@ DisplayWidget └── OpenGL QRhiWidget path ``` -During migration that code is a **reference/rollback implementation**, not the destination. +Until Phase H cutover that code may remain the current production/reference implementation. It is +not the destination and is not a permanent fallback architecture. Do not: - expand the old presenter to avoid migration work; - create new QRhiWidget-specific architecture; -- treat current class names as permanent product contracts. +- treat current old class names as permanent product contracts; +- add a production runtime switch between old and Quick presenters. -Do not delete the reference path until the active migration plan has established the replacement and -passed the required cutover gates. +Do not delete the reference path until the active migration plan reaches the cutover/deletion phases. ## 3. One-surface invariant @@ -54,26 +55,27 @@ Allowed inside that surface: - transition rendering; - visualizer/card; - runtime overlays; -- compositor-equivalent custom render items. +- retained Quick widgets; +- inline custom render nodes. Forbidden: - separate native visualizer window; - transparent accelerated overlay window; - per-widget accelerated top-level surface; -- `QQuickWidget` as the runtime presenter. +- `QQuickWidget` as the runtime presenter; +- per-effect fallback to an independently presented old surface. ## 4. Threading model The destination presenter requires the Qt Quick **threaded** scene-graph render loop on the supported Windows path. -The GUI thread remains responsible for GUI/event-loop work and may prepare/publish state. +The GUI thread remains responsible for GUI/event-loop work and may prepare/publish synchronized state. -The Quick render thread owns the rendering phase according to the selected Qt Quick primitive. +The Quick render thread owns custom rendering according to the selected inline scene-graph primitive. -Do not move visualizer logical simulation onto the render thread merely because a render thread now -exists. +Do not move visualizer logical simulation onto the render thread merely because a render thread exists. `VisualizerLogicalRuntime` remains independent and authoritative for authored visualizer time. @@ -103,30 +105,41 @@ Properties: - generation fencing; - stale-state rejection. -The exact bridge may use Qt properties/models, explicit synchronization objects, custom item -`synchronize()` state, or another bounded mechanism chosen by the migration plan. +The exact bounded GUI→Quick synchronization object may vary by owner, but those semantics may not. -## 6. Renderer primitives +## 6. Selected renderer primitives -Do not lock the product to one primitive before the migrated scene requires it. +The ordinary retained-presentation primitive is normal Qt Quick items/components. -Possible shapes include: +The selected SRPSS custom-OpenGL primitive is: -- ordinary retained Quick items; -- shader/effect items; -- `QQuickRhiItem`; -- `QSGRenderNode`; -- custom render-stage integration. +```text +QQuickItem(ItemHasContents) + -> updatePaintNode() + -> QSGRenderNode + -> direct OpenGL inside the owning QQuickWindow scene +``` + +This choice was proved during the Qt Quick foundation and is the current custom-render contract for +transitions and the visualizer migration. + +Why this is the selected path: -Prefer the simplest primitive that: +- custom rendering stays inline in the one scene; +- correct stacking with retained Quick content; +- no extra offscreen texture/composite pass solely to reinsert the effect; +- supports existing shaders, meshes, depth, VAOs/VBOs, and context-local resources; +- preserves one physical presentation surface. -- preserves exact visual fidelity; -- keeps one top-level presentation surface; -- respects thread/resource ownership; -- meets physical cadence requirements. +`QQuickRhiItem` is not the normal/final SRPSS custom-render path. `QQuickWidget` is prohibited. -A local native/C++ renderer may be considered only after profiling proves Python callback/render -cost is material. It must remain inside the accepted Quick window architecture. +If pinned PySide/compiled-product evidence proves the selected `QSGRenderNode` seam fundamentally +unusable, stop and deliberately revise the **single** custom-render primitive. Do not keep multiple +product primitives as compatibility fallbacks. + +A localized native/C++ renderer may be considered only if profiling of the migrated implementation +proves a specific Python render callback materially limits the result. It must stay inside the same +QQuickWindow/scene ownership. ## 7. Visualizer @@ -137,17 +150,19 @@ source/audio ↓ VisualizerLogicalRuntime ↓ -latest logical/render state +latest immutable logical/render state + ↓ +Quick visualizer item synchronization ↓ -Quick scene presentation +QSGRenderNode custom GL ``` The logical runtime never mutates Quick scene objects or GPU resources. The presenter never advances authored visualizer simulation. -Card and visualizer pixels share one scene/fade authority where they must appear as one authored -visual object. +Card and visualizer pixels share one scene/fade/geometry authority where they must appear as one +authored visual object. ## 8. Runtime overlays @@ -160,19 +175,18 @@ existing Python data/model owner ↓ small presentation state ↓ -Quick runtime item/layer +retained Quick runtime item/layer ``` Avoid reimplementing network/provider/business logic in QML. -The one Quick scene should own runtime pixels that visually coexist over the screensaver. +The one Quick scene owns runtime pixels that visually coexist over the screensaver. ## 9. Readiness / first frame -A runtime window must not be visibly exposed until it can show intentional current-generation -content. +A runtime window must not be visibly exposed until it can show intentional current-generation content. -Eventually preserve: +Preserve: - no white/default flash; - no black placeholder; @@ -193,18 +207,22 @@ Do not make real audio/source freshness a universal prerequisite for an intentio Topology, Settings/recreate, Edit, and shutdown remain generation-owned. -Old generation must retire before replacement gains authority. +Old generation retires before replacement gains authority. + +Quick scene/render resources are destroyed on the legal render/context owner for the selected +`QSGRenderNode` contract. -Quick scene/render resources must be destroyed on the legal owner/thread for the selected primitive. +Do not copy QRhiWidget-specific context assumptions into Quick. -Do not copy QRhiWidget-specific context assumptions into Quick without verifying the new ownership -contract. +Generation `0` remains valid. ## 11. Transition model -Transition logical/progress semantics remain display-local and monotonic with exactly-once completion. +Transition request/run semantics are display-local, immutable, monotonic, and exactly-once for +completion/cancellation. -The migration should preserve existing transition shaders/behaviour where practical. +Canonical transition implementations resolve lazily and render through the display's inline Quick +custom-render owner. Existing authored shader/math is preserved where valid. Do not individually retune transitions to hide physical frame holes. @@ -220,5 +238,5 @@ Physical presentation is judged primarily by: Internal render callbacks are not physical-display proof. -The P0 result justifies migration. Future evidence is for implementation/cutover quality, not for +The P0 result justifies migration. Later evidence is for implementation/cutover quality, not for re-litigating Quick versus the old presenter on every step. diff --git a/Docs/Contracts.md b/Docs/Contracts.md index 023f28ea..8a18c43b 100644 --- a/Docs/Contracts.md +++ b/Docs/Contracts.md @@ -1,6 +1,6 @@ # Contracts -Last updated: 2026-08-20 +Last updated: 2026-08-21 Fast task-to-owner routing during the Qt Quick presentation migration. @@ -23,30 +23,45 @@ Do not turn temporary old ownership into a new permanent contract. | Family | Durable owner/direction | Focused document | |---|---|---| -| Runtime start/stop/recreate | `ScreensaverEngine` / display lifecycle owners | `Docs/Compositor_Architecture.md` | -| Monitor topology | `DisplayManager` / topology owner | `Docs/Compositor_Architecture.md` | -| Runtime physical surface | destination: one standalone `QQuickWindow` per display | `Docs/Compositor_Architecture.md` | -| Runtime scene pixels | destination: Quick scene/render owner | `Docs/Compositor_Architecture.md` | +| Runtime start/stop/recreate | `ScreensaverEngine` / display lifecycle owners | `Docs/QtQuick_Migration/01_Runtime_Host_Lifecycle.md` | +| Monitor topology | `DisplayManager` / topology owner | `Docs/QtQuick_Migration/01_Runtime_Host_Lifecycle.md` | +| Runtime physical surface | one standalone `QQuickWindow` per display | `Docs/Compositor_Architecture.md` | +| Ordinary runtime scene pixels | retained Quick items/components | `Docs/Compositor_Architecture.md` | +| Custom GL scene pixels | inline `QQuickItem -> QSGRenderNode -> OpenGL` | `Docs/Compositor_Architecture.md` | | Settings/config UI | existing QWidget/settings owners | `Spec.md` | -| Widget data/provider lifecycle | existing Python owners | `Docs/10_WIDGET_GUIDELINES.md` | -| Runtime widget pixels | destination: display Quick scene | `Docs/10_WIDGET_GUIDELINES.md` | +| Widget data/provider lifecycle | existing/refactored Python owners | `Docs/QtQuick_Migration/04_Widget_Runtime_Presentation.md` | +| Runtime widget pixels | destination: display retained Quick scene | `Docs/QtQuick_Migration/04_Widget_Runtime_Presentation.md` | | General async work | `ThreadManager` | `Docs/Guardrails/Runtime_Efficiency.md` | | Resource accounting | `ResourceManager`; never deletion fallback | `Docs/Guardrails.md` | +`QQuickRhiItem` is not the normal SRPSS custom-render path. `QQuickWidget` is not an acceptable runtime presenter. + +## Transition ownership + +| Family | Owner | Contract | +|---|---|---| +| Canonical id/settings identity | `rendering/transition_registry.py` | stable descriptor/catalog authority | +| GUI/runtime parameter resolution | Quick transition request resolver | canonical defaults/random choices resolved before render ownership | +| Transition lifecycle/time | `TransitionRequest` / `TransitionRun` | immutable, monotonic, exactly-once completion/cancel | +| Transition implementation | lazy static Quick implementation registry | disabled implementations/resources remain dormant | +| Transition pixels/resources | display transition `QSGRenderNode` host + implementation | no old-compositor fallback or state leak | + +See `Docs/Transition_Change_Checklist.md` and `Docs/QtQuick_Migration/02_Scene_Renderer_Transitions.md`. + ## Visualizer ownership | Family | Owner | Contract | |---|---|---| | Audio capture / analysis | BeatEngine + audio worker/backend | bounded current source | -| Logical cadence | `VisualizerLogicalRuntime` | one authored mode-general clock | +| Logical cadence | `VisualizerLogicalRuntime` | sole authored mode-general clock | | Logical integration | worker-callable tick pipeline | no GUI/Quick/GL mutation | -| Logical publication | latest-state mailbox/state bridge | latest wins; generation fenced | -| Presentation bridge | migration-owned bounded GUI/Quick synchronization | no paint acknowledgement | -| Visualizer pixels | destination: Quick scene/render item(s) | inside sole display window | +| Logical publication | latest-state mailbox/snapshot bridge | latest wins; generation fenced | +| Presentation bridge | migration-owned bounded GUI/Quick synchronization | immutable; no paint acknowledgement | +| Visualizer pixels | display Quick visualizer item + `QSGRenderNode` | inside sole display window | | Bubble temporal fidelity | shared chain + Bubble authored state | BTF binding | -The historical `SpotifyBarsGLOverlay` may remain as temporary state/resource code during migration. -Its class name is not a contract and it must not become a separately presented surface. +The historical `SpotifyBarsGLOverlay` may remain as temporary state/resource/reference code during +migration. Its class name is not a contract and it must not become a separately presented surface. ## Physical presentation @@ -57,6 +72,8 @@ QQuickWindow per display ↓ threaded scene-graph render loop ↓ +retained Quick items + inline QSGRenderNode custom GL + ↓ one composed runtime scene ``` @@ -64,6 +81,7 @@ Forbidden: - `QQuickWidget` presenter; - second accelerated visualizer window; +- per-effect old-compositor fallback; - paint/present acknowledgement; - producer/display divisor gating; - FIFO/catch-up; @@ -75,7 +93,7 @@ Forbidden: There is no scheduled native/C++ presenter migration. Native code may be used only for a measured local renderer problem and must preserve the one -`QQuickWindow` presentation topology. +`QQuickWindow` presentation topology and the same logical/state contracts. ## Readiness @@ -102,6 +120,8 @@ A presentation-owned idle scene may reveal while real reactive source is unavail | Active work | `Current_Plan.md` | | Stable architecture | `Spec.md` | | Presentation architecture | `Docs/Compositor_Architecture.md` | +| Cross-cutting safety | `Docs/Guardrails.md` | +| Transitions | `Docs/Transition_Change_Checklist.md` | | Visualizer presentation | `Docs/Guardrails/Visualizer_Presentation.md` | | Bubble | `Docs/Guardrails/Bubble_Temporal_Fidelity.md` | | Qt Quick architecture evidence | `Docs/Performance_Evidence/QtQuick-P0-Comparison-2026-08-20.md` | diff --git a/Docs/Harness_Index.md b/Docs/Harness_Index.md index e9c45003..f73b1112 100644 --- a/Docs/Harness_Index.md +++ b/Docs/Harness_Index.md @@ -1,21 +1,74 @@ # Harness Index -Last updated: 2026-08-20 +Last updated: 2026-08-21 -Compact routing for recurring investigation commands. +Compact routing for recurring investigation and migration sign-off commands. -Harness success is evidence, not final visual/timing/lifecycle sign-off. +Harness success is evidence, not final visual/timing/lifecycle sign-off. Hosted CI is especially useful for deterministic source/runtime contracts; it is not authoritative for physical display cadence, GPU utilization, subjective motion feel, or real multi-monitor topology. -## 1. Full / targeted tests +## 1. Targeted tests first + +Prefer the smallest test set that can falsify the current slice: ```powershell -python tests/run_chunked.py --chunks 4 --timeout-seconds 900 pytest path\to\test_file.py -q --tb=short ``` -Use `Docs/TestSuite.md`. +Use `Docs/TestSuite.md` for broader suite guidance. -## 2. Visualizer authored-fidelity replay +The repository also has the chunk wrapper: + +```powershell +python tests/run_chunked.py --chunks 4 --timeout-seconds 900 --log +``` + +Do not treat a red full-suite chunk run as proof the active migration slice failed until the uploaded per-chunk log is inspected. + +### Current GitHub Actions caveat — 2026-08-21 + +Windows CI run `32436553793` exposed distinct failure modes: + +1. `actions/checkout` used its default shallow history (`fetch-depth: 1`). At least one existing Bubble guardrail calls `git show 510520e:...`, so that test deterministically fails in a shallow checkout even though the historical commit exists in the real repository. +2. Chunk 2 printed a complete pytest summary (`3 failed, 1219 passed, 67 skipped`) in about 25 seconds but the Python process did not exit. `tests/run_chunked.py` therefore killed the still-live process at its own 900-second timeout. That shape strongly suggests interpreter-shutdown/background-owner leakage rather than a 15-minute test body; the exact surviving owner still requires isolation. +3. Chunk 3 is different: its log stops around 50% test execution progress with no pytest summary, so it requires test-level isolation to determine the actual hanging test/owner. + +The outer GitHub job itself was not cut off: its job timeout was 70 minutes and it completed normally after the wrapper returned failure. + +When CI is repaired, make history available for history-dependent tests (`fetch-depth: 0` or an intentionally sufficient fetch) and isolate hangs with smaller/verbose chunks or explicit thread/process diagnostics. Do not merely increase the 900-second timeout. + +## 2. Phase-C Quick transition sign-off + +### Blinds + +```powershell +python tools\qtquick_blinds_smoke.py --direction horizontal --windows 1 +python tools\qtquick_blinds_smoke.py --direction vertical --windows 1 +python tools\qtquick_blinds_smoke.py --direction diagonal --windows 1 +``` + +Repeat with `--windows 2` on the physical dual-display system when that sign-off is scheduled. + +### Remaining parameterized effects + +Use: + +```powershell +python tools\qtquick_phase_c_effect_smoke.py --effect --case --windows 1 +``` + +Canonical cases exposed by the wrapper: + +- Diffuse: rectangle, membrane, lines, diamonds, amorph, random +- Ripple: count1, count3, count8 +- Crumble: top, bottom, random-weighted, random-choice, age-weighted +- Particle: authored mode/direction cases including directional variants plus swirl/converge +- Burn: six directions plus smoke/ash toggle cases + +Use `--windows 2` only for scheduled physical multi-display evidence. + +A smoke pass proves the exercised renderer/runtime contract. Eyes-on old-vs-Quick effect fidelity remains a separate acceptance statement. + +## 3. Visualizer authored-fidelity replay ```powershell $env:QT_QPA_PLATFORM='offscreen' @@ -23,11 +76,11 @@ $env:QT_QPA_PLATFORM='offscreen' .\.venv\Scripts\python.exe tools\visualizer_replay.py metrics ``` -Do not regenerate goldens to accommodate presentation migration. +Do not regenerate goldens merely to accommodate the presentation migration. -For Bubble also apply BTF. +For Bubble, also apply `Docs/Guardrails/Bubble_Temporal_Fidelity.md`. -## 3. Logical-runtime gates +## 4. Logical-runtime gates Search current tests by contract: @@ -37,46 +90,48 @@ rg -n "VisualizerLogicalRuntime|generation 0|mode switch|Spectrum|Pause|BTF|sing Required principles: -- authored cadence remains healthy; +- `VisualizerLogicalRuntime` is the sole authored mode-general logical clock; +- every authored logical step is preserved; +- latest-state semantics, no FIFO/catch-up replay; +- no paint acknowledgement; +- no producer/display divisor; +- no source/event decimation; +- no display-refresh logical cap; - worker cannot mutate GUI/Quick/GPU; -- exactly one logical clock; -- generation zero valid; +- generation zero is valid; - protected visible edges survive; -- source freshness remains separate from presentation. +- source freshness remains separate from presentation; +- worker joins cleanly. -## 4. Qt Quick migration checks +## 5. Qt Quick migration checks -Use the existing P0 harness/evidence as the architecture-selection record. +Use existing P0 evidence as the architecture-selection record; do not keep expanding P0 merely to reconfirm the chosen presenter. -Do **not** keep expanding P0 merely to reconfirm the choice. - -For production migration slices, focused harnesses should prove: +Focused Quick harnesses should prove, as relevant: - standalone QQuickWindow; - threaded scene graph; - current-generation state delivery; - first intentional frame; -- migrated visual parity; +- immutable render-boundary state; - Settings/recreate; -- topology; -- resource cleanup. - -## 5. Physical evidence +- topology/binding loss; +- resource cleanup; +- exact effect/visualizer contract being migrated. -When internal callbacks are insufficient, capture OS/display-boundary evidence. +## 6. Physical evidence -Correlate physical samples to phase timestamps. +When internal callbacks are insufficient, capture OS/display-boundary evidence and correlate it to intentional active-motion windows. -Do not interpret pre-intentional-window startup/capture rows as active-animation cadence holes. +Do not interpret startup/capture rows before intentional presentation as active-animation cadence holes. -Be cautious deriving "displayed FPS" from non-NA GDI `DisplayedTime` row counts when those rows do not -form continuous display occupancy. +Do not infer continuous displayed FPS from sparse/non-occupancy GDI `DisplayedTime` rows. -Use tails/severe gaps plus phase correlation. +Use p95/p99/tails/severe gaps plus phase correlation when cadence evidence is actually needed. -## 6. Runtime diagnostic flags +## 7. Runtime diagnostics -Use only relevant existing families such as: +Use only relevant existing flag families such as: ```text --perf @@ -87,11 +142,12 @@ Use only relevant existing families such as: --set --life --cache +--steam ``` Keep observer overhead named. -## 7. Lifecycle +## 8. Lifecycle Check: @@ -100,10 +156,13 @@ Check: - generation zero; - Quick scene/window retirement; - render-resource retirement; -- no retired callback publication. +- no retired callback publication; +- no background thread/process preventing test or product shutdown. + +A pytest summary followed by a process that never exits strongly suggests leaked shutdown ownership and should be diagnosed rather than hidden by a larger timeout. -## 8. Historical evidence +## 9. Historical evidence -Historical harnesses may describe old QRhiWidget/QOpenGLWidget paths. +Historical harnesses may describe old QRhiWidget/QOpenGLWidget/GLCompositor presentation paths. They remain evidence, not architecture instructions. -Do not copy historical presentation mechanisms back into the migration because a harness is detailed. +Do not copy a historical presentation mechanism back into the Qt Quick migration simply because the historical harness is detailed. diff --git a/Docs/QtQuick_Migration/02_Scene_Renderer_Transitions.md b/Docs/QtQuick_Migration/02_Scene_Renderer_Transitions.md index a4bcf338..bb8a73a1 100644 --- a/Docs/QtQuick_Migration/02_Scene_Renderer_Transitions.md +++ b/Docs/QtQuick_Migration/02_Scene_Renderer_Transitions.md @@ -1,412 +1,359 @@ # 02 — Quick Scene Renderer, Images, Transitions and Pacing -Status: technical decomposition only -Last updated: 2026-08-20 +Status: Phase-C landed architecture / current transition-authoring authority +Last updated: 2026-08-21 Cross-links: -- sequence: `Current_Plan.md` -- cleanup: `Future_Cleanup.md` -- transition checklist: `Docs/Transition_Change_Checklist.md` +- sequence and phase admission: `Current_Plan.md` +- transition authoring/checklist: `Docs/Transition_Change_Checklist.md` +- runtime/lifecycle: `Docs/QtQuick_Migration/01_Runtime_Host_Lifecycle.md` +- validation routing: `Docs/Harness_Index.md` +- deferred deletion: `Future_Cleanup.md` -## 1. Why not QQuickRhiItem as the default SRPSS custom renderer +## 1. Selected scene/render architecture -`QQuickRhiItem` is the Quick counterpart of QRhiWidget: render to an offscreen color texture, then -composite that texture into the scene. - -SRPSS is migrating specifically to avoid unnecessary presentation layers. - -Preferred first production proof: +The production destination remains one standalone threaded `QQuickWindow` per selected physical display with custom OpenGL rendered inline in the Quick scene. ```text -QQuickItem(ItemHasContents) - -> QSGRenderNode - -> inline OpenGL commands +QQuickWindow + -> Quick scene + -> full-screen background/transition QQuickItem + -> QSGRenderNode + -> direct OpenGL + -> visualizer QQuickItem/QSGRenderNode + -> retained Quick widgets/overlays ``` -This keeps custom content in the main Quick scene render ordering without an extra item-sized -offscreen render target. - -The choice is not irrevocable until the first primitive proof passes pinned PySide 6.9.1 + compiled -smoke. If it is fundamentally blocked, revise the single chosen primitive before porting the product. -Do not keep two runtime renderers. +`QQuickWidget` is prohibited. `QQuickRhiItem`/offscreen-composite presentation is not the normal SRPSS custom-render path. A second accelerated visualizer/transition window is prohibited. -## 2. Proposed renderer structure +Qt Quick bootstrap remains explicit before the first Quick scene graph exists: -```text -rendering/quick/ - render/ - background_item.py - background_node.py - visualizer_item.py - visualizer_node.py - gl_resources.py - image_textures.py - transition_renderer.py - transition_state.py -``` +- threaded render loop; +- OpenGL graphics API; +- current OpenGL 4.1/core requirements unless exact source proves otherwise. -### Full-screen background node +The P0 evidence already selected this architecture. Do not reopen presenter comparison without contradictory implementation evidence. -Draws: +## 2. Render-thread ownership -- stable base image; -- active old/new transition; -- transition-specific overlays/particles. +A custom render node may own, when needed: -### Visualizer node +- GL programs; +- VAOs/VBOs/meshes; +- image textures; +- transition-specific buffers; +- depth state/resources; +- later visualizer-specific GPU resources. -A separate scene-graph item/node at the visualizer card geometry/z position. +GUI/runtime state crossing into render ownership must be immutable/synchronized. The render thread must not read live QWidget/QPixmap, SettingsManager, provider objects, `SpotifyVisualizerWidget`, or arbitrary QObject state. -It is still inside the same `QQuickWindow`. +Create/use/delete context-local GL resources on the legal render owner. Failed deletion remains accounted/loud until ownership is actually released. -This lets ordinary retained Quick widgets participate naturally above/below it without a second -window or offscreen QWidget surface. +## 3. Common GL state fence -## 3. Render-thread ownership +Transition rendering must leave the Quick scene graph safe for later nodes. -Render node owns: +The common transition host is responsible for preserving/restoring every state touched by an implementation, including at least: -- GL programs; -- VAO/VBO; -- textures; -- per-transition GPU buffers; -- visualizer GPU resources used by that node. +- viewport/scissor; +- program; +- VAO/VBO bindings; +- active texture and texture bindings; +- blend enable/function; +- cull state; +- depth enable/write/function/clear state; +- stencil state; +- any later state introduced by a migrated effect. -GUI/runtime state must be synchronized into immutable render-node state. +A complex implementation may use depth or custom meshes. It may not leak those settings into the next Quick node. -The render thread must not read live: +## 4. Immutable image boundary -- `QPixmap`; -- QWidget; -- `QQuickItem` properties outside the permitted sync/updatePaintNode phase; -- `SpotifyVisualizerWidget`; -- provider objects; -- SettingsManager. +The existing engine/image pipeline may continue to produce `QPixmap` on its legal side. Presentation converts/captures detached immutable image content before render-thread ownership. -## 4. OpenGL state +Conceptually: -Inside `QSGRenderNode::render()`: +```text +PresentationImage + identity/path/cache key + logical size + DPR intent + immutable QImage/RGBA payload +``` -- use the scene graph's active OpenGL context; -- do not call `beginExternalCommands()`/`endExternalCommands()` from inside the render node unless - Qt documentation for the exact binding requires it; render nodes are already the integration seam; -- accurately declare changed render states; -- restore/leave GL state according to QSGRenderNode contract; -- do not assume the old QRhiWidget FBO/context shape. +Per-display render ownership keeps the current base texture and, during a transition, the source/destination pair required by the active run. -Audit state touched by existing renderer: +Do not repeatedly convert/upload stable images each frame. Do not perform QWidget grabs/painting from the render thread. -- viewport; -- scissor; -- blend enable/function; -- stencil; -- depth; -- program; -- VAO/VBO; -- active texture/bindings; -- clear state. +## 5. Transition lifecycle -No state leak into later Quick nodes. +The landed transition-neutral boundary is: -## 5. Image boundary +```text +canonical transition descriptor + ↓ +GUI/runtime-side settings + random resolution + ↓ +TransitionRequest + ↓ +TransitionRun + ↓ +monotonic TransitionSample + ↓ +lazy Quick transition renderer +``` -Current engine/image pipeline may continue to produce `QPixmap`. +`TransitionRequest`/`TransitionRun` own presentation-neutral lifecycle and immutable values. Completion/cancellation are exactly once and generation/run-id fenced. -The presentation boundary must convert/capture immutable image content on the GUI-safe side. +The controller does not wait for a "last frame painted" acknowledgement. A missed display opportunity advances to the current monotonic sample; it is not replayed. -Preferred model: +Explicit cancellation snaps/finalizes according to the authored destination policy once. Stale render snapshots/completions are rejected by identity/generation. -```text -PresentationImage - id / path / cache identity - logical QSize - DPR intent - QImage or immutable RGBA bytes -``` +## 6. Canonical authored timing -Render node uploads only when image identity changes. +User-selectable transition easing is retired for the final architecture. Legacy persisted easing may remain loadable during migration but is not a runtime-authoring authority. -Do not: +The canonical descriptor/effect owns timing: -- call QWidget screen-grab/paint from render thread; -- repeatedly convert QPixmap every frame; -- upload stable image textures every frame. +- Slide uses `SINE_IN_OUT`. +- Shader/physics effects that already stage their own motion normally receive a linear outer timeline. +- 3D Block Spins receives a linear outer timeline and applies its authored cubic spin internally. -Keep old/new image textures alive for exactly the active transition + resulting base ownership. +Never add easing to conceal seam/cadence defects. -## 6. Texture ownership +## 7. Static lazy implementation registry -Per display render node/context generation owns its textures. +Transition rendering is internally plugin-shaped but statically registered. -No cross-window numeric GL-handle sharing unless a later explicit proven shared-context contract -requires it. +Canonical identity remains in `rendering/transition_registry.py`. The Quick implementation registry contains only lightweight module/factory references until an enabled transition is actually resolved. -Account: +Disabled transitions may remain discoverable in Settings, but disabled runtime selection must not cause: -- current base texture; -- transition destination texture; -- transition scratch/particle buffers; -- visualizer resources. +- implementation-module import; +- transition shader import/compile; +- GPU allocation; +- transition-specific timers/state/resources. -On scene invalidation, delete on the legal render/context owner. +No central transition `if/elif` dispatcher belongs in `QuickSceneController`, `TransitionRequest`, or `TransitionRun`. -Failed deletion is loud and ownership remains accounted until actually released. +Do not turn this internal modularity into dynamic discovery, manifests, hot loading, dependency resolution, API versioning, or a third-party SDK. -## 7. Transition control refactor +A permanent registry-parity test now makes the Phase-C inventory self-checking: canonical production ids and Quick implementation ids must match exactly and remain unique. -Current `gl_compositor_*_transition.py` classes are presentation-coupled: +## 8. GUI-side parameter resolution -```text -BaseTransition --> parent DisplayWidget --> find _gl_compositor --> call compositor start_* --> AnimationManager/compositor completion -``` +Parameterized effects resolve Settings spelling, canonical defaults, random choices, clamping, color normalization, and legacy fall-through semantics before request admission. -Do not make Quick pretend to expose `_gl_compositor`. +Canonical Settings defaults are the fallback authority. Do not duplicate stale constructor defaults in the Quick resolver. -Destination: +The renderers are intentionally strict: required resolved values must be present and valid instead of being silently invented in the renderer. -```text -TransitionRequest - canonical transition id - duration - canonical descriptor-authored easing curve - direction/parameters - old/new PresentationImage identities +Per-run seeds/random values are selected once and frozen into the request/run. - ↓ +## 9. Landed canonical transition inventory -TransitionRun - immutable start identity - monotonic start time - monotonic end time - transition-specific immutable parameters +Phase C now has Quick implementations for all 12 canonical production transitions: - ↓ +1. Crossfade +2. Slide +3. Wipe +4. Warp Dissolve +5. Block Puzzle Flip +6. 3D Block Spins +7. Blinds +8. Diffuse +9. Ripple / Raindrops +10. Crumble +11. Particle +12. Burn -QuickTransitionRenderer -``` +If the canonical registry changes later, registry parity must fail until the Quick implementation surface changes with it. -The controller owns lifecycle/time. +No final Quick transition depends on `GLCompositorWidget`. -The render node samples current monotonic time and computes render progress. +## 10. Slide preservation contract -### Authored transition timing +Quick Slide supports the four product directions only: -User-selectable transition easing is deliberately retired during this migration. Transition -Settings has no Easing control or hidden Auto preference; legacy configurations containing an -easing value remain loadable, but that value is ignored and removed from current persistence. +- left; +- right; +- up; +- down. -The lightweight canonical descriptor specifies each transition's immutable progress curve. This is -an authored visual characteristic, not a global preference. Preserve an implementation's intended -timing rather than forcing every effect through one curve: Slide uses `SINE_IN_OUT`, while effects -whose shader or physics already stages and shapes time receive a linear timeline so they are not -double-eased. The resolved `EasingCurve` may remain on the immutable request/run state. +Source and destination coordinates plus the sole pixel owner come from the same immutable eased sample in one draw. Their union must cover the complete viewport at endpoints, midpoint, arbitrary fractions, and discontinuous progress jumps caused by missed presentation intervals. -Easing is never a remedy for renderer coverage or cadence defects. Quick Slide supports the four -cardinal directions. Its old/new image coordinates and sole pixel owner must be derived from the -same `TransitionSample.eased_progress` in one render operation, so their union covers the entire -viewport at endpoints, midpoint, arbitrary fractional samples, and jumps caused by missed physical -frames. Do not independently accumulate or round the two image positions. +Do not independently accumulate or round source/destination positions. -Block Puzzle Flip remains shader-authoritative, but its final Quick visual contract is row/column -3D strip slabs rather than the legacy per-cell centre-bias/jitter wave. Cardinal directions flip -column or row slabs, the two saved diagonal directions remain diagonal slab waves, and exact start -and end samples remain unshaded full images without a whole-screen dark/soft-lined startup wash. -Resolve the Settings-owned `rows`/`cols` values before request admission; do not restore CPU-region -or per-block presentation work. +Do not restore diagonal full-frame Slide unless a newly authored effect also solves the exposed-corner coverage problem. -### Static internal transition implementation boundary +## 11. Block Puzzle Flip preservation contract -Keep transition rendering internally plugin-shaped but statically registered: +Block Puzzle Flip remains shader-authoritative with resolved Settings-owned rows/columns. Its Quick visual contract is 3D strip/slab behavior rather than CPU region/per-block QWidget work. -- canonical identity remains owned by the transition registry; -- every implementation conforms to one small common renderer/state contract; -- each implementation owns its transition-specific shader, math, uniforms, and resource behaviour; -- the common host/controller owns shared old/new image textures, lifecycle, pacing, progress, - completion, and cancellation plumbing; -- the lightweight catalog/descriptor remains usable by Settings without importing or constructing - every renderer implementation; -- a small static mapping from canonical transition identity to a lazy internal implementation/factory - resolver is the intended dispatch boundary; -- adding or removing a transition should primarily change its implementation/resources, - registration, and focused tests. +Preserve cardinal and the saved diagonal direction semantics, exact endpoints, and authored visual character. Do not reintroduce CPU-region presentation ownership. -A disabled transition remains representable in Settings/catalog metadata, but is excluded from -Random/Cycle selection and is not resolved into a renderer. Its transition-specific module, shaders, -GPU resources, and runtime state therefore remain dormant; re-enabling it makes the same registered -implementation available again. Do not eagerly import every implementation merely to build the -catalog. +## 12. 3D Block Spins preservation contract -Do not accumulate a per-transition `if`/`elif`/switch tree in `QuickSceneController`, -`TransitionRequest`/`TransitionRun`, the general controller, or another central dispatcher. This is -internal modularity only: do not add dynamic discovery, manifests, hot loading, dependency -resolution, API versioning, or a third-party plugin SDK. +3D Block Spins is a real 3D effect, not a flat narrowing approximation. -## 8. Transition completion +Preserve: -Transition completion must be exactly once and not admission-coupled to paint. +- one thin 36-vertex rectangular-prism slab; +- front/back/side faces and thickness; +- depth-tested face ordering over opaque black void; +- horizontal, vertical, and both diagonal axes; +- opposite direction spin signs; +- destination/back-face UV transforms that keep the arriving image upright; +- cubic internal spin timing; +- dark side core; +- direction-sensitive moving specular band; +- edge-on white rim; +- exact source/destination endpoints; +- context-local mesh/program ownership and teardown. -Preferred: +No flat-quad fallback. -- controller knows monotonic end deadline; -- one bounded GUI-side completion deadline finalizes base image/state; -- render node samples state while run is active; -- stale completion is generation/run-id fenced. +## 13. Blinds preservation contract -Do not require "last frame painted" acknowledgement to release the next image rotation. +Preserve the existing authored Blinds fragment shader rather than a lookalike rewrite. -Interruption: +The Quick implementation keeps: -- explicit cancel policy snaps to the authored destination state exactly once; -- stale render snapshots are rejected by run id/generation. +- linear outer timeline; +- existing effective slat grid; +- Horizontal, Vertical, and Diagonal modes; +- resolved shader-space feather; +- authored centre-out band growth; +- late global destination tail; +- lazy implementation/resource ownership. -## 9. Existing shader reuse +`Random` is resolved before renderer admission. -Do not rewrite working GLSL for architectural aesthetics. +## 14. Diffuse / Ripple / Crumble -Extract the rendering helpers from `GLCompositorWidget` coupling. +These effects reuse the existing canonical shader/math surface through isolated Quick renderers. -Likely reusable: +### Diffuse -- `rendering/gl_programs/*`; -- transition shaders; -- transition state dataclasses; -- `GLTransitionRenderer` math after its compositor callbacks are removed; -- visualizer shader sources/render upload helpers. +Preserve block-size/grid semantics and authored shape modes: Rectangle, Membrane, Lines, Diamonds, Amorph, Random. -Refactor them toward explicit inputs: +### Ripple / Raindrops -```text -program/resource owner -viewport -old/new textures -progress -transition state -``` +Preserve ripple-count bounds, per-run ripple seed, existing raindrop shader behavior, and exact endpoints. -rather than callbacks into the old compositor object. +### Crumble -## 10. Active transition inventory gate +Preserve piece count, crack complexity, per-run seed, mosaic flag contract, and canonical numeric weighting modes. Current legacy Settings/factory fall-through quirks are migration evidence; do not silently reinterpret labels during renderer migration. H0 may deliberately reset/repair presentation settings later. -At execution time, query the canonical transition registry. +## 15. Particle preservation contract -The plan currently expects at least: +Particle reuses the authored canonical particle shader. -- Crossfade -- Slide -- Wipe -- Warp -- BlockFlip -- BlockSpin -- Blinds -- Diffuse -- Raindrops -- Crumble -- Particle -- Burn +Preserve: -Port the canonical active set, not a stale hard-coded list. +- Directional, Swirl, and Converge modes; +- all eight directional vectors; +- Random Direction / Random Placement shader semantics; +- particle radius and overlap; +- trail length/strength; +- swirl strength/turns/order; +- 3D shading; +- texture mapping; +- wobble; +- gloss size; +- light direction; +- per-run seed; +- physical-framebuffer `u_resolution` semantics used by the old compositor. -No migration fallback to old compositor for "the difficult transition." +Settings label/index oddities are not permission to retune the effect during migration. -BlockSpin's Quick renderer remains a single thin 3D slab over an opaque black void. Its lazy -`block_spins` implementation owns the context-local 36-vertex box mesh and shader programs, uses -the shared old/new image textures, and releases those resources through the render-node teardown -barrier. The canonical run stays linear while the implementation applies the effect's authored -cubic spin timing. Horizontal, vertical and both diagonal axes preserve their authored back-face -UV transforms and moving side treatment; the common transition host restores inherited depth -write/function/clear state after the draw. +## 16. Burn preservation contract -## 11. Presentation frame pacer +Burn is an authored-rich effect and must use the actual canonical Burn shader/math. -Create a production class, e.g.: +Preserve: -```text -QuickFramePacer -QuickFrameDemand -``` +- exact source/destination endpoints; +- four cardinal plus two diagonal directions; +- 5% ignition phase before front movement; +- four-octave noise and domain-warped FBM; +- jagged paper-like front displacement; +- heat distortion; +- warm glow bleed; +- white-hot/thermite core; +- char width; +- hot ember -> cooling ember -> dark char -> destination progression; +- char crackle/detail; +- smouldering pulse; +- glow intensity/color; +- sparks/embers; +- smoke wisps; +- falling ash; +- smoke/ash enablement and density; +- per-run seed; +- animated effect time derived from the immutable run clock; +- delayed near-completion destination tail fade. -Inputs: +Do not reduce Burn to a noisy wipe. -- display target refresh; -- active transition reason; -- visible custom-GL visualizer reason; -- any other custom render-node reason that genuinely requires continuous frames. +## 17. Presentation frame pacer -Properties: +One presentation pacer exists per display/window. -- one precise single-shot timer/pacer per window; -- next deadline derived monotonically; -- skip missed opportunities; -- call `QQuickWindow.update()` only for due opportunities; -- no render-completion self-requeue; -- no FIFO; -- no physical-to-logical cadence feedback. +Inputs include active transition demand and later visible custom-GL visualizer demand. -Static retained widgets do not keep this pacer running merely because they exist. +Properties: -## 12. Quick-native animations +- target follows the owning display refresh; +- single-shot/monotonic deadline behavior; +- missed opportunities are skipped; +- `QQuickWindow.update()` only when continuous custom rendering needs a frame; +- no `afterRendering -> update()` loop; +- no FIFO/catch-up; +- no paint acknowledgement; +- no feedback into visualizer logical cadence. -For fades/ordinary retained UI animation, prefer Quick scene properties/animations. +Static retained widgets do not keep the custom GL pacer alive merely because they exist. -Do not use a Python callback every rendered frame to animate: +## 18. Quick-native ordinary animations -- opacity; -- simple geometry; -- hover feedback. +Use retained Quick properties/animations for ordinary opacity/geometry/hover behavior where appropriate. -Do not let a Quick animation become visualizer logical-time authority. +Do not introduce a Python callback for every physical frame of simple UI animation. Quick animation never becomes visualizer simulation authority. -## 13. Transition parity tests +## 19. Evidence and deferred Phase-C sign-off -Per active transition: +Implementation/source closure and physical acceptance are separate evidence classes. -- start image; -- midpoint; -- end image; -- direction variants; -- canonical authored curve and absence of a user/callsite override; -- for Slide, seam-free cardinal coverage at endpoints, midpoint, dense fractions, and irregular or - missed presentation intervals; -- DPR 1 and non-1 where practical; -- non-zero display origin where geometry can matter; -- cancel/interruption; -- exactly-once completion. +Permanent deterministic gates should cover: -Use image/pixel or deterministic renderer-state tests when possible. +- registry parity and lazy dormancy; +- request/settings resolution; +- strict parameter admission; +- exact shader/math reuse where required; +- endpoints/midpoints/directions/modes; +- interruption/exactly-once completion; +- generation fencing; +- resource release; +- GL-state restoration. -Manual installed review remains required for motion feel. +Focused real-GL wrappers exist for the remaining parameterized effects and Blinds. `tools/qtquick_phase_c_effect_smoke.py` includes Diffuse shapes, Ripple counts, Crumble weighting modes, Particle modes/directions, and Burn directions/toggle cases. -## 14. Performance acceptance +Physical/eyes-on acceptance remains deferred and must not be inferred from hosted CI: -Check: +- one-window real OpenGL smoke; +- two physical display smoke where requested; +- normal/high-refresh continuity; +- old-vs-Quick eyes-on authored-effect comparison; +- physical cadence/PresentMon only when it answers a new question. -- 60 Hz; -- high refresh; -- mixed refresh; -- light; -- external heavy load; -- p95/p99/max physical gaps; -- severe gap counts; -- render-thread cost; -- texture upload frequency; -- GUI callback count. +A failure in deferred evidence reopens the smallest demonstrated defect. It does not reopen the selected presenter architecture by default. -Do not optimize individual transition shaders because Slide reveals shared cadence issues. +## 20. Phase-C closure status -## 15. Commit cadence +Phase-C implementation is structurally complete and Phase D may proceed. -Recommended pushed checkpoints: +Remaining Phase-C work is acceptance/sign-off only unless new evidence demonstrates an implementation defect. -1. render-node foundation; -2. image texture owner; -3. transition run controller; -4. Crossfade + Slide; -5. simple transition batch; -6. complex transition batch; -7. all-registry transition parity; -8. presentation pacing/perf closure. +Production cutover remains Phase H; old compositor-only presentation code remains until Phase I deletion. Shared authored shader/math assets may survive when the Quick renderer is their real consumer. diff --git a/Docs/QtQuick_Migration/03_Visualizer.md b/Docs/QtQuick_Migration/03_Visualizer.md index 1b497f08..0b52b408 100644 --- a/Docs/QtQuick_Migration/03_Visualizer.md +++ b/Docs/QtQuick_Migration/03_Visualizer.md @@ -1,15 +1,18 @@ # 03 — Visualizer Qt Quick Migration -Status: technical decomposition only -Last updated: 2026-08-20 +Status: ACTIVE Phase-D technical decomposition +Last updated: 2026-08-21 Cross-links: -- `Current_Plan.md` -- `Docs/Guardrails/Visualizer_Presentation.md` -- `Docs/Guardrails/Bubble_Temporal_Fidelity.md` -- `Docs/Visualizer_Reference.md` -- `Future_Cleanup.md` +- sequence/permission: `Current_Plan.md` +- presentation guardrail: `Docs/Guardrails/Visualizer_Presentation.md` +- Bubble temporal fidelity: `Docs/Guardrails/Bubble_Temporal_Fidelity.md` +- authored behavior/reference: `Docs/Visualizer_Reference.md` +- harness routing: `Docs/Harness_Index.md` +- deferred deletion: `Future_Cleanup.md` + +Phase D may proceed while Phase-C physical/eyes-on sign-off remains explicitly deferred. A later failing transition sign-off reopens only the smallest demonstrated Phase-C defect; it does not suspend unrelated Phase-D work by default. ## 1. Preserve the part that already works @@ -17,66 +20,66 @@ Keep `VisualizerLogicalRuntime`. It remains: -- the only mode-general authored visualizer clock; +- the sole mode-general authored visualizer clock; - independent of GUI/render cadence; - latest-state oriented; - generation-owned; - no catch-up; - BTF-bound for Bubble. -Do not migrate logical simulation to: +Do not move logical simulation into: - QML; - QSG render thread; - `FrameAnimation`; - physical refresh; -- a new per-mode timer. +- a second mode-specific timer. -## 2. Current presentation seam that must change +Every authored logical step must still occur even when fewer physical frames are presented. -The current compositor layer can hold a `VisualizerRenderState` whose handle references the live -visualizer owner and heavy mutable arrays. +## 2. Current presentation seam that must change -That was acceptable only because publication and paint were both GUI-thread owned. +The old compositor presentation can hold a `VisualizerRenderState` whose handle references the live visualizer owner and mutable/heavy arrays. That was only acceptable while publication and paint were GUI-thread owned. -It is **not** the Quick render-thread contract. +It is not a legal Quick render-thread contract. -Do not let the new render node read a live `SpotifyVisualizerWidget`/QObject. +The Quick render node must not read a live `SpotifyVisualizerWidget`, arbitrary QObject presentation state, provider objects, or SettingsManager. -## 3. Extract a non-pixel visualizer runtime owner +## 3. D1 — presentation-neutral runtime/controller owner -The Quick path must not instantiate a hidden QWidget just to own: +Do not instantiate a hidden QWidget merely to retain: - playback state; - presets/settings; -- BeatEngine source; +- BeatEngine/source ownership; - logical runtime; - mode state; - CUSTOM identity. -Extract/construct a presentation-independent visualizer runtime/controller. - -Possible decomposition: +Extract/retain a presentation-neutral owner, conceptually: ```text VisualizerRuntimeController settings/mode/preset activation BeatEngine/source ownership - playback edge ownership + playback-edge ownership VisualizerLogicalRuntime - latest logical snapshot publication + latest logical publication QuickVisualizerPresentation + immutable snapshot admission geometry/fade/readiness - retained card visual + retained card chrome QSGRenderNode visual content ``` -Keep source/provider logic in Python. +Keep source/provider/business logic in Python. Do not rewrite it into QML. + +Checkpoint and push the controller/runtime split before beginning renderer complexity. -## 4. Immutable latest snapshot +## 4. D2 — immutable latest snapshot -Define a render-thread-safe snapshot. +Define a bounded render-thread-safe snapshot containing only data needed to draw the committed visualizer state. Representative fields: @@ -87,8 +90,8 @@ mode playing logical_timestamp fade -card_rect -DPR/render geometry identity +card/presentation geometry +DPR/render identity common: energy bands @@ -106,159 +109,178 @@ Sine: Bubble: positions radii/extra data - trails - pop/transient state - ghost/tail state + trails/tails + pop/transient/protected-edge state + ghost state authored style DevCurve: - active layer data / order / offsets / alpha + enabled layers + order + offsets + alpha + outline/ghost state ``` -The exact payload should come from existing logical state where possible. +Use existing logical state where possible. Do not deep-copy QWidget object graphs. -Do not deep-copy arbitrary QWidget object graphs. +Use tuples, immutable records, owned numpy buffers, or another explicitly proven immutable payload shape. -Use bounded immutable arrays/tuples/owned numpy buffers or another proven immutable snapshot shape. +One latest slot per activation/display presentation. Newer committed state supersedes older unread state; there is no render backlog. -One latest slot per activation/display presentation. +Checkpoint and push the immutable bridge separately. -## 5. Synchronization +## 5. Synchronization and publication Preferred seam: ```text logical publication -> latest immutable snapshot - -> GUI/Quick item marks state dirty - -> updatePaintNode/synchronize while GUI blocked - -> render node receives complete snapshot + -> GUI/Quick item marks visual state dirty + -> updatePaintNode/synchronization boundary + -> render node receives complete current snapshot ``` -No render-thread lock that can block on provider/network/GUI work. - -No one-GUI-callback-per-logical-tick requirement. - -Coalesce naturally: latest state wins. - -Protect short-lived authored edges explicitly. - -## 6. Quick visualizer item - -Use a sub-rect custom render item/node inside the display Quick scene. +The exact PySide seam may vary with the landed Quick item, but the ownership rules may not: -The item's geometry is the visualizer card geometry. +- no render-thread lock waiting on provider/network/GUI work; +- no one-GUI-callback-per-logical-tick requirement; +- latest state wins; +- no FIFO/catch-up replay; +- short-lived authored edges are protected explicitly rather than lost through decimation; +- no paint acknowledgement feeding logical cadence. -Render node owns GL programs/resources. +## 6. Quick visualizer render item/node -Reuse current: +Use a sub-rect custom `QQuickItem`/`QSGRenderNode` inside the owning display `QQuickWindow`. -- mode fragment shaders; -- shared vertex shader where valid; -- renderer uniform upload helpers; -- Bubble data format/math; -- stencil/mask logic after converting window-space assumptions to Quick item/window geometry. +The visualizer remains inline in the same scene as retained card chrome and widgets. No separate native window and no QPainter fallback. -Do not route through an offscreen QWidget card texture as the final design. +The render node owns its context-local GL programs/resources. -## 7. Card visual +Reuse authored assets/helpers where valid: -Port the visualizer/card chrome to retained Quick presentation. +- current mode shader sources; +- shared vertex shader/math; +- uniform upload helpers after removing old compositor coupling; +- Bubble logical/output data format and authored math; +- mask/stencil behavior after converting window-space assumptions to explicit Quick geometry. -Preserve: - -- background opacity; -- border; -- radius; -- card shadow; -- header/text; -- card fade; -- geometry; -- current color/customization. +Do not use an offscreen QWidget/card screenshot as the final presentation path. -The custom GL visual content and retained card chrome must share one authoritative geometry source. +## 7. One authoritative geometry contract -Do not maintain a hidden QWidget as geometry authority. +Create one committed presentation geometry structure that feeds both retained Quick chrome and custom GL content. -## 8. Geometry +It must account for: -Create one presentation geometry structure per committed visualizer state. - -It feeds: - -- Quick item x/y/width/height; +- item x/y/width/height; +- card/background/border/radius; - GL viewport/scissor; - shader logical resolution; - framebuffer origin where required; -- mask/border radius; +- DPR from the owning QQuickWindow/QScreen; - CUSTOM edit geometry. -The display `QQuickWindow`/QScreen owns DPR. +No hidden QWidget remains geometry authority. No visualizer-local stale DPR copy. + +Card and GL content must remain aligned at non-zero display origins and non-1 DPR. -No visualizer-local stale DPR. +## 8. Card chrome and fades -## 9. Fade/readiness +Port visualizer/card chrome into retained Quick presentation while preserving current product capabilities: + +- background opacity; +- border/radius; +- card shadow; +- header/text where applicable; +- card/foreground opacity; +- current color/customization; +- geometry; +- fade behavior. -Keep separate: +Keep separate concepts: ```text presentation_ready reactive_source_ready ``` -Presentation ready requires: +Presentation may become intentionally visible before a live reactive source exists where the current product does so (for example paused Spectrum idle bars). + +Prefer one parent/presentation opacity authority for fade. Do not animate by repeatedly enabling/disabling a shadow/effect topology. + +## 9. Sole authored-clock guardrail + +`VisualizerLogicalRuntime` remains the only authored mode-general logical clock. -- visualizer Quick item exists; -- geometry committed; -- renderer resources ready; -- card chrome drawable; -- intentional state available. +Non-negotiable: -Paused Spectrum may reveal idle bars without fabricated source identity. +- preserve every authored logical step; +- latest-state semantics; +- no FIFO/catch-up replay; +- no paint acknowledgement; +- no producer/display divisor; +- no source/event decimation; +- no display-refresh logical cap; +- render cadence does not become simulation cadence; +- nonblocking media/GSMTC interaction; +- generation fencing/stale rejection; +- clean worker join. -Fade one parent/presentation opacity authority where practical. +A display may present fewer samples than the logical runtime authors. That does not authorize dropping logical updates before the latest-state publication boundary. -Do not use a shadow/effect enable/disable toggle as the fade animation. +## 10. Spectrum contract -## 10. Mode requirements +Preserve: + +- current bar/peak behavior; +- ghosting/persistence; +- borders/masks/style; +- paused idle bars perceptibly visible; +- source identity absent until a real source is available; +- Play replacing idle state in place rather than recreating presentation ownership. + +No mode-specific presentation clock. -### Spectrum +## 11. Oscilloscope contract -- idle bars perceptibly visible while paused; -- source identity absent until real source; -- Play replaces idle bars in place; -- peaks/ghosting preserved. +Preserve the authored waveform shape, line count/persistence/ghosting, idle behavior, borders/masks/style, and current logical cadence. -### Oscilloscope +Do not turn physical render cadence into waveform sampling cadence. -- exact line count and persistence behaviour; -- idle authored motion preserved. +## 12. Sine contract -### Sine +Preserve authored idle motion, layers/line persistence, reactive behavior, ghosting, and mode tuning. -- authored idle motion; -- line/layer persistence; -- no mode-specific presentation clock. +No separate Sine timer is introduced because Quick can animate. -### Bubble +## 13. Bubble contract — dedicated high-risk checkpoint -BTF mandatory. +Bubble is the highest-risk Phase-D mode and receives its own checkpoint/audit. + +`Docs/Guardrails/Bubble_Temporal_Fidelity.md` is mandatory. Preserve: +- continuous positional evolution; - trajectories; - collision/elastic feel; - trails/tails; - ghost/pop/transients; +- protected short-lived edges; - source freshness; -- logical Hz; -- protected edges. +- authored logical Hz; +- mode style and reactive personality. + +Do not retune Bubble to compensate for presentation problems. Do not discard authored logical steps to reduce callbacks/GPU use. -No retune to hide presentation issues. +The existing observation that unrelated active widgets can materially alter measured Bubble-era GPU load is reason to preserve true feature dormancy and shared-scene efficiency; it is not evidence that Bubble collision/simulation should be individually simplified. -### DevCurve +## 14. DevCurve contract -Preserve all active layer: +Preserve every active layer's: - enabled state; - order; @@ -266,55 +288,51 @@ Preserve all active layer: - offsets; - outline; - ghosting; -- mode tuning. +- tuning. -## 11. Pause / Play +Do not flatten DevCurve into a generic line visualizer during porting. -Ordinary Pause/Play: +## 15. Pause / Play + +Ordinary Pause/Play keeps the same runtime ownership: -- same logical runtime; - no window/item recreation; -- no source debounce; -- warm source policy preserved; +- no source debounce invented by Quick; +- warm-source policy preserved; - visible state changes promptly; -- current expected-state confirmation contract preserved. - -Do not make Quick activation state a second playback authority. - -## 12. CUSTOM +- existing expected-state confirmation behavior preserved; +- Quick visibility/activation is not a second playback authority. -Visualizer becomes an ordinary participant in the Quick edit scene. +## 16. CUSTOM participation -No special QWidget screenshot shell required. +The visualizer becomes an ordinary participant in the later Quick edit scene. -During edit: +During Phase D, keep the presentation-neutral geometry/state seam suitable for Phase G without implementing Phase-G CUSTOM prematurely. -- suspend/hold authored geometry ownership exactly as required by CUSTOM; -- edit presentation geometry; -- Save commits canonical custom layout; -- Cancel restores baseline; -- logical runtime/source remains correctly owned. +Final edit behavior will use the real retained Quick item, not a QWidget screenshot shell. -## 13. Lifecycle +## 17. Lifecycle -On retirement: +Retirement order conceptually: ```text close visualizer publication --> join VisualizerLogicalRuntime --> invalidate snapshot generation --> Quick item loses admission --> render-node GL resources destroyed on render owner --> QML/item/controller roots destroyed +-> stop/join VisualizerLogicalRuntime +-> invalidate activation/generation +-> Quick item loses snapshot admission +-> render-node GL resources destroy on render owner +-> item/controller roots destroy ``` -Do not let visibility determine destruction authority. +Visibility is not destruction authority. -## 14. Tests/gates +No non-daemon/background owner may survive retirement and prevent process/test shutdown. -Permanent: +## 18. Permanent tests/gates -- one logical clock; +Permanent contract coverage should include: + +- sole logical clock; - generation 0; - all five modes; - source freshness; @@ -322,29 +340,44 @@ Permanent: - BTF; - Pause/Play; - Spectrum idle; -- CUSTOM; -- Settings recreate; -- stale activation/generation rejection. +- mode switches; +- stale activation/generation rejection; +- logical runtime join. -Quick-specific: +Quick-specific coverage should include: -- render thread distinct; -- snapshot immutable/thread safe; -- no live QWidget/QObject render access; -- geometry non-zero origin + non-1 DPR; +- distinct render-thread ownership; +- immutable snapshot boundary; +- no live QWidget/QObject reads from renderer; +- non-zero origin/non-1 DPR geometry; - card/shader alignment; -- clean resource deletion; -- physical cadence. +- render resource creation/release; +- Settings recreation where presentation state is involved; +- physical cadence/eyes-on only on suitable hardware. + +Hosted Windows CI is useful for deterministic Phase-D tests but currently has known full-suite noise/hang problems documented in `Docs/Harness_Index.md`. Do not use a red unrelated full-suite job as a substitute for focused Phase-D evidence. -## 15. Commit cadence +## 19. Checkpoint cadence -Push after: +Prefer these pushed/audited checkpoints: -1. non-pixel runtime/controller split; -2. immutable snapshot bridge; -3. Quick card/geometry; +1. presentation-neutral runtime/controller split; +2. immutable latest-state snapshot bridge; +3. Quick visualizer item/node + authoritative geometry/card foundation; 4. Spectrum; -5. Oscilloscope + Sine; -6. Bubble + BTF; -7. DevCurve; -8. all-mode lifecycle/perf closure. +5. Oscilloscope; +6. Sine; +7. Bubble + BTF dedicated checkpoint; +8. DevCurve; +9. all-five-mode lifecycle/source/pause audit; +10. Phase-D documentation closure. + +A successful checkpoint is committed, branch-reachable, pushed, and independently diff-audited. Connector-created blobs/trees that are not reachable from the branch are not checkpoints. + +## 20. Phase-D exit + +Phase D implementation exits when all five modes use the Quick visualizer boundary with the authored logical runtime intact, immutable latest-state publication, correct lifecycle, and no old compositor/QWidget presentation dependency inside the new renderer. + +Physical/eyes-on acceptance may be tracked separately where the evidence requires the operator's actual display/GPU environment, but all commands and unresolved acceptance items must be explicit before promotion to Phase E. + +After Phase D implementation closure, update `Docs/Visualizer_Reference.md` and related authoring/preset guidance to the landed Quick boundary. diff --git a/Docs/QtQuick_Migration/README.md b/Docs/QtQuick_Migration/README.md index 192759f9..799a6e94 100644 --- a/Docs/QtQuick_Migration/README.md +++ b/Docs/QtQuick_Migration/README.md @@ -1,38 +1,71 @@ # Qt Quick Production Migration — Technical Decomposition Index Status: subordinate technical notes for `Current_Plan.md` -Last updated: 2026-08-20 +Last updated: 2026-08-21 -These documents are **not independent plans**. +These documents are **not independent plans**. Sequence and work admission come only from `Current_Plan.md`; deferred deletion/accounting comes from `Future_Cleanup.md`. -Sequence and work admission come only from: +Current active implementation phase: **Phase D — visualizer**. + +Phase C transition implementation is structurally complete. Its remaining physical/eyes-on acceptance is tracked explicitly and does not by itself block unrelated Phase-D implementation. + +## Required routing before active migration work + +Use the repository authority chain rather than treating one decomposition as self-sufficient: ```text +exact current source / pushed diff + ↓ Current_Plan.md + ↓ +Spec.md + Docs/Compositor_Architecture.md + Docs/Contracts.md + ↓ +Docs/Guardrails.md + the relevant focused guardrail + ↓ +ONLY the active QtQuick_Migration decomposition + ↓ +focused tests / current evidence ``` -Deferred deletion/accounting comes from: +For visualizer work, `Docs/Guardrails/Visualizer_Presentation.md` is binding; for Bubble also read `Docs/Guardrails/Bubble_Temporal_Fidelity.md` (BTF). -```text -Future_Cleanup.md -``` +`Future_Work.md` is not migration work admission. It remains a deferred feature/experiment ledger unless the operator explicitly selects an item or the active plan/cleanup authority says it is eligible. ## Documents | File | Purpose | |---|---| | `01_Runtime_Host_Lifecycle.md` | QQuickWindow/runtime owner, display topology, lifecycle, input seams | -| `02_Scene_Renderer_Transitions.md` | QSGRenderNode/OpenGL scene, image/texture ownership, transitions, frame pacing | -| `03_Visualizer.md` | logical/runtime split, immutable render snapshots, five-mode Quick rendering, BTF | +| `02_Scene_Renderer_Transitions.md` | landed QSGRenderNode/OpenGL image/transition architecture, authored transition contracts, pacing, Phase-C sign-off | +| `03_Visualizer.md` | ACTIVE Phase-D runtime split, immutable latest snapshots, five-mode Quick rendering, BTF | | `04_Widget_Runtime_Presentation.md` | widget manager/model split, retained Quick components, shadows, family migration | | `05_Custom_Layout_Input_Interaction.md` | CUSTOM Save/Cancel, edit overlays, cross-monitor transfer, interaction/context | | `06_Build_Tooling_Validation.md` | Nuitka/QML packaging, tools, tests, compiled/runtime/perf gates | ## Off-rails rule -If a document suggests work that is not the active slice in `Current_Plan.md`, do not perform it yet. +If a decomposition suggests work that is not admitted by the active slice in `Current_Plan.md`, do not perform it yet. -If exact current source invalidates a technical assumption, update the smallest affected -decomposition and `Current_Plan.md` only if sequencing changes. +If exact current source invalidates a technical assumption, update the smallest affected decomposition and update `Current_Plan.md` only when sequencing/authority actually changes. Do not create another migration roadmap document. + +Do not use a later-phase decomposition to smuggle later-phase work into the active phase. + +## Repository/API checkpoint rule + +When edits are made through a connector/API rather than a normal local Git worktree, a created blob/tree is not a checkpoint. + +For risky whole-file reconstruction use: + +```text +authoritative parent +-> candidate blobs/tree +-> UNATTACHED candidate commit +-> compare parent..candidate +-> spot-fetch reconstructed boundaries/suspicious sections +-> move branch ref only when clean +-> verify pushed commit/diff +``` + +Abandon malformed candidate commits before they become branch-reachable. diff --git a/Docs/TestSuite.md b/Docs/TestSuite.md index 39218ef5..a285ee72 100644 --- a/Docs/TestSuite.md +++ b/Docs/TestSuite.md @@ -1,26 +1,61 @@ # Test Suite Guide -Last updated: 2026-08-20 +Last updated: 2026-08-21 Testing strategy during the Qt Quick runtime presentation migration. ## 1. Standard commands -Full bounded suite: +Targeted tests are the normal per-slice gate: ```powershell -python tests/run_chunked.py --chunks 4 --timeout-seconds 900 +pytest path\to\test_file.py -q --tb=short ``` -Targeted: +The bounded full-suite diagnostic is: ```powershell -pytest path\to\test_file.py -q --tb=short +python tests/run_chunked.py --chunks 4 --timeout-seconds 900 --log ``` Discover current tests by owner/defect, not by stale phase numbering. -## 2. Validation levels +Do not use a red broad-suite run as the only evidence that the active migration slice failed. Inspect +its exact failures/timeouts and run the smallest focused gate that can falsify the changed contract. + +## 2. Current GitHub Actions caveat — 2026-08-21 + +Windows Actions run `32436553793` proved that the current broad chunked suite is useful diagnostic +evidence but is not yet a clean pass/fail migration gate. + +Observed: + +- chunks 1 and 4 completed and reported ordinary test failures from several unrelated existing areas; +- chunk 2 completed pytest itself (`3 failed, 1219 passed, 67 skipped`) in about 25 seconds but the + Python process remained alive until `tests/run_chunked.py` applied its own 900-second timeout; +- chunk 3 stopped around 50% test execution progress and reached the same wrapper timeout without a + pytest summary; +- the outer GitHub Actions job was **not** killed early; its 70-minute job timeout was not reached; +- the workflow uploaded all four chunk logs successfully. + +The workflow also used `actions/checkout` with the default shallow history. At least one existing +Bubble guardrail executes `git show 510520e:...`, which cannot succeed when that historical commit is +not present in the checkout. + +Interpretation rules: + +- a pytest summary followed by a wrapper timeout strongly suggests shutdown/background ownership + remains alive; identify the actual owner rather than increasing the timeout; +- a chunk that stops during test execution requires verbose/smaller isolation to identify the + hanging test/owner; +- a history-dependent test requires the workflow to fetch sufficient history; +- unrelated legacy/default/UI/doc failures are not evidence that a new Quick renderer failed; +- conversely, an uninspected timed-out chunk must not be assumed clean merely because focused source + review looked good. + +See `Docs/Harness_Index.md` for the exact current CI evidence and Phase-C sign-off commands. + +## 3. Validation levels ### A — pure/unit @@ -52,35 +87,55 @@ Required for: Required for: - Bubble feel/BTF; -- transition continuity; +- transition continuity/authored visual parity; - Spectrum idle visibility; - Pause/Play hitch; - startup/reveal; - widget visual parity. -## 3. Permanent visualizer gates +## 4. Permanent transition gates + +Preserve tests for: + +- canonical registry ↔ Quick implementation registry parity; +- lazy/dormant implementation resolution; +- Settings/default/random parameter resolution before render admission; +- immutable transition request/run state; +- exact endpoints and authored direction/mode variants; +- transition-specific shader/math preservation where contractually required; +- interruption/exactly-once completion; +- generation fencing; +- GL state restoration; +- resource teardown. + +Real-GL and physical-display transition sign-off is routed through `Docs/Harness_Index.md`. + +## 5. Permanent visualizer gates Preserve tests for: - one `VisualizerLogicalRuntime`; - actual authored scheduler cadence; +- every authored logical step integrated before presentation coalescing; - logical worker cannot mutate GUI/Quick/GPU state; - valid generation `0`; - all five modes; - source freshness; - protected visible edges; - Pause/Play identity; +- clean worker join; - BTF. Do not regenerate behavioural goldens merely because the presentation architecture changed. -## 4. Quick presentation gates +## 6. Quick presentation gates Add/retain runtime-shaped proof for: - one standalone top-level `QQuickWindow` per physical display; - threaded render loop active; - render-thread identity distinct from GUI thread on supported Windows path; +- inline custom GL through the selected `QSGRenderNode` seam; - no `QQuickWidget`; - no second accelerated runtime surface; - bounded latest-state synchronization; @@ -90,7 +145,7 @@ Add/retain runtime-shaped proof for: - topology recreation; - clean shutdown. -## 5. Physical frame-pacing gate +## 7. Physical frame-pacing gate Report separately per display: @@ -107,7 +162,7 @@ Internal `frameSwapped`/render callbacks are proxies, not physical-display proof Use OS/display-boundary evidence when deciding physical delivery. -## 6. Heavy-load interpretation +## 8. Heavy-load interpretation The completed P0 experiment showed Quick remains approximately in the old light-load presentation class even under substantial external CPU load. @@ -116,7 +171,7 @@ Do not require every heavy-load outlier to disappear before migration. Heavy load is a resilience gate, not permission to reopen the old architecture. -## 7. Lifecycle gate +## 9. Lifecycle gate Repeatedly exercise: @@ -135,9 +190,10 @@ Require: - generation zero preserved; - retired scene cannot reveal; - render resources return to expected baseline; -- no old-generation callback survives destruction. +- no old-generation callback survives destruction; +- no background owner prevents process/test shutdown. -## 8. Migration parity +## 10. Migration parity For each migrated presentation family, require the relevant combination of: @@ -148,8 +204,12 @@ For each migrated presentation family, require the relevant combination of: - lifecycle parity; - performance not regressing the Quick architecture win. -## 9. Completion rule +## 11. Completion rule + +Green focused tests are necessary but not sufficient. -Green tests are necessary, not sufficient. +Completion requires the relevant runtime/physical/manual evidence for the claim being made. -Completion requires the relevant runtime and visual evidence for the migrated slice. +Implementation may advance to the next phase while explicitly listed hardware/eyes-on acceptance +remains deferred, provided the next phase does not depend on that unresolved evidence. A later failed +sign-off reopens the smallest demonstrated defect. diff --git a/Spec.md b/Spec.md index ee1689d4..c1cee6a7 100644 --- a/Spec.md +++ b/Spec.md @@ -1,6 +1,6 @@ # SRPSS Specification -Last updated: 2026-08-20 +Last updated: 2026-08-21 Canonical durable architecture and product-behaviour contracts for SRPSS. @@ -162,21 +162,29 @@ The one Quick window may contain: - runtime overlay presentation; - other explicitly scene-owned layers. -The exact primitive may differ by content: +The custom-GL primitive was selected and proved during the Qt Quick foundation work: -- ordinary retained Quick items where appropriate; -- custom scene-graph rendering; -- `QSGRenderNode`; -- `QQuickRhiItem`; -- other measured Qt Quick-compatible custom rendering. +```text +QQuickItem(ItemHasContents) + -> updatePaintNode() + -> QSGRenderNode + -> direct OpenGL inside the owning Quick scene +``` + +Use ordinary retained Quick items/components for normal UI/widget/card presentation. +Use the inline `QSGRenderNode` boundary for custom OpenGL transition/visualizer rendering that needs +existing shader/math/mesh ownership. -Choose the primitive by correctness, fidelity, and measured cost. Do not choose a native rewrite by -aesthetics. +`QQuickRhiItem` is not the accepted normal custom-render path for SRPSS because it introduces an +offscreen texture/composite layer that this migration is deliberately avoiding. `QQuickWidget` is +prohibited as the runtime presenter. -No `QQuickWidget` architecture proof or production presenter. +If the selected `QSGRenderNode` primitive itself is proven fundamentally unusable by the pinned +PySide/compiled product, stop and revise the **single** custom-render primitive deliberately. Do not +ship two competing custom-render architectures or a per-effect fallback. -No transparent accelerated child/top-level window used to avoid integrating pixels into the one -runtime scene. +No transparent accelerated child/top-level window may be used to avoid integrating pixels into the +one runtime scene. ## 8. Readiness and reveal @@ -236,8 +244,8 @@ SRPSS-owned GPU resources must have: - failed deletion retaining ownership/failing closed; - accounting released only after actual ownership is released. -Do not carry QRhiWidget-specific borrowed-context rules forward as universal Quick rules. Re-establish -the exact legal resource boundary for the chosen Quick rendering primitive. +Do not carry QRhiWidget-specific borrowed-context rules forward as universal Quick rules. The +selected inline `QSGRenderNode` contract defines the custom GL render/context seam. No `glFinish()`, `DwmFlush()`, GUI sleeps, nested event pumping, or fence polling as cadence repairs.