feat(headless): on-demand SDR/HDR colour check with a test-pattern client (#126)
* fix(headless): enable virtual HDR with a real GPU ExportTargets refused an empty display modifier list for HDR, so a headless output on a real GPU always fell back to SDR. Virtual outputs now say so with SetVirtualOutput, set by the headless renderer factory. * feat(headless): dump the raw PQ codes of a virtual HDR output Add ports.Renderer.HDRPixels, which reads the exported XRGB2101010 target back as 16-bit codes, and the test-only --screenshot-raw flag, which writes them to latest-pq.png next to the screenshots. Readback is enabled on headless renderers only. * feat(examples): add a colour test-pattern client * test(app): add an on-demand colour check Add TestColorCheckSDR and TestColorCheckHDR, built with the colorcheck tag and run by make color-check. They start examples/testpattern on a headless NeferWL and compare the screenshots, or the raw PQ dump of a virtual HDR output, with the expected colours. Add pattern.PQDecode for the near-black luminance tolerance, and document the check in docs/headless.md.
- commit
- 014854eb7cb54633616dc079038a185c6b9b9986
- author
- bnema · · 2026-10-04 04:33 UTC
- committer
- GitHub · · 2026-10-04 04:33 UTC
- parent
- 2e21488
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
23 files · +1696 -57
M Makefile +6 -1
| @@ -1,4 +1,4 @@ | |||
| 1 | - | .PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch check perf-check bin tty logs pkg install | |
| 1 | + | .PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch check perf-check color-check bin tty logs pkg install | |
| 2 | 2 | ||
| 3 | 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | |
| 4 | 4 | TTY_TIMEOUT ?= 0 | |
| @@ -63,6 +63,11 @@ perf-check: | |||
| 63 | 63 | CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | |
| 64 | 64 | check: vet test arch fakes-check log-check perf-check | |
| 65 | 65 | ||
| 66 | + | # On-demand SDR/HDR colour check with examples/testpattern on a headless | |
| 67 | + | # NeferWL. Needs a GPU and /dev/udmabuf; not part of check. | |
| 68 | + | color-check: | |
| 69 | + | CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v | |
| 70 | + | ||
| 66 | 71 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | |
| 67 | 72 | # come from the module proxy in prepare(); the build itself runs offline. | |
| 68 | 73 | # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0. | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | .PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch check perf-check bin tty logs pkg install | 1 | + | .PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch check perf-check color-check bin tty logs pkg install |
| 2 | 2 | ||||
| 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | ||
| 4 | TTY_TIMEOUT ?= 0 | 4 | TTY_TIMEOUT ?= 0 | ||
| @@ -63,6 +63,11 @@ perf-check: | |||||
| 63 | CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | 63 | CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | ||
| 64 | check: vet test arch fakes-check log-check perf-check | 64 | check: vet test arch fakes-check log-check perf-check | ||
| 65 | 65 | ||||
| 66 | + | # On-demand SDR/HDR colour check with examples/testpattern on a headless | |||
| 67 | + | # NeferWL. Needs a GPU and /dev/udmabuf; not part of check. | |||
| 68 | + | color-check: | |||
| 69 | + | CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v | |||
| 70 | + | ||||
| 66 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | 71 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | ||
| 67 | # come from the module proxy in prepare(); the build itself runs offline. | 72 | # come from the module proxy in prepare(); the build itself runs offline. | ||
| 68 | # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0. | 73 | # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0. | ||
M cmd/neferwl/main.go +7 -1
| @@ -162,6 +162,7 @@ func run() error { | |||
| 162 | 162 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | |
| 163 | 163 | noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps") | |
| 164 | 164 | headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable") | |
| 165 | + | screenshotRaw := flags.Bool("screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output") | |
| 165 | 166 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| 166 | 167 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)") | |
| 167 | 168 | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | |
| @@ -186,6 +187,11 @@ func run() error { | |||
| 186 | 187 | fmt.Fprintln(os.Stderr, err) | |
| 187 | 188 | return err | |
| 188 | 189 | } | |
| 190 | + | if *screenshotRaw && (*screenshot == "" || !*headlessHDR) { | |
| 191 | + | err := usageError{fmt.Errorf("--screenshot-raw requires --screenshot and --headless-hdr")} | |
| 192 | + | fmt.Fprintln(os.Stderr, err) | |
| 193 | + | return err | |
| 194 | + | } | |
| 189 | 195 | var sizes [][2]int | |
| 190 | 196 | for _, s := range strings.Split(*size, ",") { | |
| 191 | 197 | w, h, _, sizeErr := config.ParseMode(strings.TrimSpace(s)) | |
| @@ -275,7 +281,7 @@ func run() error { | |||
| 275 | 281 | for _, w := range warnings { | |
| 276 | 282 | configLog.Warn().Int("line", w.Line).Msg(w.Msg) | |
| 277 | 283 | } | |
| 278 | - | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, Sizes: sizes, Script: script}) | |
| 284 | + | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, ScreenshotRaw: *screenshotRaw, Sizes: sizes, Script: script}) | |
| 279 | 285 | // SIGINT and SIGTERM cancel the context and are clean exits. | |
| 280 | 286 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | |
| 281 | 287 | err = nil | |
| @@ -162,6 +162,7 @@ func run() error { | |||||
| 162 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | 162 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | ||
| 163 | noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps") | 163 | noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps") | ||
| 164 | headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable") | 164 | headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable") | ||
| 165 | + | screenshotRaw := flags.Bool("screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output") | |||
| 165 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | 166 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | ||
| 166 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)") | 167 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)") | ||
| 167 | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | 168 | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | ||
| @@ -186,6 +187,11 @@ func run() error { | |||||
| 186 | fmt.Fprintln(os.Stderr, err) | 187 | fmt.Fprintln(os.Stderr, err) | ||
| 187 | return err | 188 | return err | ||
| 188 | } | 189 | } | ||
| 190 | + | if *screenshotRaw && (*screenshot == "" || !*headlessHDR) { | |||
| 191 | + | err := usageError{fmt.Errorf("--screenshot-raw requires --screenshot and --headless-hdr")} | |||
| 192 | + | fmt.Fprintln(os.Stderr, err) | |||
| 193 | + | return err | |||
| 194 | + | } | |||
| 189 | var sizes [][2]int | 195 | var sizes [][2]int | ||
| 190 | for _, s := range strings.Split(*size, ",") { | 196 | for _, s := range strings.Split(*size, ",") { | ||
| 191 | w, h, _, sizeErr := config.ParseMode(strings.TrimSpace(s)) | 197 | w, h, _, sizeErr := config.ParseMode(strings.TrimSpace(s)) | ||
| @@ -275,7 +281,7 @@ func run() error { | |||||
| 275 | for _, w := range warnings { | 281 | for _, w := range warnings { | ||
| 276 | configLog.Warn().Int("line", w.Line).Msg(w.Msg) | 282 | configLog.Warn().Int("line", w.Line).Msg(w.Msg) | ||
| 277 | } | 283 | } | ||
| 278 | - | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, Sizes: sizes, Script: script}) | 284 | + | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, ScreenshotRaw: *screenshotRaw, Sizes: sizes, Script: script}) |
| 279 | // SIGINT and SIGTERM cancel the context and are clean exits. | 285 | // SIGINT and SIGTERM cancel the context and are clean exits. | ||
| 280 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | 286 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | ||
| 281 | err = nil | 287 | err = nil | ||
M cmd/neferwl/main_test.go +2 -0
| @@ -23,6 +23,8 @@ func TestExitCodes(t *testing.T) { | |||
| 23 | 23 | {[]string{"neferwl", "--help"}, 0}, | |
| 24 | 24 | {[]string{"neferwl", "--backend=invalid"}, 2}, | |
| 25 | 25 | {[]string{"neferwl", "--backend=drm", "--screenshot=/tmp/shots"}, 2}, | |
| 26 | + | {[]string{"neferwl", "--backend=headless", "--screenshot=/tmp/shots", "--screenshot-raw"}, 2}, | |
| 27 | + | {[]string{"neferwl", "--backend=headless", "--headless-hdr", "--screenshot-raw"}, 2}, | |
| 26 | 28 | {[]string{"neferwl", "--timeout=-1s"}, 2}, | |
| 27 | 29 | {[]string{"neferwl", "extra"}, 2}, | |
| 28 | 30 | {[]string{"neferwl", "--config=/nonexistent/neferwl.conf"}, 1}, | |
| @@ -23,6 +23,8 @@ func TestExitCodes(t *testing.T) { | |||||
| 23 | {[]string{"neferwl", "--help"}, 0}, | 23 | {[]string{"neferwl", "--help"}, 0}, | ||
| 24 | {[]string{"neferwl", "--backend=invalid"}, 2}, | 24 | {[]string{"neferwl", "--backend=invalid"}, 2}, | ||
| 25 | {[]string{"neferwl", "--backend=drm", "--screenshot=/tmp/shots"}, 2}, | 25 | {[]string{"neferwl", "--backend=drm", "--screenshot=/tmp/shots"}, 2}, | ||
| 26 | + | {[]string{"neferwl", "--backend=headless", "--screenshot=/tmp/shots", "--screenshot-raw"}, 2}, | |||
| 27 | + | {[]string{"neferwl", "--backend=headless", "--headless-hdr", "--screenshot-raw"}, 2}, | |||
| 26 | {[]string{"neferwl", "--timeout=-1s"}, 2}, | 28 | {[]string{"neferwl", "--timeout=-1s"}, 2}, | ||
| 27 | {[]string{"neferwl", "extra"}, 2}, | 29 | {[]string{"neferwl", "extra"}, 2}, | ||
| 28 | {[]string{"neferwl", "--config=/nonexistent/neferwl.conf"}, 1}, | 30 | {[]string{"neferwl", "--config=/nonexistent/neferwl.conf"}, 1}, | ||
M docs/headless.md +11 -0
| @@ -38,3 +38,14 @@ WAYLAND_DISPLAY=<name from the log> go run ./examples/testime -preedit nihon -co | |||
| 38 | 38 | `-count <n>` exits after n commits, and `-delay` sets the time between preedit and commit. | |
| 39 | 39 | ||
| 40 | 40 | The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move. | |
| 41 | + | ||
| 42 | + | ## Colour check | |
| 43 | + | ||
| 44 | + | `make color-check` checks that known colours come out right. It needs a GPU and `/dev/udmabuf`, and is not part of `make check` or CI (build tag `colorcheck`, test in `internal/app/colorcheck_test.go`). It runs a headless NeferWL with one 640×360 output, starts `examples/testpattern -fullscreen` as a real client, and compares 5×5 boxes at the centre of each patch of the pattern: | |
| 45 | + | ||
| 46 | + | - `TestColorCheckSDR`: an sRGB `wl_shm` client on an SDR output; the bytes of `latest.png` must equal the sRGB values (±1 per channel). | |
| 47 | + | - `TestColorCheckHDR`: a virtual HDR output (`--headless-hdr`) read back through the raw PQ codes in `latest-pq.png`. An sRGB client must give `PQ(BT.709→BT.2020 · linear · 203 nits)`, and a PQ client (`-mode hdr`: BT.2020, `st2084_pq`, XR30 dmabuf) its own PQ codes. A channel passes within ±0.012 PQ, or, for a channel expected below 0.05 nit, within 0.03 nit of luminance, which absorbs the fp16 residue near black where PQ is very steep (about 0.02 nit measured on a zero channel of a BT.2020 primary on an RX 9070 XT). If the output does not report PQ (virtual HDR fell back to SDR), the test fails. | |
| 48 | + | ||
| 49 | + | `--screenshot-raw` is the flag behind it: with `--screenshot` and `--headless-hdr` it also writes `latest-pq.png` next to `latest.png`, a 16-bit PNG holding the 10-bit PQ codes of the exported HDR target (each code scaled to 16 bits, no cursor, no tone mapping). It is a test flag and follows the same session-protection rules as the other screenshot files. It needs an HDR modifier that supports transfer-src (test-only; the DRM path never asks for it). | |
| 50 | + | ||
| 51 | + | The check cannot see hardware scanout or the KMS plane colour pipeline. On a real HDR screen, run `WAYLAND_DISPLAY=<name> go run ./examples/testpattern -mode hdr -fullscreen` (or `-mode sdr`) and compare the patches by eye. The client asks for fullscreen 1.2 s after its first commit, because the compositor ignores earlier requests. | |
| @@ -38,3 +38,14 @@ WAYLAND_DISPLAY=<name from the log> go run ./examples/testime -preedit nihon -co | |||||
| 38 | `-count <n>` exits after n commits, and `-delay` sets the time between preedit and commit. | 38 | `-count <n>` exits after n commits, and `-delay` sets the time between preedit and commit. | ||
| 39 | 39 | ||||
| 40 | The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move. | 40 | The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move. | ||
| 41 | + | ||||
| 42 | + | ## Colour check | |||
| 43 | + | ||||
| 44 | + | `make color-check` checks that known colours come out right. It needs a GPU and `/dev/udmabuf`, and is not part of `make check` or CI (build tag `colorcheck`, test in `internal/app/colorcheck_test.go`). It runs a headless NeferWL with one 640×360 output, starts `examples/testpattern -fullscreen` as a real client, and compares 5×5 boxes at the centre of each patch of the pattern: | |||
| 45 | + | ||||
| 46 | + | - `TestColorCheckSDR`: an sRGB `wl_shm` client on an SDR output; the bytes of `latest.png` must equal the sRGB values (±1 per channel). | |||
| 47 | + | - `TestColorCheckHDR`: a virtual HDR output (`--headless-hdr`) read back through the raw PQ codes in `latest-pq.png`. An sRGB client must give `PQ(BT.709→BT.2020 · linear · 203 nits)`, and a PQ client (`-mode hdr`: BT.2020, `st2084_pq`, XR30 dmabuf) its own PQ codes. A channel passes within ±0.012 PQ, or, for a channel expected below 0.05 nit, within 0.03 nit of luminance, which absorbs the fp16 residue near black where PQ is very steep (about 0.02 nit measured on a zero channel of a BT.2020 primary on an RX 9070 XT). If the output does not report PQ (virtual HDR fell back to SDR), the test fails. | |||
| 48 | + | ||||
| 49 | + | `--screenshot-raw` is the flag behind it: with `--screenshot` and `--headless-hdr` it also writes `latest-pq.png` next to `latest.png`, a 16-bit PNG holding the 10-bit PQ codes of the exported HDR target (each code scaled to 16 bits, no cursor, no tone mapping). It is a test flag and follows the same session-protection rules as the other screenshot files. It needs an HDR modifier that supports transfer-src (test-only; the DRM path never asks for it). | |||
| 50 | + | ||||
| 51 | + | The check cannot see hardware scanout or the KMS plane colour pipeline. On a real HDR screen, run `WAYLAND_DISPLAY=<name> go run ./examples/testpattern -mode hdr -fullscreen` (or `-mode sdr`) and compare the patches by eye. The client asks for fullscreen 1.2 s after its first commit, because the compositor ignores earlier requests. | |||
A examples/testpattern/main.go +432 -0
| @@ -0,0 +1,432 @@ | |||
| 1 | + | // Command testpattern is a minimal Wayland client that shows a grid of known | |
| 2 | + | // colours, to check by eye or by screenshot that NeferWL renders them | |
| 3 | + | // correctly: | |
| 4 | + | // | |
| 5 | + | // WAYLAND_DISPLAY=wayland-1 go run ./examples/testpattern -mode hdr -fullscreen | |
| 6 | + | // | |
| 7 | + | // Modes: | |
| 8 | + | // | |
| 9 | + | // sdr XRGB8888 wl_shm buffer of sRGB patches, no colour management | |
| 10 | + | // hdr XR30 linear dmabuf of BT.2020 PQ patches, declared with | |
| 11 | + | // wp_color_manager_v1 (st2084_pq, bt2020); needs /dev/udmabuf | |
| 12 | + | // | |
| 13 | + | // The patches are defined in the pattern sub-package. The client prints | |
| 14 | + | // "ready <w> <h>" on stderr once the window is mapped and drawn at its final | |
| 15 | + | // size, and, in hdr mode, "output-tf <n>" first: the named transfer function | |
| 16 | + | // of the compositor's output (11 is PQ, so virtual or real HDR is on; 0 when | |
| 17 | + | // the output does not report one). | |
| 18 | + | // | |
| 19 | + | // With -fullscreen the window asks for fullscreen and "ready" waits for the | |
| 20 | + | // compositor to grant it. NeferWL drops set_fullscreen sent before the | |
| 21 | + | // surface is mapped and ignores client fullscreen requests during the first | |
| 22 | + | // second after the map (core's fullscreenGrace), so the client maps first and | |
| 23 | + | // sends the request 1.2 s after its first buffer commit. | |
| 24 | + | // | |
| 25 | + | // The client runs until the display closes, then exits 0. | |
| 26 | + | package main | |
| 27 | + | ||
| 28 | + | import ( | |
| 29 | + | "errors" | |
| 30 | + | "flag" | |
| 31 | + | "fmt" | |
| 32 | + | "io" | |
| 33 | + | "math" | |
| 34 | + | "net" | |
| 35 | + | "os" | |
| 36 | + | "syscall" | |
| 37 | + | "time" | |
| 38 | + | ||
| 39 | + | "github.com/bnema/neferwl/examples/testpattern/pattern" | |
| 40 | + | "github.com/bnema/wlturbo" | |
| 41 | + | "github.com/bnema/wlturbo/protocol/colormanagement" | |
| 42 | + | clientcore "github.com/bnema/wlturbo/protocol/core" | |
| 43 | + | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |
| 44 | + | "github.com/bnema/wlturbo/protocol/xdgshell" | |
| 45 | + | "golang.org/x/sys/unix" | |
| 46 | + | ) | |
| 47 | + | ||
| 48 | + | const ( | |
| 49 | + | defaultWidth, defaultHeight = 640, 360 | |
| 50 | + | // fullscreenDelay outlasts core's 1 s fullscreenGrace. | |
| 51 | + | fullscreenDelay = 1200 * time.Millisecond | |
| 52 | + | ||
| 53 | + | shmXRGB8888 = 1 | |
| 54 | + | fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24 | |
| 55 | + | ) | |
| 56 | + | ||
| 57 | + | func main() { | |
| 58 | + | mode := flag.String("mode", "sdr", "patch set: sdr or hdr") | |
| 59 | + | fullscreen := flag.Bool("fullscreen", false, "ask for fullscreen after mapping") | |
| 60 | + | flag.Parse() | |
| 61 | + | if *mode != "sdr" && *mode != "hdr" { | |
| 62 | + | fmt.Fprintf(os.Stderr, "testpattern: unknown mode %q (want sdr or hdr)\n", *mode) | |
| 63 | + | os.Exit(2) | |
| 64 | + | } | |
| 65 | + | if err := run(*mode, *fullscreen); err != nil { | |
| 66 | + | fmt.Fprintln(os.Stderr, "testpattern:", err) | |
| 67 | + | os.Exit(1) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // client holds the objects and state of one window. Only the dispatch | |
| 72 | + | // goroutine touches the state; the fullscreen timer only sends a request. | |
| 73 | + | type client struct { | |
| 74 | + | d *wlturbo.Display | |
| 75 | + | mode string | |
| 76 | + | fullscreen bool | |
| 77 | + | ||
| 78 | + | surface *clientcore.Surface | |
| 79 | + | top *xdgshell.XdgToplevel | |
| 80 | + | shm *clientcore.Shm | |
| 81 | + | dmabuf *linuxdmabuf.LinuxDmabuf | |
| 82 | + | ||
| 83 | + | // pending toplevel configure, applied by the xdg_surface configure. | |
| 84 | + | pendW, pendH int32 | |
| 85 | + | pendFull bool | |
| 86 | + | ||
| 87 | + | w, h int | |
| 88 | + | buffer *clientcore.Buffer | |
| 89 | + | ready bool | |
| 90 | + | closed bool | |
| 91 | + | err error | |
| 92 | + | xr30Lin bool // XR30 with modifier 0 advertised | |
| 93 | + | timerSet bool | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func run(mode string, fullscreen bool) error { | |
| 97 | + | d, err := wlturbo.Connect("") | |
| 98 | + | if err != nil { | |
| 99 | + | return err | |
| 100 | + | } | |
| 101 | + | defer d.Close() | |
| 102 | + | if err := d.Roundtrip(); err != nil { | |
| 103 | + | return err | |
| 104 | + | } | |
| 105 | + | c := &client{d: d, mode: mode, fullscreen: fullscreen} | |
| 106 | + | bind := func(iface string, version uint32, proxy wlturbo.Proxy) error { | |
| 107 | + | _, err := d.Registry().BindNegotiated(iface, version, proxy) | |
| 108 | + | return err | |
| 109 | + | } | |
| 110 | + | compositor := clientcore.NewCompositor(d.Context()) | |
| 111 | + | if err := bind(clientcore.CompositorInterface, 4, compositor); err != nil { | |
| 112 | + | return err | |
| 113 | + | } | |
| 114 | + | wm := xdgshell.NewXdgWmBase(d.Context()) | |
| 115 | + | if err := bind(xdgshell.XdgWmBaseInterface, 1, wm); err != nil { | |
| 116 | + | return err | |
| 117 | + | } | |
| 118 | + | wm.OnPing(func(serial uint32) { _ = wm.Pong(serial) }) | |
| 119 | + | ||
| 120 | + | var colour *colormanagement.WpColorManager | |
| 121 | + | var desc *colormanagement.WpImageDescription | |
| 122 | + | if mode == "sdr" { | |
| 123 | + | c.shm = clientcore.NewShm(d.Context()) | |
| 124 | + | if err := bind(clientcore.ShmInterface, 1, c.shm); err != nil { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | c.shm.OnFormat(func(uint32) {}) | |
| 128 | + | } else { | |
| 129 | + | c.dmabuf = linuxdmabuf.NewLinuxDmabuf(d.Context()) | |
| 130 | + | if v, err := d.Registry().BindNegotiated(linuxdmabuf.LinuxDmabufInterface, 3, c.dmabuf); err != nil { | |
| 131 | + | return err | |
| 132 | + | } else if v < 3 { | |
| 133 | + | return errors.New("linear XR30 unsupported: zwp_linux_dmabuf_v1 older than version 3") | |
| 134 | + | } | |
| 135 | + | c.dmabuf.OnFormat(func(uint32) {}) | |
| 136 | + | c.dmabuf.OnModifier(func(format, hi, lo uint32) { | |
| 137 | + | if format == fourccXR30 && hi == 0 && lo == 0 { | |
| 138 | + | c.xr30Lin = true | |
| 139 | + | } | |
| 140 | + | }) | |
| 141 | + | colour = colormanagement.NewWpColorManager(d.Context()) | |
| 142 | + | if err := bind(colormanagement.WpColorManagerInterface, 2, colour); err != nil { | |
| 143 | + | return err | |
| 144 | + | } | |
| 145 | + | if err := d.Roundtrip(); err != nil { | |
| 146 | + | return err | |
| 147 | + | } | |
| 148 | + | if !c.xr30Lin { | |
| 149 | + | return errors.New("linear XR30 unsupported") | |
| 150 | + | } | |
| 151 | + | if desc, err = c.describe(colour); err != nil { | |
| 152 | + | return err | |
| 153 | + | } | |
| 154 | + | if err := c.printOutputTF(colour); err != nil { | |
| 155 | + | return err | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | if c.surface, err = compositor.CreateSurface(); err != nil { | |
| 160 | + | return err | |
| 161 | + | } | |
| 162 | + | if desc != nil { | |
| 163 | + | cs, err := colour.GetSurface(c.surface) | |
| 164 | + | if err != nil { | |
| 165 | + | return err | |
| 166 | + | } | |
| 167 | + | if err := cs.SetImageDescription(desc, 0); err != nil { | |
| 168 | + | return err | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | xs, err := wm.GetXdgSurface(c.surface) | |
| 172 | + | if err != nil { | |
| 173 | + | return err | |
| 174 | + | } | |
| 175 | + | if c.top, err = xs.GetToplevel(); err != nil { | |
| 176 | + | return err | |
| 177 | + | } | |
| 178 | + | _ = c.top.SetTitle("testpattern") | |
| 179 | + | _ = c.top.SetAppId("testpattern") | |
| 180 | + | c.top.OnConfigure(func(w, h int32, states []byte) { | |
| 181 | + | c.pendW, c.pendH, c.pendFull = w, h, hasState(states, xdgshell.STATE_FULLSCREEN) | |
| 182 | + | }) | |
| 183 | + | c.top.OnClose(func() { c.closed = true }) | |
| 184 | + | xs.OnConfigure(func(serial uint32) { | |
| 185 | + | if err := xs.AckConfigure(serial); err != nil { | |
| 186 | + | c.err = err | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | if err := c.configured(); err != nil { | |
| 190 | + | c.err = err | |
| 191 | + | } | |
| 192 | + | }) | |
| 193 | + | if err := c.surface.Commit(); err != nil { // initial commit asks for the first configure | |
| 194 | + | return err | |
| 195 | + | } | |
| 196 | + | for !c.closed && c.err == nil { | |
| 197 | + | if err := d.Dispatch(); err != nil { | |
| 198 | + | if isClosed(err) { | |
| 199 | + | return nil | |
| 200 | + | } | |
| 201 | + | return err | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | return c.err | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // describe creates the PQ/BT.2020 image description and waits until it is | |
| 208 | + | // ready: a surface may only use a ready description. | |
| 209 | + | func (c *client) describe(colour *colormanagement.WpColorManager) (*colormanagement.WpImageDescription, error) { | |
| 210 | + | creator, err := colour.CreateParametricCreator() | |
| 211 | + | if err != nil { | |
| 212 | + | return nil, err | |
| 213 | + | } | |
| 214 | + | if err := creator.SetTfNamed(colormanagement.TRANSFER_FUNCTION_ST2084_PQ); err != nil { | |
| 215 | + | return nil, err | |
| 216 | + | } | |
| 217 | + | if err := creator.SetPrimariesNamed(colormanagement.PRIMARIES_BT2020); err != nil { | |
| 218 | + | return nil, err | |
| 219 | + | } | |
| 220 | + | desc, err := creator.Create() | |
| 221 | + | if err != nil { | |
| 222 | + | return nil, err | |
| 223 | + | } | |
| 224 | + | ready, failed := false, "" | |
| 225 | + | desc.OnReady2(func(uint32, uint32) { ready = true }) | |
| 226 | + | desc.OnFailed(func(cause uint32, msg string) { failed = fmt.Sprintf("cause %d: %s", cause, msg) }) | |
| 227 | + | if err := c.until(func() bool { return ready || failed != "" }); err != nil { | |
| 228 | + | return nil, err | |
| 229 | + | } | |
| 230 | + | if failed != "" { | |
| 231 | + | return nil, fmt.Errorf("image description failed: %s", failed) | |
| 232 | + | } | |
| 233 | + | return desc, nil | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // printOutputTF reports the named transfer function of the first output on | |
| 237 | + | // stderr as "output-tf <n>" (0 when it reports none). | |
| 238 | + | func (c *client) printOutputTF(colour *colormanagement.WpColorManager) error { | |
| 239 | + | g, ok := c.d.Registry().FindGlobal(clientcore.OutputInterface) | |
| 240 | + | if !ok { | |
| 241 | + | return errors.New("compositor has no wl_output") | |
| 242 | + | } | |
| 243 | + | output := clientcore.NewOutput(c.d.Context()) | |
| 244 | + | if err := c.d.Registry().Bind(g.Name, g.Interface, min(g.Version, 4), output); err != nil { | |
| 245 | + | return err | |
| 246 | + | } | |
| 247 | + | co, err := colour.GetOutput(output) | |
| 248 | + | if err != nil { | |
| 249 | + | return err | |
| 250 | + | } | |
| 251 | + | desc, err := co.GetImageDescription() | |
| 252 | + | if err != nil { | |
| 253 | + | return err | |
| 254 | + | } | |
| 255 | + | ready, failed := false, false | |
| 256 | + | desc.OnReady2(func(uint32, uint32) { ready = true }) | |
| 257 | + | desc.OnFailed(func(uint32, string) { failed = true }) | |
| 258 | + | if err := c.until(func() bool { return ready || failed }); err != nil { | |
| 259 | + | return err | |
| 260 | + | } | |
| 261 | + | if failed { | |
| 262 | + | return errors.New("output image description failed") | |
| 263 | + | } | |
| 264 | + | info, err := desc.GetInformation() | |
| 265 | + | if err != nil { | |
| 266 | + | return err | |
| 267 | + | } | |
| 268 | + | tf, done := uint32(0), false | |
| 269 | + | info.OnTfNamed(func(v uint32) { tf = v }) | |
| 270 | + | info.OnDone(func() { done = true }) | |
| 271 | + | if err := c.until(func() bool { return done }); err != nil { | |
| 272 | + | return err | |
| 273 | + | } | |
| 274 | + | fmt.Fprintf(os.Stderr, "output-tf %d\n", tf) | |
| 275 | + | return nil | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // until roundtrips until cond holds; setup events arrive within a few. | |
| 279 | + | func (c *client) until(cond func() bool) error { | |
| 280 | + | for range 20 { | |
| 281 | + | if cond() { | |
| 282 | + | return nil | |
| 283 | + | } | |
| 284 | + | if err := c.d.Roundtrip(); err != nil { | |
| 285 | + | return err | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | if cond() { | |
| 289 | + | return nil | |
| 290 | + | } | |
| 291 | + | return errors.New("compositor did not answer") | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // configured applies a toplevel configure after it was acknowledged: it | |
| 295 | + | // (re)builds the buffer when the size changed, commits, and reports ready. | |
| 296 | + | func (c *client) configured() error { | |
| 297 | + | w, h := int(c.pendW), int(c.pendH) | |
| 298 | + | if w <= 0 || h <= 0 { | |
| 299 | + | w, h = c.w, c.h // the client picks the size | |
| 300 | + | if w == 0 || h == 0 { | |
| 301 | + | w, h = defaultWidth, defaultHeight | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | if c.buffer == nil || w != c.w || h != c.h { | |
| 305 | + | buf, err := c.newBuffer(w, h) | |
| 306 | + | if err != nil { | |
| 307 | + | return err | |
| 308 | + | } | |
| 309 | + | old := c.buffer | |
| 310 | + | c.buffer, c.w, c.h = buf, w, h | |
| 311 | + | defer func() { | |
| 312 | + | if old != nil { | |
| 313 | + | _ = old.Destroy() | |
| 314 | + | } | |
| 315 | + | }() | |
| 316 | + | } | |
| 317 | + | if err := c.surface.Attach(c.buffer, 0, 0); err != nil { | |
| 318 | + | return err | |
| 319 | + | } | |
| 320 | + | if err := c.surface.Damage(0, 0, int32(c.w), int32(c.h)); err != nil { | |
| 321 | + | return err | |
| 322 | + | } | |
| 323 | + | if err := c.surface.Commit(); err != nil { | |
| 324 | + | return err | |
| 325 | + | } | |
| 326 | + | if c.fullscreen && !c.timerSet { | |
| 327 | + | c.timerSet = true | |
| 328 | + | top := c.top | |
| 329 | + | time.AfterFunc(fullscreenDelay, func() { | |
| 330 | + | if err := top.SetFullscreen(nil); err != nil { | |
| 331 | + | fmt.Fprintf(os.Stderr, "testpattern: set_fullscreen: %v\n", err) | |
| 332 | + | } | |
| 333 | + | }) | |
| 334 | + | } | |
| 335 | + | if !c.ready && (!c.fullscreen || c.pendFull) { | |
| 336 | + | c.ready = true | |
| 337 | + | fmt.Fprintf(os.Stderr, "ready %d %d\n", c.w, c.h) | |
| 338 | + | } | |
| 339 | + | return nil | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func (c *client) newBuffer(w, h int) (*clientcore.Buffer, error) { | |
| 343 | + | patches := pattern.Patches(c.mode, w, h) | |
| 344 | + | if c.mode == "sdr" { | |
| 345 | + | return c.shmBuffer(w, h, patches) | |
| 346 | + | } | |
| 347 | + | return c.dmabufBuffer(w, h, patches) | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // shmBuffer draws the sRGB patches into XRGB8888 (little-endian B, G, R, X). | |
| 351 | + | func (c *client) shmBuffer(w, h int, patches []pattern.Patch) (*clientcore.Buffer, error) { | |
| 352 | + | size := w * h * 4 | |
| 353 | + | fd, err := wlturbo.CreateAnonymousFile(int64(size)) | |
| 354 | + | if err != nil { | |
| 355 | + | return nil, err | |
| 356 | + | } | |
| 357 | + | data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) | |
| 358 | + | if err != nil { | |
| 359 | + | _ = unix.Close(fd) | |
| 360 | + | return nil, err | |
| 361 | + | } | |
| 362 | + | for _, p := range patches { | |
| 363 | + | for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ { | |
| 364 | + | for x := p.Rect.Min.X; x < p.Rect.Max.X; x++ { | |
| 365 | + | i := (y*w + x) * 4 | |
| 366 | + | data[i], data[i+1], data[i+2], data[i+3] = p.SRGB[2], p.SRGB[1], p.SRGB[0], 0xff | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | // The compositor maps the file itself; ours is not needed any more. | |
| 371 | + | _ = unix.Munmap(data) | |
| 372 | + | pool, err := c.shm.CreatePool(fd, int32(size)) // closes fd once sent | |
| 373 | + | if err != nil { | |
| 374 | + | _ = unix.Close(fd) | |
| 375 | + | return nil, err | |
| 376 | + | } | |
| 377 | + | buf, err := pool.CreateBuffer(0, int32(w), int32(h), int32(w*4), shmXRGB8888) | |
| 378 | + | _ = pool.Destroy() | |
| 379 | + | return buf, err | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | // dmabufBuffer draws the PQ patches into a udmabuf as XR30 words | |
| 383 | + | // b | g<<10 | r<<20. | |
| 384 | + | func (c *client) dmabufBuffer(w, h int, patches []pattern.Patch) (*clientcore.Buffer, error) { | |
| 385 | + | code := func(nits float64) uint32 { return uint32(math.Round(pattern.PQEncode(nits) * 1023)) } | |
| 386 | + | f, err := udmabuf(w*h*4, func(data []byte) { | |
| 387 | + | for _, p := range patches { | |
| 388 | + | word := code(p.Nits[2]) | code(p.Nits[1])<<10 | code(p.Nits[0])<<20 | |
| 389 | + | for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ { | |
| 390 | + | for x := p.Rect.Min.X; x < p.Rect.Max.X; x++ { | |
| 391 | + | i := (y*w + x) * 4 | |
| 392 | + | data[i], data[i+1], data[i+2], data[i+3] = byte(word), byte(word>>8), byte(word>>16), byte(word>>24) | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | }) | |
| 397 | + | if err != nil { | |
| 398 | + | return nil, err | |
| 399 | + | } | |
| 400 | + | defer f.Close() | |
| 401 | + | params, err := c.dmabuf.CreateParams() | |
| 402 | + | if err != nil { | |
| 403 | + | return nil, err | |
| 404 | + | } | |
| 405 | + | // Add sends a duplicate: the send closes the descriptor it is given. | |
| 406 | + | fd, err := unix.Dup(int(f.Fd())) | |
| 407 | + | if err != nil { | |
| 408 | + | return nil, err | |
| 409 | + | } | |
| 410 | + | if err := params.Add(fd, 0, 0, uint32(w*4), 0, 0); err != nil { | |
| 411 | + | _ = unix.Close(fd) | |
| 412 | + | return nil, err | |
| 413 | + | } | |
| 414 | + | buf, err := params.CreateImmed(int32(w), int32(h), fourccXR30, 0) | |
| 415 | + | _ = params.Destroy() | |
| 416 | + | return buf, err | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | func hasState(states []byte, want uint32) bool { | |
| 420 | + | for i := 0; i+4 <= len(states); i += 4 { | |
| 421 | + | if uint32(states[i])|uint32(states[i+1])<<8|uint32(states[i+2])<<16|uint32(states[i+3])<<24 == want { | |
| 422 | + | return true | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | return false | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // isClosed reports whether err is the compositor going away. | |
| 429 | + | func isClosed(err error) bool { | |
| 430 | + | return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, os.ErrClosed) || | |
| 431 | + | errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) || errors.Is(err, net.ErrClosed) | |
| 432 | + | } | |
| @@ -0,0 +1,432 @@ | |||||
| 1 | + | // Command testpattern is a minimal Wayland client that shows a grid of known | |||
| 2 | + | // colours, to check by eye or by screenshot that NeferWL renders them | |||
| 3 | + | // correctly: | |||
| 4 | + | // | |||
| 5 | + | // WAYLAND_DISPLAY=wayland-1 go run ./examples/testpattern -mode hdr -fullscreen | |||
| 6 | + | // | |||
| 7 | + | // Modes: | |||
| 8 | + | // | |||
| 9 | + | // sdr XRGB8888 wl_shm buffer of sRGB patches, no colour management | |||
| 10 | + | // hdr XR30 linear dmabuf of BT.2020 PQ patches, declared with | |||
| 11 | + | // wp_color_manager_v1 (st2084_pq, bt2020); needs /dev/udmabuf | |||
| 12 | + | // | |||
| 13 | + | // The patches are defined in the pattern sub-package. The client prints | |||
| 14 | + | // "ready <w> <h>" on stderr once the window is mapped and drawn at its final | |||
| 15 | + | // size, and, in hdr mode, "output-tf <n>" first: the named transfer function | |||
| 16 | + | // of the compositor's output (11 is PQ, so virtual or real HDR is on; 0 when | |||
| 17 | + | // the output does not report one). | |||
| 18 | + | // | |||
| 19 | + | // With -fullscreen the window asks for fullscreen and "ready" waits for the | |||
| 20 | + | // compositor to grant it. NeferWL drops set_fullscreen sent before the | |||
| 21 | + | // surface is mapped and ignores client fullscreen requests during the first | |||
| 22 | + | // second after the map (core's fullscreenGrace), so the client maps first and | |||
| 23 | + | // sends the request 1.2 s after its first buffer commit. | |||
| 24 | + | // | |||
| 25 | + | // The client runs until the display closes, then exits 0. | |||
| 26 | + | package main | |||
| 27 | + | ||||
| 28 | + | import ( | |||
| 29 | + | "errors" | |||
| 30 | + | "flag" | |||
| 31 | + | "fmt" | |||
| 32 | + | "io" | |||
| 33 | + | "math" | |||
| 34 | + | "net" | |||
| 35 | + | "os" | |||
| 36 | + | "syscall" | |||
| 37 | + | "time" | |||
| 38 | + | ||||
| 39 | + | "github.com/bnema/neferwl/examples/testpattern/pattern" | |||
| 40 | + | "github.com/bnema/wlturbo" | |||
| 41 | + | "github.com/bnema/wlturbo/protocol/colormanagement" | |||
| 42 | + | clientcore "github.com/bnema/wlturbo/protocol/core" | |||
| 43 | + | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |||
| 44 | + | "github.com/bnema/wlturbo/protocol/xdgshell" | |||
| 45 | + | "golang.org/x/sys/unix" | |||
| 46 | + | ) | |||
| 47 | + | ||||
| 48 | + | const ( | |||
| 49 | + | defaultWidth, defaultHeight = 640, 360 | |||
| 50 | + | // fullscreenDelay outlasts core's 1 s fullscreenGrace. | |||
| 51 | + | fullscreenDelay = 1200 * time.Millisecond | |||
| 52 | + | ||||
| 53 | + | shmXRGB8888 = 1 | |||
| 54 | + | fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24 | |||
| 55 | + | ) | |||
| 56 | + | ||||
| 57 | + | func main() { | |||
| 58 | + | mode := flag.String("mode", "sdr", "patch set: sdr or hdr") | |||
| 59 | + | fullscreen := flag.Bool("fullscreen", false, "ask for fullscreen after mapping") | |||
| 60 | + | flag.Parse() | |||
| 61 | + | if *mode != "sdr" && *mode != "hdr" { | |||
| 62 | + | fmt.Fprintf(os.Stderr, "testpattern: unknown mode %q (want sdr or hdr)\n", *mode) | |||
| 63 | + | os.Exit(2) | |||
| 64 | + | } | |||
| 65 | + | if err := run(*mode, *fullscreen); err != nil { | |||
| 66 | + | fmt.Fprintln(os.Stderr, "testpattern:", err) | |||
| 67 | + | os.Exit(1) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // client holds the objects and state of one window. Only the dispatch | |||
| 72 | + | // goroutine touches the state; the fullscreen timer only sends a request. | |||
| 73 | + | type client struct { | |||
| 74 | + | d *wlturbo.Display | |||
| 75 | + | mode string | |||
| 76 | + | fullscreen bool | |||
| 77 | + | ||||
| 78 | + | surface *clientcore.Surface | |||
| 79 | + | top *xdgshell.XdgToplevel | |||
| 80 | + | shm *clientcore.Shm | |||
| 81 | + | dmabuf *linuxdmabuf.LinuxDmabuf | |||
| 82 | + | ||||
| 83 | + | // pending toplevel configure, applied by the xdg_surface configure. | |||
| 84 | + | pendW, pendH int32 | |||
| 85 | + | pendFull bool | |||
| 86 | + | ||||
| 87 | + | w, h int | |||
| 88 | + | buffer *clientcore.Buffer | |||
| 89 | + | ready bool | |||
| 90 | + | closed bool | |||
| 91 | + | err error | |||
| 92 | + | xr30Lin bool // XR30 with modifier 0 advertised | |||
| 93 | + | timerSet bool | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func run(mode string, fullscreen bool) error { | |||
| 97 | + | d, err := wlturbo.Connect("") | |||
| 98 | + | if err != nil { | |||
| 99 | + | return err | |||
| 100 | + | } | |||
| 101 | + | defer d.Close() | |||
| 102 | + | if err := d.Roundtrip(); err != nil { | |||
| 103 | + | return err | |||
| 104 | + | } | |||
| 105 | + | c := &client{d: d, mode: mode, fullscreen: fullscreen} | |||
| 106 | + | bind := func(iface string, version uint32, proxy wlturbo.Proxy) error { | |||
| 107 | + | _, err := d.Registry().BindNegotiated(iface, version, proxy) | |||
| 108 | + | return err | |||
| 109 | + | } | |||
| 110 | + | compositor := clientcore.NewCompositor(d.Context()) | |||
| 111 | + | if err := bind(clientcore.CompositorInterface, 4, compositor); err != nil { | |||
| 112 | + | return err | |||
| 113 | + | } | |||
| 114 | + | wm := xdgshell.NewXdgWmBase(d.Context()) | |||
| 115 | + | if err := bind(xdgshell.XdgWmBaseInterface, 1, wm); err != nil { | |||
| 116 | + | return err | |||
| 117 | + | } | |||
| 118 | + | wm.OnPing(func(serial uint32) { _ = wm.Pong(serial) }) | |||
| 119 | + | ||||
| 120 | + | var colour *colormanagement.WpColorManager | |||
| 121 | + | var desc *colormanagement.WpImageDescription | |||
| 122 | + | if mode == "sdr" { | |||
| 123 | + | c.shm = clientcore.NewShm(d.Context()) | |||
| 124 | + | if err := bind(clientcore.ShmInterface, 1, c.shm); err != nil { | |||
| 125 | + | return err | |||
| 126 | + | } | |||
| 127 | + | c.shm.OnFormat(func(uint32) {}) | |||
| 128 | + | } else { | |||
| 129 | + | c.dmabuf = linuxdmabuf.NewLinuxDmabuf(d.Context()) | |||
| 130 | + | if v, err := d.Registry().BindNegotiated(linuxdmabuf.LinuxDmabufInterface, 3, c.dmabuf); err != nil { | |||
| 131 | + | return err | |||
| 132 | + | } else if v < 3 { | |||
| 133 | + | return errors.New("linear XR30 unsupported: zwp_linux_dmabuf_v1 older than version 3") | |||
| 134 | + | } | |||
| 135 | + | c.dmabuf.OnFormat(func(uint32) {}) | |||
| 136 | + | c.dmabuf.OnModifier(func(format, hi, lo uint32) { | |||
| 137 | + | if format == fourccXR30 && hi == 0 && lo == 0 { | |||
| 138 | + | c.xr30Lin = true | |||
| 139 | + | } | |||
| 140 | + | }) | |||
| 141 | + | colour = colormanagement.NewWpColorManager(d.Context()) | |||
| 142 | + | if err := bind(colormanagement.WpColorManagerInterface, 2, colour); err != nil { | |||
| 143 | + | return err | |||
| 144 | + | } | |||
| 145 | + | if err := d.Roundtrip(); err != nil { | |||
| 146 | + | return err | |||
| 147 | + | } | |||
| 148 | + | if !c.xr30Lin { | |||
| 149 | + | return errors.New("linear XR30 unsupported") | |||
| 150 | + | } | |||
| 151 | + | if desc, err = c.describe(colour); err != nil { | |||
| 152 | + | return err | |||
| 153 | + | } | |||
| 154 | + | if err := c.printOutputTF(colour); err != nil { | |||
| 155 | + | return err | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | if c.surface, err = compositor.CreateSurface(); err != nil { | |||
| 160 | + | return err | |||
| 161 | + | } | |||
| 162 | + | if desc != nil { | |||
| 163 | + | cs, err := colour.GetSurface(c.surface) | |||
| 164 | + | if err != nil { | |||
| 165 | + | return err | |||
| 166 | + | } | |||
| 167 | + | if err := cs.SetImageDescription(desc, 0); err != nil { | |||
| 168 | + | return err | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | xs, err := wm.GetXdgSurface(c.surface) | |||
| 172 | + | if err != nil { | |||
| 173 | + | return err | |||
| 174 | + | } | |||
| 175 | + | if c.top, err = xs.GetToplevel(); err != nil { | |||
| 176 | + | return err | |||
| 177 | + | } | |||
| 178 | + | _ = c.top.SetTitle("testpattern") | |||
| 179 | + | _ = c.top.SetAppId("testpattern") | |||
| 180 | + | c.top.OnConfigure(func(w, h int32, states []byte) { | |||
| 181 | + | c.pendW, c.pendH, c.pendFull = w, h, hasState(states, xdgshell.STATE_FULLSCREEN) | |||
| 182 | + | }) | |||
| 183 | + | c.top.OnClose(func() { c.closed = true }) | |||
| 184 | + | xs.OnConfigure(func(serial uint32) { | |||
| 185 | + | if err := xs.AckConfigure(serial); err != nil { | |||
| 186 | + | c.err = err | |||
| 187 | + | return | |||
| 188 | + | } | |||
| 189 | + | if err := c.configured(); err != nil { | |||
| 190 | + | c.err = err | |||
| 191 | + | } | |||
| 192 | + | }) | |||
| 193 | + | if err := c.surface.Commit(); err != nil { // initial commit asks for the first configure | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 196 | + | for !c.closed && c.err == nil { | |||
| 197 | + | if err := d.Dispatch(); err != nil { | |||
| 198 | + | if isClosed(err) { | |||
| 199 | + | return nil | |||
| 200 | + | } | |||
| 201 | + | return err | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | return c.err | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // describe creates the PQ/BT.2020 image description and waits until it is | |||
| 208 | + | // ready: a surface may only use a ready description. | |||
| 209 | + | func (c *client) describe(colour *colormanagement.WpColorManager) (*colormanagement.WpImageDescription, error) { | |||
| 210 | + | creator, err := colour.CreateParametricCreator() | |||
| 211 | + | if err != nil { | |||
| 212 | + | return nil, err | |||
| 213 | + | } | |||
| 214 | + | if err := creator.SetTfNamed(colormanagement.TRANSFER_FUNCTION_ST2084_PQ); err != nil { | |||
| 215 | + | return nil, err | |||
| 216 | + | } | |||
| 217 | + | if err := creator.SetPrimariesNamed(colormanagement.PRIMARIES_BT2020); err != nil { | |||
| 218 | + | return nil, err | |||
| 219 | + | } | |||
| 220 | + | desc, err := creator.Create() | |||
| 221 | + | if err != nil { | |||
| 222 | + | return nil, err | |||
| 223 | + | } | |||
| 224 | + | ready, failed := false, "" | |||
| 225 | + | desc.OnReady2(func(uint32, uint32) { ready = true }) | |||
| 226 | + | desc.OnFailed(func(cause uint32, msg string) { failed = fmt.Sprintf("cause %d: %s", cause, msg) }) | |||
| 227 | + | if err := c.until(func() bool { return ready || failed != "" }); err != nil { | |||
| 228 | + | return nil, err | |||
| 229 | + | } | |||
| 230 | + | if failed != "" { | |||
| 231 | + | return nil, fmt.Errorf("image description failed: %s", failed) | |||
| 232 | + | } | |||
| 233 | + | return desc, nil | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // printOutputTF reports the named transfer function of the first output on | |||
| 237 | + | // stderr as "output-tf <n>" (0 when it reports none). | |||
| 238 | + | func (c *client) printOutputTF(colour *colormanagement.WpColorManager) error { | |||
| 239 | + | g, ok := c.d.Registry().FindGlobal(clientcore.OutputInterface) | |||
| 240 | + | if !ok { | |||
| 241 | + | return errors.New("compositor has no wl_output") | |||
| 242 | + | } | |||
| 243 | + | output := clientcore.NewOutput(c.d.Context()) | |||
| 244 | + | if err := c.d.Registry().Bind(g.Name, g.Interface, min(g.Version, 4), output); err != nil { | |||
| 245 | + | return err | |||
| 246 | + | } | |||
| 247 | + | co, err := colour.GetOutput(output) | |||
| 248 | + | if err != nil { | |||
| 249 | + | return err | |||
| 250 | + | } | |||
| 251 | + | desc, err := co.GetImageDescription() | |||
| 252 | + | if err != nil { | |||
| 253 | + | return err | |||
| 254 | + | } | |||
| 255 | + | ready, failed := false, false | |||
| 256 | + | desc.OnReady2(func(uint32, uint32) { ready = true }) | |||
| 257 | + | desc.OnFailed(func(uint32, string) { failed = true }) | |||
| 258 | + | if err := c.until(func() bool { return ready || failed }); err != nil { | |||
| 259 | + | return err | |||
| 260 | + | } | |||
| 261 | + | if failed { | |||
| 262 | + | return errors.New("output image description failed") | |||
| 263 | + | } | |||
| 264 | + | info, err := desc.GetInformation() | |||
| 265 | + | if err != nil { | |||
| 266 | + | return err | |||
| 267 | + | } | |||
| 268 | + | tf, done := uint32(0), false | |||
| 269 | + | info.OnTfNamed(func(v uint32) { tf = v }) | |||
| 270 | + | info.OnDone(func() { done = true }) | |||
| 271 | + | if err := c.until(func() bool { return done }); err != nil { | |||
| 272 | + | return err | |||
| 273 | + | } | |||
| 274 | + | fmt.Fprintf(os.Stderr, "output-tf %d\n", tf) | |||
| 275 | + | return nil | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // until roundtrips until cond holds; setup events arrive within a few. | |||
| 279 | + | func (c *client) until(cond func() bool) error { | |||
| 280 | + | for range 20 { | |||
| 281 | + | if cond() { | |||
| 282 | + | return nil | |||
| 283 | + | } | |||
| 284 | + | if err := c.d.Roundtrip(); err != nil { | |||
| 285 | + | return err | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | if cond() { | |||
| 289 | + | return nil | |||
| 290 | + | } | |||
| 291 | + | return errors.New("compositor did not answer") | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | // configured applies a toplevel configure after it was acknowledged: it | |||
| 295 | + | // (re)builds the buffer when the size changed, commits, and reports ready. | |||
| 296 | + | func (c *client) configured() error { | |||
| 297 | + | w, h := int(c.pendW), int(c.pendH) | |||
| 298 | + | if w <= 0 || h <= 0 { | |||
| 299 | + | w, h = c.w, c.h // the client picks the size | |||
| 300 | + | if w == 0 || h == 0 { | |||
| 301 | + | w, h = defaultWidth, defaultHeight | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | if c.buffer == nil || w != c.w || h != c.h { | |||
| 305 | + | buf, err := c.newBuffer(w, h) | |||
| 306 | + | if err != nil { | |||
| 307 | + | return err | |||
| 308 | + | } | |||
| 309 | + | old := c.buffer | |||
| 310 | + | c.buffer, c.w, c.h = buf, w, h | |||
| 311 | + | defer func() { | |||
| 312 | + | if old != nil { | |||
| 313 | + | _ = old.Destroy() | |||
| 314 | + | } | |||
| 315 | + | }() | |||
| 316 | + | } | |||
| 317 | + | if err := c.surface.Attach(c.buffer, 0, 0); err != nil { | |||
| 318 | + | return err | |||
| 319 | + | } | |||
| 320 | + | if err := c.surface.Damage(0, 0, int32(c.w), int32(c.h)); err != nil { | |||
| 321 | + | return err | |||
| 322 | + | } | |||
| 323 | + | if err := c.surface.Commit(); err != nil { | |||
| 324 | + | return err | |||
| 325 | + | } | |||
| 326 | + | if c.fullscreen && !c.timerSet { | |||
| 327 | + | c.timerSet = true | |||
| 328 | + | top := c.top | |||
| 329 | + | time.AfterFunc(fullscreenDelay, func() { | |||
| 330 | + | if err := top.SetFullscreen(nil); err != nil { | |||
| 331 | + | fmt.Fprintf(os.Stderr, "testpattern: set_fullscreen: %v\n", err) | |||
| 332 | + | } | |||
| 333 | + | }) | |||
| 334 | + | } | |||
| 335 | + | if !c.ready && (!c.fullscreen || c.pendFull) { | |||
| 336 | + | c.ready = true | |||
| 337 | + | fmt.Fprintf(os.Stderr, "ready %d %d\n", c.w, c.h) | |||
| 338 | + | } | |||
| 339 | + | return nil | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | func (c *client) newBuffer(w, h int) (*clientcore.Buffer, error) { | |||
| 343 | + | patches := pattern.Patches(c.mode, w, h) | |||
| 344 | + | if c.mode == "sdr" { | |||
| 345 | + | return c.shmBuffer(w, h, patches) | |||
| 346 | + | } | |||
| 347 | + | return c.dmabufBuffer(w, h, patches) | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // shmBuffer draws the sRGB patches into XRGB8888 (little-endian B, G, R, X). | |||
| 351 | + | func (c *client) shmBuffer(w, h int, patches []pattern.Patch) (*clientcore.Buffer, error) { | |||
| 352 | + | size := w * h * 4 | |||
| 353 | + | fd, err := wlturbo.CreateAnonymousFile(int64(size)) | |||
| 354 | + | if err != nil { | |||
| 355 | + | return nil, err | |||
| 356 | + | } | |||
| 357 | + | data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) | |||
| 358 | + | if err != nil { | |||
| 359 | + | _ = unix.Close(fd) | |||
| 360 | + | return nil, err | |||
| 361 | + | } | |||
| 362 | + | for _, p := range patches { | |||
| 363 | + | for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ { | |||
| 364 | + | for x := p.Rect.Min.X; x < p.Rect.Max.X; x++ { | |||
| 365 | + | i := (y*w + x) * 4 | |||
| 366 | + | data[i], data[i+1], data[i+2], data[i+3] = p.SRGB[2], p.SRGB[1], p.SRGB[0], 0xff | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | // The compositor maps the file itself; ours is not needed any more. | |||
| 371 | + | _ = unix.Munmap(data) | |||
| 372 | + | pool, err := c.shm.CreatePool(fd, int32(size)) // closes fd once sent | |||
| 373 | + | if err != nil { | |||
| 374 | + | _ = unix.Close(fd) | |||
| 375 | + | return nil, err | |||
| 376 | + | } | |||
| 377 | + | buf, err := pool.CreateBuffer(0, int32(w), int32(h), int32(w*4), shmXRGB8888) | |||
| 378 | + | _ = pool.Destroy() | |||
| 379 | + | return buf, err | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | // dmabufBuffer draws the PQ patches into a udmabuf as XR30 words | |||
| 383 | + | // b | g<<10 | r<<20. | |||
| 384 | + | func (c *client) dmabufBuffer(w, h int, patches []pattern.Patch) (*clientcore.Buffer, error) { | |||
| 385 | + | code := func(nits float64) uint32 { return uint32(math.Round(pattern.PQEncode(nits) * 1023)) } | |||
| 386 | + | f, err := udmabuf(w*h*4, func(data []byte) { | |||
| 387 | + | for _, p := range patches { | |||
| 388 | + | word := code(p.Nits[2]) | code(p.Nits[1])<<10 | code(p.Nits[0])<<20 | |||
| 389 | + | for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ { | |||
| 390 | + | for x := p.Rect.Min.X; x < p.Rect.Max.X; x++ { | |||
| 391 | + | i := (y*w + x) * 4 | |||
| 392 | + | data[i], data[i+1], data[i+2], data[i+3] = byte(word), byte(word>>8), byte(word>>16), byte(word>>24) | |||
| 393 | + | } | |||
| 394 | + | } | |||
| 395 | + | } | |||
| 396 | + | }) | |||
| 397 | + | if err != nil { | |||
| 398 | + | return nil, err | |||
| 399 | + | } | |||
| 400 | + | defer f.Close() | |||
| 401 | + | params, err := c.dmabuf.CreateParams() | |||
| 402 | + | if err != nil { | |||
| 403 | + | return nil, err | |||
| 404 | + | } | |||
| 405 | + | // Add sends a duplicate: the send closes the descriptor it is given. | |||
| 406 | + | fd, err := unix.Dup(int(f.Fd())) | |||
| 407 | + | if err != nil { | |||
| 408 | + | return nil, err | |||
| 409 | + | } | |||
| 410 | + | if err := params.Add(fd, 0, 0, uint32(w*4), 0, 0); err != nil { | |||
| 411 | + | _ = unix.Close(fd) | |||
| 412 | + | return nil, err | |||
| 413 | + | } | |||
| 414 | + | buf, err := params.CreateImmed(int32(w), int32(h), fourccXR30, 0) | |||
| 415 | + | _ = params.Destroy() | |||
| 416 | + | return buf, err | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | func hasState(states []byte, want uint32) bool { | |||
| 420 | + | for i := 0; i+4 <= len(states); i += 4 { | |||
| 421 | + | if uint32(states[i])|uint32(states[i+1])<<8|uint32(states[i+2])<<16|uint32(states[i+3])<<24 == want { | |||
| 422 | + | return true | |||
| 423 | + | } | |||
| 424 | + | } | |||
| 425 | + | return false | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | // isClosed reports whether err is the compositor going away. | |||
| 429 | + | func isClosed(err error) bool { | |||
| 430 | + | return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, os.ErrClosed) || | |||
| 431 | + | errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) || errors.Is(err, net.ErrClosed) | |||
| 432 | + | } | |||
A examples/testpattern/pattern/pattern.go +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | // Package pattern defines the colour patches of the testpattern client and | |
| 2 | + | // the transfer functions that say what each patch must look like. It has no | |
| 3 | + | // Wayland imports so tests can reuse it as an oracle; it is deliberately | |
| 4 | + | // independent of the renderer's own colour code. | |
| 5 | + | package pattern | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "image" | |
| 9 | + | "math" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | // Columns and Rows give the patch grid. | |
| 14 | + | Columns = 6 | |
| 15 | + | Rows = 2 | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Patch is one solid rectangle of the test pattern. SRGB is set in "sdr" | |
| 19 | + | // mode, Nits (linear BT.2020, absolute luminance) in "hdr" mode. | |
| 20 | + | type Patch struct { | |
| 21 | + | Rect image.Rectangle | |
| 22 | + | SRGB [3]uint8 | |
| 23 | + | Nits [3]float64 | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // Centre returns the middle of the patch. | |
| 27 | + | func (p Patch) Centre() image.Point { | |
| 28 | + | return image.Pt((p.Rect.Min.X+p.Rect.Max.X)/2, (p.Rect.Min.Y+p.Rect.Max.Y)/2) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // sdrColours are the sRGB patches in row-major order. | |
| 32 | + | var sdrColours = [Columns * Rows][3]uint8{ | |
| 33 | + | {0, 0, 0}, {64, 64, 64}, {128, 128, 128}, {192, 192, 192}, {255, 255, 255}, {255, 128, 0}, | |
| 34 | + | {255, 0, 0}, {0, 255, 0}, {0, 0, 255}, {0, 255, 255}, {255, 0, 255}, {255, 255, 0}, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // BT709ToBT2020 converts linear BT.709 RGB to linear BT.2020 RGB. | |
| 38 | + | var BT709ToBT2020 = [3][3]float64{ | |
| 39 | + | {0.627404, 0.329283, 0.043313}, | |
| 40 | + | {0.069097, 0.919540, 0.011362}, | |
| 41 | + | {0.016391, 0.088013, 0.895595}, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // ReferenceWhite is the luminance of SDR white in an HDR signal, in nits. | |
| 45 | + | const ReferenceWhite = 203 | |
| 46 | + | ||
| 47 | + | // hdrNits are the BT.2020 linear patches in row-major order. | |
| 48 | + | var hdrNits = func() (n [Columns * Rows][3]float64) { | |
| 49 | + | for i, v := range []float64{0, 1, 100, ReferenceWhite, 400, 1000} { | |
| 50 | + | n[i] = [3]float64{v, v, v} | |
| 51 | + | } | |
| 52 | + | n[6] = [3]float64{ReferenceWhite, 0, 0} | |
| 53 | + | n[7] = [3]float64{0, ReferenceWhite, 0} | |
| 54 | + | n[8] = [3]float64{0, 0, ReferenceWhite} | |
| 55 | + | for c := range 3 { | |
| 56 | + | var v [3]float64 | |
| 57 | + | for r := range 3 { | |
| 58 | + | v[r] = BT709ToBT2020[r][c] * ReferenceWhite | |
| 59 | + | } | |
| 60 | + | n[9+c] = v | |
| 61 | + | } | |
| 62 | + | return | |
| 63 | + | }() | |
| 64 | + | ||
| 65 | + | // Patches lays the mode's patches out as a 6×2 grid filling w×h. Cell sizes | |
| 66 | + | // use integer division; the last column and row take the remainder. Mode is | |
| 67 | + | // "sdr" or "hdr"; any other value yields nil. | |
| 68 | + | func Patches(mode string, w, h int) []Patch { | |
| 69 | + | if mode != "sdr" && mode != "hdr" { | |
| 70 | + | return nil | |
| 71 | + | } | |
| 72 | + | patches := make([]Patch, 0, Columns*Rows) | |
| 73 | + | for i := range Columns * Rows { | |
| 74 | + | col, row := i%Columns, i/Columns | |
| 75 | + | x0, x1 := col*(w/Columns), (col+1)*(w/Columns) | |
| 76 | + | y0, y1 := row*(h/Rows), (row+1)*(h/Rows) | |
| 77 | + | if col == Columns-1 { | |
| 78 | + | x1 = w | |
| 79 | + | } | |
| 80 | + | if row == Rows-1 { | |
| 81 | + | y1 = h | |
| 82 | + | } | |
| 83 | + | p := Patch{Rect: image.Rect(x0, y0, x1, y1)} | |
| 84 | + | if mode == "sdr" { | |
| 85 | + | p.SRGB = sdrColours[i] | |
| 86 | + | } else { | |
| 87 | + | p.Nits = hdrNits[i] | |
| 88 | + | } | |
| 89 | + | patches = append(patches, p) | |
| 90 | + | } | |
| 91 | + | return patches | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // PQEncode encodes absolute luminance in nits with SMPTE ST 2084, clamped to | |
| 95 | + | // 0–10000 nits, and returns a unit-range signal. | |
| 96 | + | func PQEncode(nits float64) float64 { | |
| 97 | + | if nits <= 0 { | |
| 98 | + | return 0 | |
| 99 | + | } | |
| 100 | + | x := math.Pow(min(nits, 10000)/10000, 2610.0/16384) | |
| 101 | + | return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // PQDecode is the SMPTE ST 2084 EOTF: it turns a unit-range PQ signal into | |
| 105 | + | // absolute luminance in nits (0–10000). It inverts PQEncode. | |
| 106 | + | func PQDecode(signal float64) float64 { | |
| 107 | + | if signal <= 0 { | |
| 108 | + | return 0 | |
| 109 | + | } | |
| 110 | + | e := math.Pow(min(signal, 1), 32.0/2523) | |
| 111 | + | num := max(e-3424.0/4096, 0) | |
| 112 | + | den := 2413.0/128 - 2392.0/128*e | |
| 113 | + | return 10000 * math.Pow(num/den, 16384.0/2610) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // SRGBToLinear decodes a unit-range sRGB component. | |
| 117 | + | func SRGBToLinear(v float64) float64 { | |
| 118 | + | if v <= 0.04045 { | |
| 119 | + | return v / 12.92 | |
| 120 | + | } | |
| 121 | + | return math.Pow((v+0.055)/1.055, 2.4) | |
| 122 | + | } | |
| @@ -0,0 +1,122 @@ | |||||
| 1 | + | // Package pattern defines the colour patches of the testpattern client and | |||
| 2 | + | // the transfer functions that say what each patch must look like. It has no | |||
| 3 | + | // Wayland imports so tests can reuse it as an oracle; it is deliberately | |||
| 4 | + | // independent of the renderer's own colour code. | |||
| 5 | + | package pattern | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "image" | |||
| 9 | + | "math" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | // Columns and Rows give the patch grid. | |||
| 14 | + | Columns = 6 | |||
| 15 | + | Rows = 2 | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // Patch is one solid rectangle of the test pattern. SRGB is set in "sdr" | |||
| 19 | + | // mode, Nits (linear BT.2020, absolute luminance) in "hdr" mode. | |||
| 20 | + | type Patch struct { | |||
| 21 | + | Rect image.Rectangle | |||
| 22 | + | SRGB [3]uint8 | |||
| 23 | + | Nits [3]float64 | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // Centre returns the middle of the patch. | |||
| 27 | + | func (p Patch) Centre() image.Point { | |||
| 28 | + | return image.Pt((p.Rect.Min.X+p.Rect.Max.X)/2, (p.Rect.Min.Y+p.Rect.Max.Y)/2) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // sdrColours are the sRGB patches in row-major order. | |||
| 32 | + | var sdrColours = [Columns * Rows][3]uint8{ | |||
| 33 | + | {0, 0, 0}, {64, 64, 64}, {128, 128, 128}, {192, 192, 192}, {255, 255, 255}, {255, 128, 0}, | |||
| 34 | + | {255, 0, 0}, {0, 255, 0}, {0, 0, 255}, {0, 255, 255}, {255, 0, 255}, {255, 255, 0}, | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // BT709ToBT2020 converts linear BT.709 RGB to linear BT.2020 RGB. | |||
| 38 | + | var BT709ToBT2020 = [3][3]float64{ | |||
| 39 | + | {0.627404, 0.329283, 0.043313}, | |||
| 40 | + | {0.069097, 0.919540, 0.011362}, | |||
| 41 | + | {0.016391, 0.088013, 0.895595}, | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // ReferenceWhite is the luminance of SDR white in an HDR signal, in nits. | |||
| 45 | + | const ReferenceWhite = 203 | |||
| 46 | + | ||||
| 47 | + | // hdrNits are the BT.2020 linear patches in row-major order. | |||
| 48 | + | var hdrNits = func() (n [Columns * Rows][3]float64) { | |||
| 49 | + | for i, v := range []float64{0, 1, 100, ReferenceWhite, 400, 1000} { | |||
| 50 | + | n[i] = [3]float64{v, v, v} | |||
| 51 | + | } | |||
| 52 | + | n[6] = [3]float64{ReferenceWhite, 0, 0} | |||
| 53 | + | n[7] = [3]float64{0, ReferenceWhite, 0} | |||
| 54 | + | n[8] = [3]float64{0, 0, ReferenceWhite} | |||
| 55 | + | for c := range 3 { | |||
| 56 | + | var v [3]float64 | |||
| 57 | + | for r := range 3 { | |||
| 58 | + | v[r] = BT709ToBT2020[r][c] * ReferenceWhite | |||
| 59 | + | } | |||
| 60 | + | n[9+c] = v | |||
| 61 | + | } | |||
| 62 | + | return | |||
| 63 | + | }() | |||
| 64 | + | ||||
| 65 | + | // Patches lays the mode's patches out as a 6×2 grid filling w×h. Cell sizes | |||
| 66 | + | // use integer division; the last column and row take the remainder. Mode is | |||
| 67 | + | // "sdr" or "hdr"; any other value yields nil. | |||
| 68 | + | func Patches(mode string, w, h int) []Patch { | |||
| 69 | + | if mode != "sdr" && mode != "hdr" { | |||
| 70 | + | return nil | |||
| 71 | + | } | |||
| 72 | + | patches := make([]Patch, 0, Columns*Rows) | |||
| 73 | + | for i := range Columns * Rows { | |||
| 74 | + | col, row := i%Columns, i/Columns | |||
| 75 | + | x0, x1 := col*(w/Columns), (col+1)*(w/Columns) | |||
| 76 | + | y0, y1 := row*(h/Rows), (row+1)*(h/Rows) | |||
| 77 | + | if col == Columns-1 { | |||
| 78 | + | x1 = w | |||
| 79 | + | } | |||
| 80 | + | if row == Rows-1 { | |||
| 81 | + | y1 = h | |||
| 82 | + | } | |||
| 83 | + | p := Patch{Rect: image.Rect(x0, y0, x1, y1)} | |||
| 84 | + | if mode == "sdr" { | |||
| 85 | + | p.SRGB = sdrColours[i] | |||
| 86 | + | } else { | |||
| 87 | + | p.Nits = hdrNits[i] | |||
| 88 | + | } | |||
| 89 | + | patches = append(patches, p) | |||
| 90 | + | } | |||
| 91 | + | return patches | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // PQEncode encodes absolute luminance in nits with SMPTE ST 2084, clamped to | |||
| 95 | + | // 0–10000 nits, and returns a unit-range signal. | |||
| 96 | + | func PQEncode(nits float64) float64 { | |||
| 97 | + | if nits <= 0 { | |||
| 98 | + | return 0 | |||
| 99 | + | } | |||
| 100 | + | x := math.Pow(min(nits, 10000)/10000, 2610.0/16384) | |||
| 101 | + | return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // PQDecode is the SMPTE ST 2084 EOTF: it turns a unit-range PQ signal into | |||
| 105 | + | // absolute luminance in nits (0–10000). It inverts PQEncode. | |||
| 106 | + | func PQDecode(signal float64) float64 { | |||
| 107 | + | if signal <= 0 { | |||
| 108 | + | return 0 | |||
| 109 | + | } | |||
| 110 | + | e := math.Pow(min(signal, 1), 32.0/2523) | |||
| 111 | + | num := max(e-3424.0/4096, 0) | |||
| 112 | + | den := 2413.0/128 - 2392.0/128*e | |||
| 113 | + | return 10000 * math.Pow(num/den, 16384.0/2610) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // SRGBToLinear decodes a unit-range sRGB component. | |||
| 117 | + | func SRGBToLinear(v float64) float64 { | |||
| 118 | + | if v <= 0.04045 { | |||
| 119 | + | return v / 12.92 | |||
| 120 | + | } | |||
| 121 | + | return math.Pow((v+0.055)/1.055, 2.4) | |||
| 122 | + | } | |||
A examples/testpattern/pattern/pattern_test.go +100 -0
| @@ -0,0 +1,100 @@ | |||
| 1 | + | package pattern | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "math" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPatchesTileTheSurface(t *testing.T) { | |
| 10 | + | for _, mode := range []string{"sdr", "hdr"} { | |
| 11 | + | for _, size := range [][2]int{{640, 360}, {641, 361}} { | |
| 12 | + | w, h := size[0], size[1] | |
| 13 | + | patches := Patches(mode, w, h) | |
| 14 | + | if len(patches) != Columns*Rows { | |
| 15 | + | t.Fatalf("%s %dx%d: %d patches", mode, w, h, len(patches)) | |
| 16 | + | } | |
| 17 | + | covered := make([]int, w*h) | |
| 18 | + | for _, p := range patches { | |
| 19 | + | if !p.Rect.In(image.Rect(0, 0, w, h)) || p.Rect.Empty() { | |
| 20 | + | t.Fatalf("%s %dx%d: bad rect %v", mode, w, h, p.Rect) | |
| 21 | + | } | |
| 22 | + | for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ { | |
| 23 | + | for x := p.Rect.Min.X; x < p.Rect.Max.X; x++ { | |
| 24 | + | covered[y*w+x]++ | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | for i, n := range covered { | |
| 29 | + | if n != 1 { | |
| 30 | + | t.Fatalf("%s %dx%d: pixel (%d,%d) covered %d times", mode, w, h, i%w, i/w, n) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestPatchesModes(t *testing.T) { | |
| 38 | + | if Patches("bogus", 640, 360) != nil { | |
| 39 | + | t.Fatal("unknown mode must yield nil") | |
| 40 | + | } | |
| 41 | + | sdr := Patches("sdr", 600, 200) | |
| 42 | + | if sdr[5].SRGB != [3]uint8{255, 128, 0} || sdr[11].SRGB != [3]uint8{255, 255, 0} { | |
| 43 | + | t.Fatalf("sdr patches: %v", sdr) | |
| 44 | + | } | |
| 45 | + | hdr := Patches("hdr", 600, 200) | |
| 46 | + | if hdr[3].Nits != [3]float64{203, 203, 203} || hdr[5].Nits != [3]float64{1000, 1000, 1000} { | |
| 47 | + | t.Fatalf("hdr neutrals: %v", hdr) | |
| 48 | + | } | |
| 49 | + | // BT.709 red at 203 nits in BT.2020. | |
| 50 | + | want := [3]float64{0.627404 * 203, 0.069097 * 203, 0.016391 * 203} | |
| 51 | + | for i, v := range hdr[9].Nits { | |
| 52 | + | if math.Abs(v-want[i]) > 1e-9 { | |
| 53 | + | t.Fatalf("709 red = %v, want %v", hdr[9].Nits, want) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | if c := sdr[0].Centre(); c != image.Pt(50, 50) { | |
| 57 | + | t.Fatalf("centre = %v", c) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestPQEncode(t *testing.T) { | |
| 62 | + | if v := PQEncode(203); math.Abs(v-0.5807) > 1e-4 { | |
| 63 | + | t.Fatalf("PQEncode(203) = %v", v) | |
| 64 | + | } | |
| 65 | + | if v := PQEncode(10000); math.Abs(v-1) > 1e-4 { | |
| 66 | + | t.Fatalf("PQEncode(10000) = %v", v) | |
| 67 | + | } | |
| 68 | + | if PQEncode(0) != 0 || PQEncode(-1) != 0 { | |
| 69 | + | t.Fatal("PQEncode must clamp to zero") | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestPQDecodeInvertsPQEncode(t *testing.T) { | |
| 74 | + | for _, nits := range []float64{0.001, 0.02, 1, 10, 100, 203, 400, 1000, 4000, 10000} { | |
| 75 | + | if got := PQDecode(PQEncode(nits)); math.Abs(got-nits) > nits*1e-9+1e-9 { | |
| 76 | + | t.Errorf("PQDecode(PQEncode(%v)) = %v", nits, got) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | for _, v := range []float64{0, 0.0313, 0.1508, 0.5807, 0.75, 1} { | |
| 80 | + | if got := PQEncode(PQDecode(v)); math.Abs(got-v) > 1e-9 { | |
| 81 | + | t.Errorf("PQEncode(PQDecode(%v)) = %v", v, got) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | if PQDecode(0) != 0 || PQDecode(-1) != 0 || math.Abs(PQDecode(1)-10000) > 1e-6 { | |
| 85 | + | t.Fatal("PQDecode endpoints") | |
| 86 | + | } | |
| 87 | + | // The compositor's near-black residue (0.0313 PQ) is a few hundredths of a nit. | |
| 88 | + | if v := PQDecode(0.0313); v < 0.01 || v > 0.05 { | |
| 89 | + | t.Fatalf("PQDecode(0.0313) = %v nits", v) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestSRGBToLinear(t *testing.T) { | |
| 94 | + | if SRGBToLinear(0) != 0 || math.Abs(SRGBToLinear(1)-1) > 1e-12 { | |
| 95 | + | t.Fatal("endpoints") | |
| 96 | + | } | |
| 97 | + | if v := SRGBToLinear(128.0 / 255); math.Abs(v-0.2158605) > 1e-6 { | |
| 98 | + | t.Fatalf("SRGBToLinear(128/255) = %v", v) | |
| 99 | + | } | |
| 100 | + | } | |
| @@ -0,0 +1,100 @@ | |||||
| 1 | + | package pattern | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "math" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestPatchesTileTheSurface(t *testing.T) { | |||
| 10 | + | for _, mode := range []string{"sdr", "hdr"} { | |||
| 11 | + | for _, size := range [][2]int{{640, 360}, {641, 361}} { | |||
| 12 | + | w, h := size[0], size[1] | |||
| 13 | + | patches := Patches(mode, w, h) | |||
| 14 | + | if len(patches) != Columns*Rows { | |||
| 15 | + | t.Fatalf("%s %dx%d: %d patches", mode, w, h, len(patches)) | |||
| 16 | + | } | |||
| 17 | + | covered := make([]int, w*h) | |||
| 18 | + | for _, p := range patches { | |||
| 19 | + | if !p.Rect.In(image.Rect(0, 0, w, h)) || p.Rect.Empty() { | |||
| 20 | + | t.Fatalf("%s %dx%d: bad rect %v", mode, w, h, p.Rect) | |||
| 21 | + | } | |||
| 22 | + | for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ { | |||
| 23 | + | for x := p.Rect.Min.X; x < p.Rect.Max.X; x++ { | |||
| 24 | + | covered[y*w+x]++ | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | for i, n := range covered { | |||
| 29 | + | if n != 1 { | |||
| 30 | + | t.Fatalf("%s %dx%d: pixel (%d,%d) covered %d times", mode, w, h, i%w, i/w, n) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestPatchesModes(t *testing.T) { | |||
| 38 | + | if Patches("bogus", 640, 360) != nil { | |||
| 39 | + | t.Fatal("unknown mode must yield nil") | |||
| 40 | + | } | |||
| 41 | + | sdr := Patches("sdr", 600, 200) | |||
| 42 | + | if sdr[5].SRGB != [3]uint8{255, 128, 0} || sdr[11].SRGB != [3]uint8{255, 255, 0} { | |||
| 43 | + | t.Fatalf("sdr patches: %v", sdr) | |||
| 44 | + | } | |||
| 45 | + | hdr := Patches("hdr", 600, 200) | |||
| 46 | + | if hdr[3].Nits != [3]float64{203, 203, 203} || hdr[5].Nits != [3]float64{1000, 1000, 1000} { | |||
| 47 | + | t.Fatalf("hdr neutrals: %v", hdr) | |||
| 48 | + | } | |||
| 49 | + | // BT.709 red at 203 nits in BT.2020. | |||
| 50 | + | want := [3]float64{0.627404 * 203, 0.069097 * 203, 0.016391 * 203} | |||
| 51 | + | for i, v := range hdr[9].Nits { | |||
| 52 | + | if math.Abs(v-want[i]) > 1e-9 { | |||
| 53 | + | t.Fatalf("709 red = %v, want %v", hdr[9].Nits, want) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | if c := sdr[0].Centre(); c != image.Pt(50, 50) { | |||
| 57 | + | t.Fatalf("centre = %v", c) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestPQEncode(t *testing.T) { | |||
| 62 | + | if v := PQEncode(203); math.Abs(v-0.5807) > 1e-4 { | |||
| 63 | + | t.Fatalf("PQEncode(203) = %v", v) | |||
| 64 | + | } | |||
| 65 | + | if v := PQEncode(10000); math.Abs(v-1) > 1e-4 { | |||
| 66 | + | t.Fatalf("PQEncode(10000) = %v", v) | |||
| 67 | + | } | |||
| 68 | + | if PQEncode(0) != 0 || PQEncode(-1) != 0 { | |||
| 69 | + | t.Fatal("PQEncode must clamp to zero") | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestPQDecodeInvertsPQEncode(t *testing.T) { | |||
| 74 | + | for _, nits := range []float64{0.001, 0.02, 1, 10, 100, 203, 400, 1000, 4000, 10000} { | |||
| 75 | + | if got := PQDecode(PQEncode(nits)); math.Abs(got-nits) > nits*1e-9+1e-9 { | |||
| 76 | + | t.Errorf("PQDecode(PQEncode(%v)) = %v", nits, got) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | for _, v := range []float64{0, 0.0313, 0.1508, 0.5807, 0.75, 1} { | |||
| 80 | + | if got := PQEncode(PQDecode(v)); math.Abs(got-v) > 1e-9 { | |||
| 81 | + | t.Errorf("PQEncode(PQDecode(%v)) = %v", v, got) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | if PQDecode(0) != 0 || PQDecode(-1) != 0 || math.Abs(PQDecode(1)-10000) > 1e-6 { | |||
| 85 | + | t.Fatal("PQDecode endpoints") | |||
| 86 | + | } | |||
| 87 | + | // The compositor's near-black residue (0.0313 PQ) is a few hundredths of a nit. | |||
| 88 | + | if v := PQDecode(0.0313); v < 0.01 || v > 0.05 { | |||
| 89 | + | t.Fatalf("PQDecode(0.0313) = %v nits", v) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestSRGBToLinear(t *testing.T) { | |||
| 94 | + | if SRGBToLinear(0) != 0 || math.Abs(SRGBToLinear(1)-1) > 1e-12 { | |||
| 95 | + | t.Fatal("endpoints") | |||
| 96 | + | } | |||
| 97 | + | if v := SRGBToLinear(128.0 / 255); math.Abs(v-0.2158605) > 1e-6 { | |||
| 98 | + | t.Fatalf("SRGBToLinear(128/255) = %v", v) | |||
| 99 | + | } | |||
| 100 | + | } | |||
A examples/testpattern/udmabuf.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "unsafe" | |
| 7 | + | ||
| 8 | + | "golang.org/x/sys/unix" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // udmabuf turns memfd pages into a real dmabuf: a linear buffer any GPU | |
| 12 | + | // driver imports. fill gets the page-aligned backing store, size bytes long. | |
| 13 | + | func udmabuf(size int, fill func(data []byte)) (*os.File, error) { | |
| 14 | + | dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0) | |
| 15 | + | if err != nil { | |
| 16 | + | return nil, fmt.Errorf("udmabuf unavailable: %w", err) | |
| 17 | + | } | |
| 18 | + | defer dev.Close() | |
| 19 | + | size = (size + os.Getpagesize() - 1) / os.Getpagesize() * os.Getpagesize() | |
| 20 | + | mem, err := unix.MemfdCreate("testpattern-dmabuf", unix.MFD_ALLOW_SEALING|unix.MFD_CLOEXEC) | |
| 21 | + | if err != nil { | |
| 22 | + | return nil, err | |
| 23 | + | } | |
| 24 | + | defer unix.Close(mem) | |
| 25 | + | if err := unix.Ftruncate(mem, int64(size)); err != nil { | |
| 26 | + | return nil, err | |
| 27 | + | } | |
| 28 | + | data, err := unix.Mmap(mem, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) | |
| 29 | + | if err != nil { | |
| 30 | + | return nil, err | |
| 31 | + | } | |
| 32 | + | fill(data) | |
| 33 | + | if err := unix.Munmap(data); err != nil { | |
| 34 | + | return nil, err | |
| 35 | + | } | |
| 36 | + | if _, err := unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK); err != nil { | |
| 37 | + | return nil, err | |
| 38 | + | } | |
| 39 | + | // struct udmabuf_create { u32 memfd; u32 flags; u64 offset; u64 size; } | |
| 40 | + | arg := struct { | |
| 41 | + | memfd, flags uint32 | |
| 42 | + | offset, size uint64 | |
| 43 | + | }{memfd: uint32(mem), flags: 1 /* CLOEXEC */, size: uint64(size)} | |
| 44 | + | const udmabufCreate = 0x40187542 // _IOW('u', 0x42, struct udmabuf_create) | |
| 45 | + | fd, _, errno := unix.Syscall(unix.SYS_IOCTL, dev.Fd(), udmabufCreate, uintptr(unsafe.Pointer(&arg))) | |
| 46 | + | if errno != 0 { | |
| 47 | + | return nil, fmt.Errorf("udmabuf create: %w", errno) | |
| 48 | + | } | |
| 49 | + | return os.NewFile(fd, "dmabuf"), nil | |
| 50 | + | } | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "unsafe" | |||
| 7 | + | ||||
| 8 | + | "golang.org/x/sys/unix" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // udmabuf turns memfd pages into a real dmabuf: a linear buffer any GPU | |||
| 12 | + | // driver imports. fill gets the page-aligned backing store, size bytes long. | |||
| 13 | + | func udmabuf(size int, fill func(data []byte)) (*os.File, error) { | |||
| 14 | + | dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0) | |||
| 15 | + | if err != nil { | |||
| 16 | + | return nil, fmt.Errorf("udmabuf unavailable: %w", err) | |||
| 17 | + | } | |||
| 18 | + | defer dev.Close() | |||
| 19 | + | size = (size + os.Getpagesize() - 1) / os.Getpagesize() * os.Getpagesize() | |||
| 20 | + | mem, err := unix.MemfdCreate("testpattern-dmabuf", unix.MFD_ALLOW_SEALING|unix.MFD_CLOEXEC) | |||
| 21 | + | if err != nil { | |||
| 22 | + | return nil, err | |||
| 23 | + | } | |||
| 24 | + | defer unix.Close(mem) | |||
| 25 | + | if err := unix.Ftruncate(mem, int64(size)); err != nil { | |||
| 26 | + | return nil, err | |||
| 27 | + | } | |||
| 28 | + | data, err := unix.Mmap(mem, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) | |||
| 29 | + | if err != nil { | |||
| 30 | + | return nil, err | |||
| 31 | + | } | |||
| 32 | + | fill(data) | |||
| 33 | + | if err := unix.Munmap(data); err != nil { | |||
| 34 | + | return nil, err | |||
| 35 | + | } | |||
| 36 | + | if _, err := unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK); err != nil { | |||
| 37 | + | return nil, err | |||
| 38 | + | } | |||
| 39 | + | // struct udmabuf_create { u32 memfd; u32 flags; u64 offset; u64 size; } | |||
| 40 | + | arg := struct { | |||
| 41 | + | memfd, flags uint32 | |||
| 42 | + | offset, size uint64 | |||
| 43 | + | }{memfd: uint32(mem), flags: 1 /* CLOEXEC */, size: uint64(size)} | |||
| 44 | + | const udmabufCreate = 0x40187542 // _IOW('u', 0x42, struct udmabuf_create) | |||
| 45 | + | fd, _, errno := unix.Syscall(unix.SYS_IOCTL, dev.Fd(), udmabufCreate, uintptr(unsafe.Pointer(&arg))) | |||
| 46 | + | if errno != 0 { | |||
| 47 | + | return nil, fmt.Errorf("udmabuf create: %w", errno) | |||
| 48 | + | } | |||
| 49 | + | return os.NewFile(fd, "dmabuf"), nil | |||
| 50 | + | } | |||
M internal/adapters/headless/output.go +23 -5
| @@ -34,10 +34,13 @@ type Options struct { | |||
| 34 | 34 | LoadCursor func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) | |
| 35 | 35 | Width, Height int | |
| 36 | 36 | ScreenshotDir string | |
| 37 | - | HDR bool // virtual HDR output for protocol testing only | |
| 38 | - | Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting | |
| 39 | - | Log zerowrap.Logger | |
| 40 | - | NewRenderer func(w, h int) (ports.Renderer, error) | |
| 37 | + | HDR bool // virtual HDR output for protocol testing only | |
| 38 | + | // RawHDR also writes latest-pq.png, the raw PQ codes of an HDR output | |
| 39 | + | // (16-bit), next to the screenshots. Test-only; needs HDR and ScreenshotDir. | |
| 40 | + | RawHDR bool | |
| 41 | + | Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting | |
| 42 | + | Log zerowrap.Logger | |
| 43 | + | NewRenderer func(w, h int) (ports.Renderer, error) | |
| 41 | 44 | // NewCaptureRenderer makes the child renderer of a hidden workspace | |
| 42 | 45 | // capture session (Scene.CaptureScene). Nil: such captures fail closed. | |
| 43 | 46 | NewCaptureRenderer func(w, h int) (ports.Renderer, error) | |
| @@ -594,6 +597,21 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 594 | 597 | if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil { | |
| 595 | 598 | return fmt.Errorf("latest screenshot: %w", err) | |
| 596 | 599 | } | |
| 600 | + | if opts.RawHDR && opts.HDR { | |
| 601 | + | // No cursor on the raw image. Only a confirmed HDR output | |
| 602 | + | // has a readable PQ target; else HDRPixels is nil. | |
| 603 | + | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| 604 | + | continue | |
| 605 | + | } | |
| 606 | + | raw := r.HDRPixels() | |
| 607 | + | if raw == nil { | |
| 608 | + | opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("raw HDR readback unavailable; frame skipped") | |
| 609 | + | continue | |
| 610 | + | } | |
| 611 | + | if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest-pq.png"), raw, opts.Security, scene.Security); err != nil { | |
| 612 | + | return fmt.Errorf("raw HDR screenshot: %w", err) | |
| 613 | + | } | |
| 614 | + | } | |
| 597 | 615 | } | |
| 598 | 616 | opts.Log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame") | |
| 599 | 617 | } | |
| @@ -603,7 +621,7 @@ func (opts Options) protected() bool { | |||
| 603 | 621 | return opts.Security != nil && opts.Security.Snapshot().Protected | |
| 604 | 622 | } | |
| 605 | 623 | ||
| 606 | - | func writePNGSecure(path string, img *image.RGBA, security ports.SessionSecurity, state ports.SecurityState) error { | |
| 624 | + | func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error { | |
| 607 | 625 | if security != nil && (state.Protected || security.Snapshot() != state) { | |
| 608 | 626 | return nil | |
| 609 | 627 | } | |
| @@ -34,10 +34,13 @@ type Options struct { | |||||
| 34 | LoadCursor func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) | 34 | LoadCursor func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) | ||
| 35 | Width, Height int | 35 | Width, Height int | ||
| 36 | ScreenshotDir string | 36 | ScreenshotDir string | ||
| 37 | - | HDR bool // virtual HDR output for protocol testing only | 37 | + | HDR bool // virtual HDR output for protocol testing only |
| 38 | - | Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting | 38 | + | // RawHDR also writes latest-pq.png, the raw PQ codes of an HDR output |
| 39 | - | Log zerowrap.Logger | 39 | + | // (16-bit), next to the screenshots. Test-only; needs HDR and ScreenshotDir. |
| 40 | - | NewRenderer func(w, h int) (ports.Renderer, error) | 40 | + | RawHDR bool |
| 41 | + | Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting | |||
| 42 | + | Log zerowrap.Logger | |||
| 43 | + | NewRenderer func(w, h int) (ports.Renderer, error) | |||
| 41 | // NewCaptureRenderer makes the child renderer of a hidden workspace | 44 | // NewCaptureRenderer makes the child renderer of a hidden workspace | ||
| 42 | // capture session (Scene.CaptureScene). Nil: such captures fail closed. | 45 | // capture session (Scene.CaptureScene). Nil: such captures fail closed. | ||
| 43 | NewCaptureRenderer func(w, h int) (ports.Renderer, error) | 46 | NewCaptureRenderer func(w, h int) (ports.Renderer, error) | ||
| @@ -594,6 +597,21 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 594 | if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil { | 597 | if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil { | ||
| 595 | return fmt.Errorf("latest screenshot: %w", err) | 598 | return fmt.Errorf("latest screenshot: %w", err) | ||
| 596 | } | 599 | } | ||
| 600 | + | if opts.RawHDR && opts.HDR { | |||
| 601 | + | // No cursor on the raw image. Only a confirmed HDR output | |||
| 602 | + | // has a readable PQ target; else HDRPixels is nil. | |||
| 603 | + | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |||
| 604 | + | continue | |||
| 605 | + | } | |||
| 606 | + | raw := r.HDRPixels() | |||
| 607 | + | if raw == nil { | |||
| 608 | + | opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("raw HDR readback unavailable; frame skipped") | |||
| 609 | + | continue | |||
| 610 | + | } | |||
| 611 | + | if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest-pq.png"), raw, opts.Security, scene.Security); err != nil { | |||
| 612 | + | return fmt.Errorf("raw HDR screenshot: %w", err) | |||
| 613 | + | } | |||
| 614 | + | } | |||
| 597 | } | 615 | } | ||
| 598 | opts.Log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame") | 616 | opts.Log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame") | ||
| 599 | } | 617 | } | ||
| @@ -603,7 +621,7 @@ func (opts Options) protected() bool { | |||||
| 603 | return opts.Security != nil && opts.Security.Snapshot().Protected | 621 | return opts.Security != nil && opts.Security.Snapshot().Protected | ||
| 604 | } | 622 | } | ||
| 605 | 623 | ||||
| 606 | - | func writePNGSecure(path string, img *image.RGBA, security ports.SessionSecurity, state ports.SecurityState) error { | 624 | + | func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error { |
| 607 | if security != nil && (state.Protected || security.Snapshot() != state) { | 625 | if security != nil && (state.Protected || security.Snapshot() != state) { | ||
| 608 | return nil | 626 | return nil | ||
| 609 | } | 627 | } | ||
M internal/adapters/headless/output_test.go +76 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "image" | |
| 7 | + | "image/color" | |
| 7 | 8 | "image/png" | |
| 8 | 9 | "maps" | |
| 9 | 10 | "os" | |
| @@ -293,6 +294,81 @@ func TestCaptureForcesFreshHeadlessFrame(t *testing.T) { | |||
| 293 | 294 | } | |
| 294 | 295 | } | |
| 295 | 296 | ||
| 297 | + | // RawHDR writes the renderer's raw PQ codes as a 16-bit latest-pq.png next | |
| 298 | + | // to the 8-bit screenshots. | |
| 299 | + | func TestHeadlessRawHDRScreenshot(t *testing.T) { | |
| 300 | + | dir := t.TempDir() | |
| 301 | + | want := image.NewRGBA64(image.Rect(0, 0, 2, 2)) | |
| 302 | + | for i := range 4 { | |
| 303 | + | want.SetRGBA64(i%2, i/2, color.RGBA64{R: uint16(i*0x1111 + 0x0123), G: 0x8000 + uint16(i), B: 0xffc0, A: 0xffff}) | |
| 304 | + | } | |
| 305 | + | f, err := os.CreateTemp(t.TempDir(), "target") | |
| 306 | + | if err != nil { | |
| 307 | + | t.Fatal(err) | |
| 308 | + | } | |
| 309 | + | r := portsmocks.NewMockRenderer(t) | |
| 310 | + | r.EXPECT().SetHDR(float64(203)).Return().Once() | |
| 311 | + | r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once() | |
| 312 | + | rendered := make(chan struct{}, 4) | |
| 313 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 314 | + | rendered <- struct{}{} | |
| 315 | + | return nil, nil | |
| 316 | + | }).Maybe() | |
| 317 | + | r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe() | |
| 318 | + | r.EXPECT().HDRPixels().Return(want).Maybe() | |
| 319 | + | r.EXPECT().Close().Return().Once() | |
| 320 | + | scenes := make(chan ports.Scene, 1) | |
| 321 | + | scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2} | |
| 322 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 323 | + | done := make(chan error, 1) | |
| 324 | + | go func() { | |
| 325 | + | done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |
| 326 | + | }() | |
| 327 | + | select { | |
| 328 | + | case <-rendered: | |
| 329 | + | case <-time.After(3 * time.Second): | |
| 330 | + | t.Fatal("no frame") | |
| 331 | + | } | |
| 332 | + | // The files are written after Render returns. | |
| 333 | + | path := filepath.Join(dir, "latest-pq.png") | |
| 334 | + | deadline := time.Now().Add(3 * time.Second) | |
| 335 | + | for { | |
| 336 | + | if _, err := os.Stat(path); err == nil { | |
| 337 | + | break | |
| 338 | + | } | |
| 339 | + | if time.Now().After(deadline) { | |
| 340 | + | t.Fatal("no latest-pq.png") | |
| 341 | + | } | |
| 342 | + | time.Sleep(10 * time.Millisecond) | |
| 343 | + | } | |
| 344 | + | cancel() | |
| 345 | + | if err := <-done; err != nil { | |
| 346 | + | t.Fatal(err) | |
| 347 | + | } | |
| 348 | + | file, err := os.Open(path) | |
| 349 | + | if err != nil { | |
| 350 | + | t.Fatal(err) | |
| 351 | + | } | |
| 352 | + | defer file.Close() | |
| 353 | + | got, err := png.Decode(file) | |
| 354 | + | if err != nil { | |
| 355 | + | t.Fatal(err) | |
| 356 | + | } | |
| 357 | + | if _, ok := got.(*image.NRGBA64); !ok && got.ColorModel() != color.RGBA64Model { | |
| 358 | + | t.Fatalf("not a 16-bit image: %T", got) | |
| 359 | + | } | |
| 360 | + | for y := range 2 { | |
| 361 | + | for x := range 2 { | |
| 362 | + | if g, w := color.RGBA64Model.Convert(got.At(x, y)), want.RGBA64At(x, y); g != color.Color(w) { | |
| 363 | + | t.Errorf("(%d,%d) = %v, want %v", x, y, g, w) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | if _, err := os.Stat(filepath.Join(dir, "latest.png")); err != nil { | |
| 368 | + | t.Errorf("8-bit screenshot: %v", err) | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | ||
| 296 | 372 | // Only the output which successfully prepared HDR reports confirmed HDR. | |
| 297 | 373 | // The fallback must explicitly withdraw it, and close exported descriptors. | |
| 298 | 374 | func TestHeadlessHDRFormatsConfirmed(t *testing.T) { | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "image" | 6 | "image" | ||
| 7 | + | "image/color" | |||
| 7 | "image/png" | 8 | "image/png" | ||
| 8 | "maps" | 9 | "maps" | ||
| 9 | "os" | 10 | "os" | ||
| @@ -293,6 +294,81 @@ func TestCaptureForcesFreshHeadlessFrame(t *testing.T) { | |||||
| 293 | } | 294 | } | ||
| 294 | } | 295 | } | ||
| 295 | 296 | ||||
| 297 | + | // RawHDR writes the renderer's raw PQ codes as a 16-bit latest-pq.png next | |||
| 298 | + | // to the 8-bit screenshots. | |||
| 299 | + | func TestHeadlessRawHDRScreenshot(t *testing.T) { | |||
| 300 | + | dir := t.TempDir() | |||
| 301 | + | want := image.NewRGBA64(image.Rect(0, 0, 2, 2)) | |||
| 302 | + | for i := range 4 { | |||
| 303 | + | want.SetRGBA64(i%2, i/2, color.RGBA64{R: uint16(i*0x1111 + 0x0123), G: 0x8000 + uint16(i), B: 0xffc0, A: 0xffff}) | |||
| 304 | + | } | |||
| 305 | + | f, err := os.CreateTemp(t.TempDir(), "target") | |||
| 306 | + | if err != nil { | |||
| 307 | + | t.Fatal(err) | |||
| 308 | + | } | |||
| 309 | + | r := portsmocks.NewMockRenderer(t) | |||
| 310 | + | r.EXPECT().SetHDR(float64(203)).Return().Once() | |||
| 311 | + | r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once() | |||
| 312 | + | rendered := make(chan struct{}, 4) | |||
| 313 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 314 | + | rendered <- struct{}{} | |||
| 315 | + | return nil, nil | |||
| 316 | + | }).Maybe() | |||
| 317 | + | r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe() | |||
| 318 | + | r.EXPECT().HDRPixels().Return(want).Maybe() | |||
| 319 | + | r.EXPECT().Close().Return().Once() | |||
| 320 | + | scenes := make(chan ports.Scene, 1) | |||
| 321 | + | scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2} | |||
| 322 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 323 | + | done := make(chan error, 1) | |||
| 324 | + | go func() { | |||
| 325 | + | done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |||
| 326 | + | }() | |||
| 327 | + | select { | |||
| 328 | + | case <-rendered: | |||
| 329 | + | case <-time.After(3 * time.Second): | |||
| 330 | + | t.Fatal("no frame") | |||
| 331 | + | } | |||
| 332 | + | // The files are written after Render returns. | |||
| 333 | + | path := filepath.Join(dir, "latest-pq.png") | |||
| 334 | + | deadline := time.Now().Add(3 * time.Second) | |||
| 335 | + | for { | |||
| 336 | + | if _, err := os.Stat(path); err == nil { | |||
| 337 | + | break | |||
| 338 | + | } | |||
| 339 | + | if time.Now().After(deadline) { | |||
| 340 | + | t.Fatal("no latest-pq.png") | |||
| 341 | + | } | |||
| 342 | + | time.Sleep(10 * time.Millisecond) | |||
| 343 | + | } | |||
| 344 | + | cancel() | |||
| 345 | + | if err := <-done; err != nil { | |||
| 346 | + | t.Fatal(err) | |||
| 347 | + | } | |||
| 348 | + | file, err := os.Open(path) | |||
| 349 | + | if err != nil { | |||
| 350 | + | t.Fatal(err) | |||
| 351 | + | } | |||
| 352 | + | defer file.Close() | |||
| 353 | + | got, err := png.Decode(file) | |||
| 354 | + | if err != nil { | |||
| 355 | + | t.Fatal(err) | |||
| 356 | + | } | |||
| 357 | + | if _, ok := got.(*image.NRGBA64); !ok && got.ColorModel() != color.RGBA64Model { | |||
| 358 | + | t.Fatalf("not a 16-bit image: %T", got) | |||
| 359 | + | } | |||
| 360 | + | for y := range 2 { | |||
| 361 | + | for x := range 2 { | |||
| 362 | + | if g, w := color.RGBA64Model.Convert(got.At(x, y)), want.RGBA64At(x, y); g != color.Color(w) { | |||
| 363 | + | t.Errorf("(%d,%d) = %v, want %v", x, y, g, w) | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | } | |||
| 367 | + | if _, err := os.Stat(filepath.Join(dir, "latest.png")); err != nil { | |||
| 368 | + | t.Errorf("8-bit screenshot: %v", err) | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | ||||
| 296 | // Only the output which successfully prepared HDR reports confirmed HDR. | 372 | // Only the output which successfully prepared HDR reports confirmed HDR. | ||
| 297 | // The fallback must explicitly withdraw it, and close exported descriptors. | 373 | // The fallback must explicitly withdraw it, and close exported descriptors. | ||
| 298 | func TestHeadlessHDRFormatsConfirmed(t *testing.T) { | 374 | func TestHeadlessHDRFormatsConfirmed(t *testing.T) { | ||
M internal/adapters/headless/security_test.go +96 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "testing" | |
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | + | "github.com/bnema/neferwl/internal/logging" | |
| 13 | 14 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 14 | 15 | "github.com/bnema/neferwl/internal/ports" | |
| 15 | 16 | "github.com/stretchr/testify/mock" | |
| @@ -223,6 +224,101 @@ func TestSecurityCursorAndPNGSuppressed(t *testing.T) { | |||
| 223 | 224 | } | |
| 224 | 225 | } | |
| 225 | 226 | ||
| 227 | + | // The raw HDR file follows the screenshot security gating: nothing is read | |
| 228 | + | // back or written for a protected session, none is read back once the | |
| 229 | + | // session turned protected after latest.png, and a frame that crossed a | |
| 230 | + | // transition during the readback is not written. | |
| 231 | + | func TestSecurityRawHDRSuppressed(t *testing.T) { | |
| 232 | + | unprotected := ports.SecurityState{Generation: 1} | |
| 233 | + | protected := ports.SecurityState{Generation: 2, Protected: true} | |
| 234 | + | tests := []struct { | |
| 235 | + | name string | |
| 236 | + | // hdrPixels: HDRPixels is expected and flips the session protected. | |
| 237 | + | hdrPixels bool | |
| 238 | + | // flipAfterLatest: the session turns protected once latest.png exists. | |
| 239 | + | flipAfterLatest bool | |
| 240 | + | startProtected bool | |
| 241 | + | wantLatest bool | |
| 242 | + | }{ | |
| 243 | + | {name: "protected from the start", startProtected: true}, | |
| 244 | + | {name: "protected after latest.png, before the readback", flipAfterLatest: true, wantLatest: true}, | |
| 245 | + | {name: "protected during the readback", hdrPixels: true, wantLatest: true}, | |
| 246 | + | } | |
| 247 | + | for _, tc := range tests { | |
| 248 | + | t.Run(tc.name, func(t *testing.T) { | |
| 249 | + | dir := t.TempDir() | |
| 250 | + | var state atomic.Value | |
| 251 | + | state.Store(unprotected) | |
| 252 | + | if tc.startProtected { | |
| 253 | + | state.Store(protected) | |
| 254 | + | } | |
| 255 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 256 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |
| 257 | + | if tc.flipAfterLatest { | |
| 258 | + | if _, err := os.Stat(filepath.Join(dir, "latest.png")); err == nil { | |
| 259 | + | state.Store(protected) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | return state.Load().(ports.SecurityState) | |
| 263 | + | }) | |
| 264 | + | f, err := os.CreateTemp(t.TempDir(), "target") | |
| 265 | + | if err != nil { | |
| 266 | + | t.Fatal(err) | |
| 267 | + | } | |
| 268 | + | r := portsmocks.NewMockRenderer(t) | |
| 269 | + | r.EXPECT().SetHDR(float64(203)).Return().Once() | |
| 270 | + | r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once() | |
| 271 | + | rendered := make(chan ports.Scene, 4) | |
| 272 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 273 | + | rendered <- s | |
| 274 | + | return nil, nil | |
| 275 | + | }).Maybe() | |
| 276 | + | if !tc.startProtected { | |
| 277 | + | r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe() | |
| 278 | + | } | |
| 279 | + | // Without an HDRPixels expectation, a readback fails the test. | |
| 280 | + | if tc.hdrPixels { | |
| 281 | + | r.EXPECT().HDRPixels().RunAndReturn(func() *image.RGBA64 { | |
| 282 | + | state.Store(protected) | |
| 283 | + | return image.NewRGBA64(image.Rect(0, 0, 2, 2)) | |
| 284 | + | }).Once() | |
| 285 | + | } | |
| 286 | + | r.EXPECT().Close().Return().Once() | |
| 287 | + | scenes := make(chan ports.Scene, 1) | |
| 288 | + | scenes <- ports.Scene{Seq: 1, Security: unprotected, OutputWidth: 2, OutputHeight: 2} | |
| 289 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 290 | + | done := make(chan error, 1) | |
| 291 | + | go func() { | |
| 292 | + | done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Name: "H", Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |
| 293 | + | }() | |
| 294 | + | // The black protection frame follows the frame under test (or is | |
| 295 | + | // the only one): the raw block is over once it is rendered. | |
| 296 | + | for s := securityReceive(t, rendered); !s.Security.Protected; s = securityReceive(t, rendered) { | |
| 297 | + | } | |
| 298 | + | cancel() | |
| 299 | + | if err := securityReceive(t, done); err != nil { | |
| 300 | + | t.Fatal(err) | |
| 301 | + | } | |
| 302 | + | if _, err := os.Stat(filepath.Join(dir, "latest.png")); (err == nil) != tc.wantLatest { | |
| 303 | + | t.Errorf("latest.png: %v, want present %v", err, tc.wantLatest) | |
| 304 | + | } | |
| 305 | + | if _, err := os.Stat(filepath.Join(dir, "latest-pq.png")); !os.IsNotExist(err) { | |
| 306 | + | t.Errorf("latest-pq.png delivered: %v", err) | |
| 307 | + | } | |
| 308 | + | }) | |
| 309 | + | } | |
| 310 | + | // The write gate itself refuses a stale or protected 16-bit image. | |
| 311 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 312 | + | gate.EXPECT().Snapshot().Return(protected) | |
| 313 | + | path := filepath.Join(t.TempDir(), "latest-pq.png") | |
| 314 | + | if err := writePNGSecure(path, image.NewRGBA64(image.Rect(0, 0, 1, 1)), gate, ports.SecurityState{}); err != nil { | |
| 315 | + | t.Fatal(err) | |
| 316 | + | } | |
| 317 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |
| 318 | + | t.Fatal("stale raw screenshot delivered") | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 226 | 322 | func TestSecurityQueuedCaptureFailedOnceAndProofCancellation(t *testing.T) { | |
| 227 | 323 | var state atomic.Value | |
| 228 | 324 | state.Store(ports.SecurityState{}) | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "testing" | 10 | "testing" | ||
| 11 | "time" | 11 | "time" | ||
| 12 | 12 | ||||
| 13 | + | "github.com/bnema/neferwl/internal/logging" | |||
| 13 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | 14 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | ||
| 14 | "github.com/bnema/neferwl/internal/ports" | 15 | "github.com/bnema/neferwl/internal/ports" | ||
| 15 | "github.com/stretchr/testify/mock" | 16 | "github.com/stretchr/testify/mock" | ||
| @@ -223,6 +224,101 @@ func TestSecurityCursorAndPNGSuppressed(t *testing.T) { | |||||
| 223 | } | 224 | } | ||
| 224 | } | 225 | } | ||
| 225 | 226 | ||||
| 227 | + | // The raw HDR file follows the screenshot security gating: nothing is read | |||
| 228 | + | // back or written for a protected session, none is read back once the | |||
| 229 | + | // session turned protected after latest.png, and a frame that crossed a | |||
| 230 | + | // transition during the readback is not written. | |||
| 231 | + | func TestSecurityRawHDRSuppressed(t *testing.T) { | |||
| 232 | + | unprotected := ports.SecurityState{Generation: 1} | |||
| 233 | + | protected := ports.SecurityState{Generation: 2, Protected: true} | |||
| 234 | + | tests := []struct { | |||
| 235 | + | name string | |||
| 236 | + | // hdrPixels: HDRPixels is expected and flips the session protected. | |||
| 237 | + | hdrPixels bool | |||
| 238 | + | // flipAfterLatest: the session turns protected once latest.png exists. | |||
| 239 | + | flipAfterLatest bool | |||
| 240 | + | startProtected bool | |||
| 241 | + | wantLatest bool | |||
| 242 | + | }{ | |||
| 243 | + | {name: "protected from the start", startProtected: true}, | |||
| 244 | + | {name: "protected after latest.png, before the readback", flipAfterLatest: true, wantLatest: true}, | |||
| 245 | + | {name: "protected during the readback", hdrPixels: true, wantLatest: true}, | |||
| 246 | + | } | |||
| 247 | + | for _, tc := range tests { | |||
| 248 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 249 | + | dir := t.TempDir() | |||
| 250 | + | var state atomic.Value | |||
| 251 | + | state.Store(unprotected) | |||
| 252 | + | if tc.startProtected { | |||
| 253 | + | state.Store(protected) | |||
| 254 | + | } | |||
| 255 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 256 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |||
| 257 | + | if tc.flipAfterLatest { | |||
| 258 | + | if _, err := os.Stat(filepath.Join(dir, "latest.png")); err == nil { | |||
| 259 | + | state.Store(protected) | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | return state.Load().(ports.SecurityState) | |||
| 263 | + | }) | |||
| 264 | + | f, err := os.CreateTemp(t.TempDir(), "target") | |||
| 265 | + | if err != nil { | |||
| 266 | + | t.Fatal(err) | |||
| 267 | + | } | |||
| 268 | + | r := portsmocks.NewMockRenderer(t) | |||
| 269 | + | r.EXPECT().SetHDR(float64(203)).Return().Once() | |||
| 270 | + | r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once() | |||
| 271 | + | rendered := make(chan ports.Scene, 4) | |||
| 272 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 273 | + | rendered <- s | |||
| 274 | + | return nil, nil | |||
| 275 | + | }).Maybe() | |||
| 276 | + | if !tc.startProtected { | |||
| 277 | + | r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe() | |||
| 278 | + | } | |||
| 279 | + | // Without an HDRPixels expectation, a readback fails the test. | |||
| 280 | + | if tc.hdrPixels { | |||
| 281 | + | r.EXPECT().HDRPixels().RunAndReturn(func() *image.RGBA64 { | |||
| 282 | + | state.Store(protected) | |||
| 283 | + | return image.NewRGBA64(image.Rect(0, 0, 2, 2)) | |||
| 284 | + | }).Once() | |||
| 285 | + | } | |||
| 286 | + | r.EXPECT().Close().Return().Once() | |||
| 287 | + | scenes := make(chan ports.Scene, 1) | |||
| 288 | + | scenes <- ports.Scene{Seq: 1, Security: unprotected, OutputWidth: 2, OutputHeight: 2} | |||
| 289 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 290 | + | done := make(chan error, 1) | |||
| 291 | + | go func() { | |||
| 292 | + | done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Name: "H", Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |||
| 293 | + | }() | |||
| 294 | + | // The black protection frame follows the frame under test (or is | |||
| 295 | + | // the only one): the raw block is over once it is rendered. | |||
| 296 | + | for s := securityReceive(t, rendered); !s.Security.Protected; s = securityReceive(t, rendered) { | |||
| 297 | + | } | |||
| 298 | + | cancel() | |||
| 299 | + | if err := securityReceive(t, done); err != nil { | |||
| 300 | + | t.Fatal(err) | |||
| 301 | + | } | |||
| 302 | + | if _, err := os.Stat(filepath.Join(dir, "latest.png")); (err == nil) != tc.wantLatest { | |||
| 303 | + | t.Errorf("latest.png: %v, want present %v", err, tc.wantLatest) | |||
| 304 | + | } | |||
| 305 | + | if _, err := os.Stat(filepath.Join(dir, "latest-pq.png")); !os.IsNotExist(err) { | |||
| 306 | + | t.Errorf("latest-pq.png delivered: %v", err) | |||
| 307 | + | } | |||
| 308 | + | }) | |||
| 309 | + | } | |||
| 310 | + | // The write gate itself refuses a stale or protected 16-bit image. | |||
| 311 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 312 | + | gate.EXPECT().Snapshot().Return(protected) | |||
| 313 | + | path := filepath.Join(t.TempDir(), "latest-pq.png") | |||
| 314 | + | if err := writePNGSecure(path, image.NewRGBA64(image.Rect(0, 0, 1, 1)), gate, ports.SecurityState{}); err != nil { | |||
| 315 | + | t.Fatal(err) | |||
| 316 | + | } | |||
| 317 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |||
| 318 | + | t.Fatal("stale raw screenshot delivered") | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 226 | func TestSecurityQueuedCaptureFailedOnceAndProofCancellation(t *testing.T) { | 322 | func TestSecurityQueuedCaptureFailedOnceAndProofCancellation(t *testing.T) { | ||
| 227 | var state atomic.Value | 323 | var state atomic.Value | ||
| 228 | state.Store(ports.SecurityState{}) | 324 | state.Store(ports.SecurityState{}) | ||
M internal/adapters/vulkan/capture.go +54 -0
| @@ -508,6 +508,60 @@ func (r *Renderer) debugCapture() (*captureFrame, error) { | |||
| 508 | 508 | return f, nil | |
| 509 | 509 | } | |
| 510 | 510 | ||
| 511 | + | // readHDRTarget copies the exported PQ target last rendered (XRGB2101010 | |
| 512 | + | // words, one per pixel) through a free capture slot and waits for the GPU. | |
| 513 | + | // Exported targets are transfer sources only when hdrReadback is set; it | |
| 514 | + | // errors when there is no HDR frame to read, every slot is leased or the | |
| 515 | + | // copy fails. read gets the words in the slot's mapped memory, valid only | |
| 516 | + | // during the call: copy what must outlive it. | |
| 517 | + | func (r *Renderer) readHDRTarget(read func(words []uint32)) error { | |
| 518 | + | switch { | |
| 519 | + | case r.device == 0 || r.dd == nil: | |
| 520 | + | return errors.New("renderer closed") | |
| 521 | + | case r.hdrNits == 0 || !r.hdrReadback: | |
| 522 | + | return errors.New("HDR readback not enabled") | |
| 523 | + | case r.lastHDRTarget < 0 || r.lastHDRTarget >= len(r.targets): | |
| 524 | + | return errors.New("no exported HDR target") | |
| 525 | + | case r.last != &r.hdrOwn: | |
| 526 | + | return errors.New("no HDR frame rendered") | |
| 527 | + | case r.submitted != r.frame: | |
| 528 | + | return errors.New("last frame not submitted") | |
| 529 | + | } | |
| 530 | + | if err := r.waitFrame(r.submitted); err != nil { | |
| 531 | + | return err | |
| 532 | + | } | |
| 533 | + | if err := r.createCaptureSlots(); err != nil { | |
| 534 | + | return err | |
| 535 | + | } | |
| 536 | + | slot, err := r.freeCapture() | |
| 537 | + | if err != nil { | |
| 538 | + | return err | |
| 539 | + | } | |
| 540 | + | if slot == nil { | |
| 541 | + | return ports.ErrCaptureBusy | |
| 542 | + | } | |
| 543 | + | target := r.targets[r.lastHDRTarget] | |
| 544 | + | err = r.oneShot(func(cmd vk.CommandBuffer) { | |
| 545 | + | d := r.dd | |
| 546 | + | // Acquire from the display, copy, release back. | |
| 547 | + | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: vk.ImageLayoutGeneral, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: target.image, SubresourceRange: colorRange} | |
| 548 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |
| 549 | + | region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}} | |
| 550 | + | d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, slot.buffer, 1, ®ion) | |
| 551 | + | hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: slot.buffer, Size: wholeSize} | |
| 552 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil) | |
| 553 | + | b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0 | |
| 554 | + | b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral | |
| 555 | + | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |
| 556 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |
| 557 | + | }) | |
| 558 | + | if err != nil { | |
| 559 | + | return err | |
| 560 | + | } | |
| 561 | + | read(unsafe.Slice((*uint32)(slot.mapped), r.width*r.height)) | |
| 562 | + | return nil | |
| 563 | + | } | |
| 564 | + | ||
| 511 | 565 | // recordCaptureCopy copies image t (in layout) into the slot's buffer. | |
| 512 | 566 | // Exported targets are borrowed from the display without a layout | |
| 513 | 567 | // change: the display may be reading them at the same time. | |
| @@ -508,6 +508,60 @@ func (r *Renderer) debugCapture() (*captureFrame, error) { | |||||
| 508 | return f, nil | 508 | return f, nil | ||
| 509 | } | 509 | } | ||
| 510 | 510 | ||||
| 511 | + | // readHDRTarget copies the exported PQ target last rendered (XRGB2101010 | |||
| 512 | + | // words, one per pixel) through a free capture slot and waits for the GPU. | |||
| 513 | + | // Exported targets are transfer sources only when hdrReadback is set; it | |||
| 514 | + | // errors when there is no HDR frame to read, every slot is leased or the | |||
| 515 | + | // copy fails. read gets the words in the slot's mapped memory, valid only | |||
| 516 | + | // during the call: copy what must outlive it. | |||
| 517 | + | func (r *Renderer) readHDRTarget(read func(words []uint32)) error { | |||
| 518 | + | switch { | |||
| 519 | + | case r.device == 0 || r.dd == nil: | |||
| 520 | + | return errors.New("renderer closed") | |||
| 521 | + | case r.hdrNits == 0 || !r.hdrReadback: | |||
| 522 | + | return errors.New("HDR readback not enabled") | |||
| 523 | + | case r.lastHDRTarget < 0 || r.lastHDRTarget >= len(r.targets): | |||
| 524 | + | return errors.New("no exported HDR target") | |||
| 525 | + | case r.last != &r.hdrOwn: | |||
| 526 | + | return errors.New("no HDR frame rendered") | |||
| 527 | + | case r.submitted != r.frame: | |||
| 528 | + | return errors.New("last frame not submitted") | |||
| 529 | + | } | |||
| 530 | + | if err := r.waitFrame(r.submitted); err != nil { | |||
| 531 | + | return err | |||
| 532 | + | } | |||
| 533 | + | if err := r.createCaptureSlots(); err != nil { | |||
| 534 | + | return err | |||
| 535 | + | } | |||
| 536 | + | slot, err := r.freeCapture() | |||
| 537 | + | if err != nil { | |||
| 538 | + | return err | |||
| 539 | + | } | |||
| 540 | + | if slot == nil { | |||
| 541 | + | return ports.ErrCaptureBusy | |||
| 542 | + | } | |||
| 543 | + | target := r.targets[r.lastHDRTarget] | |||
| 544 | + | err = r.oneShot(func(cmd vk.CommandBuffer) { | |||
| 545 | + | d := r.dd | |||
| 546 | + | // Acquire from the display, copy, release back. | |||
| 547 | + | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: vk.ImageLayoutGeneral, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: target.image, SubresourceRange: colorRange} | |||
| 548 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |||
| 549 | + | region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}} | |||
| 550 | + | d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, slot.buffer, 1, ®ion) | |||
| 551 | + | hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: slot.buffer, Size: wholeSize} | |||
| 552 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil) | |||
| 553 | + | b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0 | |||
| 554 | + | b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral | |||
| 555 | + | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |||
| 556 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |||
| 557 | + | }) | |||
| 558 | + | if err != nil { | |||
| 559 | + | return err | |||
| 560 | + | } | |||
| 561 | + | read(unsafe.Slice((*uint32)(slot.mapped), r.width*r.height)) | |||
| 562 | + | return nil | |||
| 563 | + | } | |||
| 564 | + | ||||
| 511 | // recordCaptureCopy copies image t (in layout) into the slot's buffer. | 565 | // recordCaptureCopy copies image t (in layout) into the slot's buffer. | ||
| 512 | // Exported targets are borrowed from the display without a layout | 566 | // Exported targets are borrowed from the display without a layout | ||
| 513 | // change: the display may be reading them at the same time. | 567 | // change: the display may be reading them at the same time. | ||
M internal/adapters/vulkan/frame.go +3 -0
| @@ -310,6 +310,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 310 | 310 | tg.hold(s, dmg) | |
| 311 | 311 | r.damageDrawn = oldWindows | |
| 312 | 312 | r.last = tg | |
| 313 | + | if hdr { | |
| 314 | + | r.lastHDRTarget = r.current | |
| 315 | + | } | |
| 313 | 316 | r.dropUnused() | |
| 314 | 317 | r.dropShm() | |
| 315 | 318 | if !r.syncFD { | |
| @@ -310,6 +310,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 310 | tg.hold(s, dmg) | 310 | tg.hold(s, dmg) | ||
| 311 | r.damageDrawn = oldWindows | 311 | r.damageDrawn = oldWindows | ||
| 312 | r.last = tg | 312 | r.last = tg | ||
| 313 | + | if hdr { | |||
| 314 | + | r.lastHDRTarget = r.current | |||
| 315 | + | } | |||
| 313 | r.dropUnused() | 316 | r.dropUnused() | ||
| 314 | r.dropShm() | 317 | r.dropShm() | ||
| 315 | if !r.syncFD { | 318 | if !r.syncFD { | ||
M internal/adapters/vulkan/hdr_composition_test.go +119 -32
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "math" | |
| 7 | 7 | "slices" | |
| 8 | 8 | "testing" | |
| 9 | - | "unsafe" | |
| 10 | 9 | ||
| 11 | 10 | "github.com/bnema/neferwl/internal/ports" | |
| 12 | 11 | vk "github.com/bnema/purego-vulkan/vulkan" | |
| @@ -20,7 +19,7 @@ func hdrTestRenderer(t *testing.T) *Renderer { | |||
| 20 | 19 | } | |
| 21 | 20 | t.Cleanup(r.Close) | |
| 22 | 21 | r.SetHDR(203) | |
| 23 | - | r.hdrReadback = true | |
| 22 | + | r.SetHDRReadback(true) | |
| 24 | 23 | if r.physical == 0 { | |
| 25 | 24 | t.Skip("no exportable GPU") | |
| 26 | 25 | } | |
| @@ -40,34 +39,11 @@ func hdrTestRenderer(t *testing.T) *Renderer { | |||
| 40 | 39 | // not transfer-src), staging through a capture slot's buffer. | |
| 41 | 40 | func pqTargetWords(t *testing.T, r *Renderer) []uint32 { | |
| 42 | 41 | t.Helper() | |
| 43 | - | if err := r.waitFrame(r.submitted); err != nil { | |
| 42 | + | var words []uint32 | |
| 43 | + | if err := r.readHDRTarget(func(w []uint32) { words = slices.Clone(w) }); err != nil { | |
| 44 | 44 | t.Fatal(err) | |
| 45 | 45 | } | |
| 46 | - | if err := r.createCaptureSlots(); err != nil { | |
| 47 | - | t.Fatal(err) | |
| 48 | - | } | |
| 49 | - | slot := r.captures[0] | |
| 50 | - | if slot.leased { | |
| 51 | - | t.Fatal("capture slot 0 leased") | |
| 52 | - | } | |
| 53 | - | target := r.targets[0] | |
| 54 | - | err := r.oneShot(func(cmd vk.CommandBuffer) { | |
| 55 | - | d := r.dd | |
| 56 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: vk.ImageLayoutGeneral, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: target.image, SubresourceRange: colorRange} | |
| 57 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |
| 58 | - | region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}} | |
| 59 | - | d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, slot.buffer, 1, ®ion) | |
| 60 | - | hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: slot.buffer, Size: wholeSize} | |
| 61 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil) | |
| 62 | - | b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0 | |
| 63 | - | b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral | |
| 64 | - | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |
| 65 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |
| 66 | - | }) | |
| 67 | - | if err != nil { | |
| 68 | - | t.Fatal(err) | |
| 69 | - | } | |
| 70 | - | return unsafe.Slice((*uint32)(slot.mapped), r.width*r.height) | |
| 46 | + | return words | |
| 71 | 47 | } | |
| 72 | 48 | ||
| 73 | 49 | func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 { | |
| @@ -76,13 +52,14 @@ func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 { | |||
| 76 | 52 | return [3]float64{float64(pixel>>20&1023) / 1023, float64(pixel>>10&1023) / 1023, float64(pixel&1023) / 1023} | |
| 77 | 53 | } | |
| 78 | 54 | ||
| 79 | - | func TestHDRWindowedComposition(t *testing.T) { | |
| 80 | - | r := hdrTestRenderer(t) | |
| 55 | + | // hdrPQScene is a 64×16 XR30 udmabuf client holding 1000-nit neutral, red | |
| 56 | + | // and blue BT.2020 thirds (x/20), drawn full-output. | |
| 57 | + | func hdrPQScene(t *testing.T, r *Renderer) (ports.Scene, map[ports.WindowID]ports.SurfaceContent, [3][3]float64) { | |
| 58 | + | t.Helper() | |
| 81 | 59 | format := ports.DMABufFormat{Format: fourcc('X', 'R', '3', '0'), Modifier: 0} | |
| 82 | 60 | if !slices.Contains(r.DMABuf().Formats, format) { | |
| 83 | 61 | t.Skip("linear XR30 import unavailable") | |
| 84 | 62 | } | |
| 85 | - | // 1000-nit neutral, red and blue BT.2020 client pixels. | |
| 86 | 63 | nits := [3][3]float64{{1000, 1000, 1000}, {1000, 0, 0}, {0, 0, 1000}} | |
| 87 | 64 | f := udmabuf(t, 64, 16, func(x, _ int) [4]byte { | |
| 88 | 65 | v := nits[min(x/20, 2)] | |
| @@ -93,7 +70,117 @@ func TestHDRWindowedComposition(t *testing.T) { | |||
| 93 | 70 | buf := &ports.DMABuf{ID: 2020, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}} | |
| 94 | 71 | c := ports.SurfaceContent{ID: 1, Seq: 1, Width: 64, Height: 16, LogicalW: 64, LogicalH: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}} | |
| 95 | 72 | scene := ports.Scene{Seq: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}} | |
| 96 | - | contents := map[ports.WindowID]ports.SurfaceContent{1: c} | |
| 73 | + | return scene, map[ports.WindowID]ports.SurfaceContent{1: c}, nits | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // HDRPixels returns the exported PQ target's codes scaled to 16 bits, and | |
| 77 | + | // nothing on an SDR renderer. | |
| 78 | + | func TestHDRPixels(t *testing.T) { | |
| 79 | + | r := hdrTestRenderer(t) | |
| 80 | + | if r.HDRPixels() != nil { | |
| 81 | + | t.Fatal("HDRPixels before any frame") | |
| 82 | + | } | |
| 83 | + | scene, contents, nits := hdrPQScene(t, r) | |
| 84 | + | if err := render(r, scene, contents); err != nil { | |
| 85 | + | t.Fatal(err) | |
| 86 | + | } | |
| 87 | + | img := r.HDRPixels() | |
| 88 | + | if img == nil { | |
| 89 | + | t.Fatal("HDRPixels nil after an HDR frame") | |
| 90 | + | } | |
| 91 | + | if img.Bounds() != image.Rect(0, 0, 64, 16) { | |
| 92 | + | t.Fatalf("bounds %v", img.Bounds()) | |
| 93 | + | } | |
| 94 | + | for i, v := range nits { | |
| 95 | + | x := i*20 + 5 | |
| 96 | + | got := img.RGBA64At(x, 8) | |
| 97 | + | raw := hdrTargetAt(t, r, x, 8) // code/1023, the same words | |
| 98 | + | for ch, c := range []uint16{got.R, got.G, got.B} { | |
| 99 | + | code := uint32(math.Round(raw[ch] * 1023)) | |
| 100 | + | if want := uint16(code<<6 | code>>4); c != want { | |
| 101 | + | t.Errorf("sample %d channel %d: %#04x want %#04x", i, ch, c, want) | |
| 102 | + | } | |
| 103 | + | if d := math.Abs(float64(c)/65535 - pqEncode(v[ch])); d > .012 { | |
| 104 | + | t.Errorf("sample %d channel %d: %.4f want %.4f", i, ch, float64(c)/65535, pqEncode(v[ch])) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | if got.A != 0xffff { | |
| 108 | + | t.Errorf("sample %d alpha %#04x", i, got.A) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | // An HDR virtual output without SetHDRReadback has no readable target. | |
| 112 | + | plain, err := New(64, 16) | |
| 113 | + | if err != nil { | |
| 114 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 115 | + | } | |
| 116 | + | defer plain.Close() | |
| 117 | + | plain.SetHDR(203) | |
| 118 | + | plain.SetVirtualOutput(true) | |
| 119 | + | bufs, err := plain.ExportTargets(1, nil) | |
| 120 | + | if err != nil { | |
| 121 | + | t.Skipf("no HDR target: %v", err) | |
| 122 | + | } | |
| 123 | + | defer bufs[0].Planes[0].File.Close() | |
| 124 | + | if err := render(plain, ports.Scene{Background: "#ffffff"}, nil); err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | if plain.HDRPixels() != nil { | |
| 128 | + | t.Fatal("HDRPixels without SetHDRReadback") | |
| 129 | + | } | |
| 130 | + | sdr, err := New(64, 4) | |
| 131 | + | if err != nil { | |
| 132 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 133 | + | } | |
| 134 | + | defer sdr.Close() | |
| 135 | + | if err := render(sdr, ports.Scene{Background: "#ffffff"}, nil); err != nil { | |
| 136 | + | t.Fatal(err) | |
| 137 | + | } | |
| 138 | + | if sdr.HDRPixels() != nil { | |
| 139 | + | t.Fatal("HDRPixels on an SDR renderer") | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // HDRPixels reads the target the last HDR frame drew into, not the one | |
| 144 | + | // selected for the next frame, and never a target exported after it. | |
| 145 | + | func TestHDRPixelsReadsRenderedTarget(t *testing.T) { | |
| 146 | + | r := hdrTestRenderer(t) | |
| 147 | + | scene, contents, nits := hdrPQScene(t, r) | |
| 148 | + | if err := render(r, scene, contents); err != nil { | |
| 149 | + | t.Fatal(err) | |
| 150 | + | } | |
| 151 | + | // New targets replace the one the frame drew into: nothing to read. | |
| 152 | + | bufs, err := r.ExportTargets(2, r.hdrMods) | |
| 153 | + | if err != nil { | |
| 154 | + | t.Skipf("no two HDR targets: %v", err) | |
| 155 | + | } | |
| 156 | + | for _, b := range bufs { | |
| 157 | + | defer b.Planes[0].File.Close() | |
| 158 | + | } | |
| 159 | + | if r.HDRPixels() != nil { | |
| 160 | + | t.Fatal("HDRPixels from targets no frame drew into") | |
| 161 | + | } | |
| 162 | + | r.UseTarget(0) | |
| 163 | + | if err := render(r, scene, contents); err != nil { | |
| 164 | + | t.Fatal(err) | |
| 165 | + | } | |
| 166 | + | r.UseTarget(1) | |
| 167 | + | img := r.HDRPixels() | |
| 168 | + | if img == nil { | |
| 169 | + | t.Fatal("HDRPixels nil after an HDR frame") | |
| 170 | + | } | |
| 171 | + | for i, v := range nits { | |
| 172 | + | got := img.RGBA64At(i*20+5, 8) | |
| 173 | + | for ch, c := range []uint16{got.R, got.G, got.B} { | |
| 174 | + | if d := math.Abs(float64(c)/65535 - pqEncode(v[ch])); d > .012 { | |
| 175 | + | t.Errorf("sample %d channel %d: %.4f want %.4f", i, ch, float64(c)/65535, pqEncode(v[ch])) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestHDRWindowedComposition(t *testing.T) { | |
| 182 | + | r := hdrTestRenderer(t) | |
| 183 | + | scene, contents, nits := hdrPQScene(t, r) | |
| 97 | 184 | if err := render(r, scene, contents); err != nil { | |
| 98 | 185 | t.Fatal(err) | |
| 99 | 186 | } | |
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "math" | 6 | "math" | ||
| 7 | "slices" | 7 | "slices" | ||
| 8 | "testing" | 8 | "testing" | ||
| 9 | - | "unsafe" | |||
| 10 | 9 | ||||
| 11 | "github.com/bnema/neferwl/internal/ports" | 10 | "github.com/bnema/neferwl/internal/ports" | ||
| 12 | vk "github.com/bnema/purego-vulkan/vulkan" | 11 | vk "github.com/bnema/purego-vulkan/vulkan" | ||
| @@ -20,7 +19,7 @@ func hdrTestRenderer(t *testing.T) *Renderer { | |||||
| 20 | } | 19 | } | ||
| 21 | t.Cleanup(r.Close) | 20 | t.Cleanup(r.Close) | ||
| 22 | r.SetHDR(203) | 21 | r.SetHDR(203) | ||
| 23 | - | r.hdrReadback = true | 22 | + | r.SetHDRReadback(true) |
| 24 | if r.physical == 0 { | 23 | if r.physical == 0 { | ||
| 25 | t.Skip("no exportable GPU") | 24 | t.Skip("no exportable GPU") | ||
| 26 | } | 25 | } | ||
| @@ -40,34 +39,11 @@ func hdrTestRenderer(t *testing.T) *Renderer { | |||||
| 40 | // not transfer-src), staging through a capture slot's buffer. | 39 | // not transfer-src), staging through a capture slot's buffer. | ||
| 41 | func pqTargetWords(t *testing.T, r *Renderer) []uint32 { | 40 | func pqTargetWords(t *testing.T, r *Renderer) []uint32 { | ||
| 42 | t.Helper() | 41 | t.Helper() | ||
| 43 | - | if err := r.waitFrame(r.submitted); err != nil { | 42 | + | var words []uint32 |
| 43 | + | if err := r.readHDRTarget(func(w []uint32) { words = slices.Clone(w) }); err != nil { | |||
| 44 | t.Fatal(err) | 44 | t.Fatal(err) | ||
| 45 | } | 45 | } | ||
| 46 | - | if err := r.createCaptureSlots(); err != nil { | 46 | + | return words |
| 47 | - | t.Fatal(err) | |||
| 48 | - | } | |||
| 49 | - | slot := r.captures[0] | |||
| 50 | - | if slot.leased { | |||
| 51 | - | t.Fatal("capture slot 0 leased") | |||
| 52 | - | } | |||
| 53 | - | target := r.targets[0] | |||
| 54 | - | err := r.oneShot(func(cmd vk.CommandBuffer) { | |||
| 55 | - | d := r.dd | |||
| 56 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: vk.ImageLayoutGeneral, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: target.image, SubresourceRange: colorRange} | |||
| 57 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |||
| 58 | - | region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}} | |||
| 59 | - | d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, slot.buffer, 1, ®ion) | |||
| 60 | - | hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: slot.buffer, Size: wholeSize} | |||
| 61 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil) | |||
| 62 | - | b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0 | |||
| 63 | - | b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral | |||
| 64 | - | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |||
| 65 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |||
| 66 | - | }) | |||
| 67 | - | if err != nil { | |||
| 68 | - | t.Fatal(err) | |||
| 69 | - | } | |||
| 70 | - | return unsafe.Slice((*uint32)(slot.mapped), r.width*r.height) | |||
| 71 | } | 47 | } | ||
| 72 | 48 | ||||
| 73 | func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 { | 49 | func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 { | ||
| @@ -76,13 +52,14 @@ func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 { | |||||
| 76 | return [3]float64{float64(pixel>>20&1023) / 1023, float64(pixel>>10&1023) / 1023, float64(pixel&1023) / 1023} | 52 | return [3]float64{float64(pixel>>20&1023) / 1023, float64(pixel>>10&1023) / 1023, float64(pixel&1023) / 1023} | ||
| 77 | } | 53 | } | ||
| 78 | 54 | ||||
| 79 | - | func TestHDRWindowedComposition(t *testing.T) { | 55 | + | // hdrPQScene is a 64×16 XR30 udmabuf client holding 1000-nit neutral, red |
| 80 | - | r := hdrTestRenderer(t) | 56 | + | // and blue BT.2020 thirds (x/20), drawn full-output. |
| 57 | + | func hdrPQScene(t *testing.T, r *Renderer) (ports.Scene, map[ports.WindowID]ports.SurfaceContent, [3][3]float64) { | |||
| 58 | + | t.Helper() | |||
| 81 | format := ports.DMABufFormat{Format: fourcc('X', 'R', '3', '0'), Modifier: 0} | 59 | format := ports.DMABufFormat{Format: fourcc('X', 'R', '3', '0'), Modifier: 0} | ||
| 82 | if !slices.Contains(r.DMABuf().Formats, format) { | 60 | if !slices.Contains(r.DMABuf().Formats, format) { | ||
| 83 | t.Skip("linear XR30 import unavailable") | 61 | t.Skip("linear XR30 import unavailable") | ||
| 84 | } | 62 | } | ||
| 85 | - | // 1000-nit neutral, red and blue BT.2020 client pixels. | |||
| 86 | nits := [3][3]float64{{1000, 1000, 1000}, {1000, 0, 0}, {0, 0, 1000}} | 63 | nits := [3][3]float64{{1000, 1000, 1000}, {1000, 0, 0}, {0, 0, 1000}} | ||
| 87 | f := udmabuf(t, 64, 16, func(x, _ int) [4]byte { | 64 | f := udmabuf(t, 64, 16, func(x, _ int) [4]byte { | ||
| 88 | v := nits[min(x/20, 2)] | 65 | v := nits[min(x/20, 2)] | ||
| @@ -93,7 +70,117 @@ func TestHDRWindowedComposition(t *testing.T) { | |||||
| 93 | buf := &ports.DMABuf{ID: 2020, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}} | 70 | buf := &ports.DMABuf{ID: 2020, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}} | ||
| 94 | c := ports.SurfaceContent{ID: 1, Seq: 1, Width: 64, Height: 16, LogicalW: 64, LogicalH: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}} | 71 | c := ports.SurfaceContent{ID: 1, Seq: 1, Width: 64, Height: 16, LogicalW: 64, LogicalH: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}} | ||
| 95 | scene := ports.Scene{Seq: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}} | 72 | scene := ports.Scene{Seq: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}} | ||
| 96 | - | contents := map[ports.WindowID]ports.SurfaceContent{1: c} | 73 | + | return scene, map[ports.WindowID]ports.SurfaceContent{1: c}, nits |
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // HDRPixels returns the exported PQ target's codes scaled to 16 bits, and | |||
| 77 | + | // nothing on an SDR renderer. | |||
| 78 | + | func TestHDRPixels(t *testing.T) { | |||
| 79 | + | r := hdrTestRenderer(t) | |||
| 80 | + | if r.HDRPixels() != nil { | |||
| 81 | + | t.Fatal("HDRPixels before any frame") | |||
| 82 | + | } | |||
| 83 | + | scene, contents, nits := hdrPQScene(t, r) | |||
| 84 | + | if err := render(r, scene, contents); err != nil { | |||
| 85 | + | t.Fatal(err) | |||
| 86 | + | } | |||
| 87 | + | img := r.HDRPixels() | |||
| 88 | + | if img == nil { | |||
| 89 | + | t.Fatal("HDRPixels nil after an HDR frame") | |||
| 90 | + | } | |||
| 91 | + | if img.Bounds() != image.Rect(0, 0, 64, 16) { | |||
| 92 | + | t.Fatalf("bounds %v", img.Bounds()) | |||
| 93 | + | } | |||
| 94 | + | for i, v := range nits { | |||
| 95 | + | x := i*20 + 5 | |||
| 96 | + | got := img.RGBA64At(x, 8) | |||
| 97 | + | raw := hdrTargetAt(t, r, x, 8) // code/1023, the same words | |||
| 98 | + | for ch, c := range []uint16{got.R, got.G, got.B} { | |||
| 99 | + | code := uint32(math.Round(raw[ch] * 1023)) | |||
| 100 | + | if want := uint16(code<<6 | code>>4); c != want { | |||
| 101 | + | t.Errorf("sample %d channel %d: %#04x want %#04x", i, ch, c, want) | |||
| 102 | + | } | |||
| 103 | + | if d := math.Abs(float64(c)/65535 - pqEncode(v[ch])); d > .012 { | |||
| 104 | + | t.Errorf("sample %d channel %d: %.4f want %.4f", i, ch, float64(c)/65535, pqEncode(v[ch])) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | if got.A != 0xffff { | |||
| 108 | + | t.Errorf("sample %d alpha %#04x", i, got.A) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | // An HDR virtual output without SetHDRReadback has no readable target. | |||
| 112 | + | plain, err := New(64, 16) | |||
| 113 | + | if err != nil { | |||
| 114 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 115 | + | } | |||
| 116 | + | defer plain.Close() | |||
| 117 | + | plain.SetHDR(203) | |||
| 118 | + | plain.SetVirtualOutput(true) | |||
| 119 | + | bufs, err := plain.ExportTargets(1, nil) | |||
| 120 | + | if err != nil { | |||
| 121 | + | t.Skipf("no HDR target: %v", err) | |||
| 122 | + | } | |||
| 123 | + | defer bufs[0].Planes[0].File.Close() | |||
| 124 | + | if err := render(plain, ports.Scene{Background: "#ffffff"}, nil); err != nil { | |||
| 125 | + | t.Fatal(err) | |||
| 126 | + | } | |||
| 127 | + | if plain.HDRPixels() != nil { | |||
| 128 | + | t.Fatal("HDRPixels without SetHDRReadback") | |||
| 129 | + | } | |||
| 130 | + | sdr, err := New(64, 4) | |||
| 131 | + | if err != nil { | |||
| 132 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 133 | + | } | |||
| 134 | + | defer sdr.Close() | |||
| 135 | + | if err := render(sdr, ports.Scene{Background: "#ffffff"}, nil); err != nil { | |||
| 136 | + | t.Fatal(err) | |||
| 137 | + | } | |||
| 138 | + | if sdr.HDRPixels() != nil { | |||
| 139 | + | t.Fatal("HDRPixels on an SDR renderer") | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // HDRPixels reads the target the last HDR frame drew into, not the one | |||
| 144 | + | // selected for the next frame, and never a target exported after it. | |||
| 145 | + | func TestHDRPixelsReadsRenderedTarget(t *testing.T) { | |||
| 146 | + | r := hdrTestRenderer(t) | |||
| 147 | + | scene, contents, nits := hdrPQScene(t, r) | |||
| 148 | + | if err := render(r, scene, contents); err != nil { | |||
| 149 | + | t.Fatal(err) | |||
| 150 | + | } | |||
| 151 | + | // New targets replace the one the frame drew into: nothing to read. | |||
| 152 | + | bufs, err := r.ExportTargets(2, r.hdrMods) | |||
| 153 | + | if err != nil { | |||
| 154 | + | t.Skipf("no two HDR targets: %v", err) | |||
| 155 | + | } | |||
| 156 | + | for _, b := range bufs { | |||
| 157 | + | defer b.Planes[0].File.Close() | |||
| 158 | + | } | |||
| 159 | + | if r.HDRPixels() != nil { | |||
| 160 | + | t.Fatal("HDRPixels from targets no frame drew into") | |||
| 161 | + | } | |||
| 162 | + | r.UseTarget(0) | |||
| 163 | + | if err := render(r, scene, contents); err != nil { | |||
| 164 | + | t.Fatal(err) | |||
| 165 | + | } | |||
| 166 | + | r.UseTarget(1) | |||
| 167 | + | img := r.HDRPixels() | |||
| 168 | + | if img == nil { | |||
| 169 | + | t.Fatal("HDRPixels nil after an HDR frame") | |||
| 170 | + | } | |||
| 171 | + | for i, v := range nits { | |||
| 172 | + | got := img.RGBA64At(i*20+5, 8) | |||
| 173 | + | for ch, c := range []uint16{got.R, got.G, got.B} { | |||
| 174 | + | if d := math.Abs(float64(c)/65535 - pqEncode(v[ch])); d > .012 { | |||
| 175 | + | t.Errorf("sample %d channel %d: %.4f want %.4f", i, ch, float64(c)/65535, pqEncode(v[ch])) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestHDRWindowedComposition(t *testing.T) { | |||
| 182 | + | r := hdrTestRenderer(t) | |||
| 183 | + | scene, contents, nits := hdrPQScene(t, r) | |||
| 97 | if err := render(r, scene, contents); err != nil { | 184 | if err := render(r, scene, contents); err != nil { | ||
| 98 | t.Fatal(err) | 185 | t.Fatal(err) | ||
| 99 | } | 186 | } | ||
M internal/adapters/vulkan/renderer.go +39 -3
| @@ -35,11 +35,18 @@ type Renderer struct { | |||
| 35 | 35 | hdrNits float64 | |
| 36 | 36 | hdr hdrPass | |
| 37 | 37 | hdrError error | |
| 38 | - | // hdrReadback is used only by the GPU test to permit transfer from a 10-bit target. | |
| 38 | + | // hdrReadback makes exported HDR targets transfer sources, for GPU tests | |
| 39 | + | // and headless --screenshot-raw (SetHDRReadback); never DRM. | |
| 39 | 40 | hdrReadback bool | |
| 40 | - | physical vk.PhysicalDevice | |
| 41 | + | // virtual: the output has no display (headless), so ExportTargets | |
| 42 | + | // accepts an empty modifier list for HDR (SetVirtualOutput). | |
| 43 | + | virtual bool | |
| 44 | + | physical vk.PhysicalDevice | |
| 41 | 45 | // One converted cursor image is kept; changes replace it in bounded memory. | |
| 42 | 46 | cursorCache cursorConversion | |
| 47 | + | // lastHDRTarget is the index in targets of the exported PQ target the | |
| 48 | + | // last HDR frame rendered into; -1 when the current targets hold none. | |
| 49 | + | lastHDRTarget int | |
| 43 | 50 | // last is the target of the last frame: what captures copy. | |
| 44 | 51 | last *target | |
| 45 | 52 | // captures are the readback slots (capture.go), allocated on the | |
| @@ -111,7 +118,7 @@ func New(width, height int) (r *Renderer, err error) { | |||
| 111 | 118 | if width <= 0 || height <= 0 || width > math.MaxUint32 || height > math.MaxUint32 || uint64(width)*uint64(height) > uint64(math.MaxInt/4) { | |
| 112 | 119 | return nil, fmt.Errorf("invalid renderer dimensions %d x %d", width, height) | |
| 113 | 120 | } | |
| 114 | - | r = &Renderer{width: width, height: height, imports: map[uint64]*imported{}, pools: map[uint64]*mapping{}, shm: map[shmKey]*shmSurface{}} | |
| 121 | + | r = &Renderer{width: width, height: height, lastHDRTarget: -1, imports: map[uint64]*imported{}, pools: map[uint64]*mapping{}, shm: map[shmKey]*shmSurface{}} | |
| 115 | 122 | defer func() { | |
| 116 | 123 | if err != nil { | |
| 117 | 124 | r.Close() | |
| @@ -357,6 +364,35 @@ func (r *Renderer) Pixels() *image.RGBA { | |||
| 357 | 364 | return out | |
| 358 | 365 | } | |
| 359 | 366 | ||
| 367 | + | // SetHDRReadback makes the exported HDR targets transfer sources so that | |
| 368 | + | // HDRPixels can read them (they are not in production: the DRM path never | |
| 369 | + | // sets it). Call before ExportTargets. Not on the port. | |
| 370 | + | func (r *Renderer) SetHDRReadback(on bool) { r.hdrReadback = on } | |
| 371 | + | ||
| 372 | + | // HDRPixels reads the last HDR frame's XRGB2101010 target back, each 10-bit | |
| 373 | + | // code scaled to 16 bits (c<<6 | c>>4), alpha opaque. It waits for the GPU: | |
| 374 | + | // debug only. Nil when the output is SDR, the target is not readable (no | |
| 375 | + | // SetHDRReadback), no HDR frame was rendered, every slot is leased or the | |
| 376 | + | // copy failed. | |
| 377 | + | func (r *Renderer) HDRPixels() *image.RGBA64 { | |
| 378 | + | var out *image.RGBA64 | |
| 379 | + | err := r.readHDRTarget(func(words []uint32) { | |
| 380 | + | out = image.NewRGBA64(image.Rect(0, 0, r.width, r.height)) | |
| 381 | + | for i, w := range words { | |
| 382 | + | p := out.Pix[i*8 : i*8+8] | |
| 383 | + | for ch, c := range [3]uint32{w >> 20 & 1023, w >> 10 & 1023, w & 1023} { | |
| 384 | + | v := uint16(c<<6 | c>>4) | |
| 385 | + | p[ch*2], p[ch*2+1] = byte(v>>8), byte(v) | |
| 386 | + | } | |
| 387 | + | p[6], p[7] = 0xff, 0xff | |
| 388 | + | } | |
| 389 | + | }) | |
| 390 | + | if err != nil { | |
| 391 | + | return nil | |
| 392 | + | } | |
| 393 | + | return out | |
| 394 | + | } | |
| 395 | + | ||
| 360 | 396 | // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE. | |
| 361 | 397 | const ( | |
| 362 | 398 | queueFamilyIgnored = ^uint32(0) | |
| @@ -35,11 +35,18 @@ type Renderer struct { | |||||
| 35 | hdrNits float64 | 35 | hdrNits float64 | ||
| 36 | hdr hdrPass | 36 | hdr hdrPass | ||
| 37 | hdrError error | 37 | hdrError error | ||
| 38 | - | // hdrReadback is used only by the GPU test to permit transfer from a 10-bit target. | 38 | + | // hdrReadback makes exported HDR targets transfer sources, for GPU tests |
| 39 | + | // and headless --screenshot-raw (SetHDRReadback); never DRM. | |||
| 39 | hdrReadback bool | 40 | hdrReadback bool | ||
| 40 | - | physical vk.PhysicalDevice | 41 | + | // virtual: the output has no display (headless), so ExportTargets |
| 42 | + | // accepts an empty modifier list for HDR (SetVirtualOutput). | |||
| 43 | + | virtual bool | |||
| 44 | + | physical vk.PhysicalDevice | |||
| 41 | // One converted cursor image is kept; changes replace it in bounded memory. | 45 | // One converted cursor image is kept; changes replace it in bounded memory. | ||
| 42 | cursorCache cursorConversion | 46 | cursorCache cursorConversion | ||
| 47 | + | // lastHDRTarget is the index in targets of the exported PQ target the | |||
| 48 | + | // last HDR frame rendered into; -1 when the current targets hold none. | |||
| 49 | + | lastHDRTarget int | |||
| 43 | // last is the target of the last frame: what captures copy. | 50 | // last is the target of the last frame: what captures copy. | ||
| 44 | last *target | 51 | last *target | ||
| 45 | // captures are the readback slots (capture.go), allocated on the | 52 | // captures are the readback slots (capture.go), allocated on the | ||
| @@ -111,7 +118,7 @@ func New(width, height int) (r *Renderer, err error) { | |||||
| 111 | if width <= 0 || height <= 0 || width > math.MaxUint32 || height > math.MaxUint32 || uint64(width)*uint64(height) > uint64(math.MaxInt/4) { | 118 | if width <= 0 || height <= 0 || width > math.MaxUint32 || height > math.MaxUint32 || uint64(width)*uint64(height) > uint64(math.MaxInt/4) { | ||
| 112 | return nil, fmt.Errorf("invalid renderer dimensions %d x %d", width, height) | 119 | return nil, fmt.Errorf("invalid renderer dimensions %d x %d", width, height) | ||
| 113 | } | 120 | } | ||
| 114 | - | r = &Renderer{width: width, height: height, imports: map[uint64]*imported{}, pools: map[uint64]*mapping{}, shm: map[shmKey]*shmSurface{}} | 121 | + | r = &Renderer{width: width, height: height, lastHDRTarget: -1, imports: map[uint64]*imported{}, pools: map[uint64]*mapping{}, shm: map[shmKey]*shmSurface{}} |
| 115 | defer func() { | 122 | defer func() { | ||
| 116 | if err != nil { | 123 | if err != nil { | ||
| 117 | r.Close() | 124 | r.Close() | ||
| @@ -357,6 +364,35 @@ func (r *Renderer) Pixels() *image.RGBA { | |||||
| 357 | return out | 364 | return out | ||
| 358 | } | 365 | } | ||
| 359 | 366 | ||||
| 367 | + | // SetHDRReadback makes the exported HDR targets transfer sources so that | |||
| 368 | + | // HDRPixels can read them (they are not in production: the DRM path never | |||
| 369 | + | // sets it). Call before ExportTargets. Not on the port. | |||
| 370 | + | func (r *Renderer) SetHDRReadback(on bool) { r.hdrReadback = on } | |||
| 371 | + | ||||
| 372 | + | // HDRPixels reads the last HDR frame's XRGB2101010 target back, each 10-bit | |||
| 373 | + | // code scaled to 16 bits (c<<6 | c>>4), alpha opaque. It waits for the GPU: | |||
| 374 | + | // debug only. Nil when the output is SDR, the target is not readable (no | |||
| 375 | + | // SetHDRReadback), no HDR frame was rendered, every slot is leased or the | |||
| 376 | + | // copy failed. | |||
| 377 | + | func (r *Renderer) HDRPixels() *image.RGBA64 { | |||
| 378 | + | var out *image.RGBA64 | |||
| 379 | + | err := r.readHDRTarget(func(words []uint32) { | |||
| 380 | + | out = image.NewRGBA64(image.Rect(0, 0, r.width, r.height)) | |||
| 381 | + | for i, w := range words { | |||
| 382 | + | p := out.Pix[i*8 : i*8+8] | |||
| 383 | + | for ch, c := range [3]uint32{w >> 20 & 1023, w >> 10 & 1023, w & 1023} { | |||
| 384 | + | v := uint16(c<<6 | c>>4) | |||
| 385 | + | p[ch*2], p[ch*2+1] = byte(v>>8), byte(v) | |||
| 386 | + | } | |||
| 387 | + | p[6], p[7] = 0xff, 0xff | |||
| 388 | + | } | |||
| 389 | + | }) | |||
| 390 | + | if err != nil { | |||
| 391 | + | return nil | |||
| 392 | + | } | |||
| 393 | + | return out | |||
| 394 | + | } | |||
| 395 | + | ||||
| 360 | // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE. | 396 | // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE. | ||
| 361 | const ( | 397 | const ( | ||
| 362 | queueFamilyIgnored = ^uint32(0) | 398 | queueFamilyIgnored = ^uint32(0) | ||
M internal/adapters/vulkan/targets.go +17 -5
| @@ -47,7 +47,8 @@ const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24 | |||
| 47 | 47 | func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) { | |
| 48 | 48 | r.dropTargets() | |
| 49 | 49 | if n > 0 && r.hdrNits > 0 && r.hdrReadback && r.physical != 0 { | |
| 50 | - | // Test-only transfer-source targets have different format requirements. | |
| 50 | + | // Readback targets (GPU tests, headless --screenshot-raw) are also | |
| 51 | + | // transfer sources, which changes the format requirements. | |
| 51 | 52 | r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32) | |
| 52 | 53 | } | |
| 53 | 54 | if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 { | |
| @@ -85,6 +86,11 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err | |||
| 85 | 86 | return out, nil | |
| 86 | 87 | } | |
| 87 | 88 | ||
| 89 | + | // SetVirtualOutput marks the output as having no display (headless): an | |
| 90 | + | // empty display modifier list then accepts every exportable HDR modifier, | |
| 91 | + | // as it does for SDR. DRM never sets it. Call before ExportTargets. | |
| 92 | + | func (r *Renderer) SetVirtualOutput(on bool) { r.virtual = on } | |
| 93 | + | ||
| 88 | 94 | // UseTarget selects the exported image the next frames draw into. | |
| 89 | 95 | func (r *Renderer) UseTarget(i int) { | |
| 90 | 96 | if i >= 0 && i < len(r.targets) { | |
| @@ -101,7 +107,8 @@ func (r *Renderer) target() *target { | |||
| 101 | 107 | } | |
| 102 | 108 | ||
| 103 | 109 | // exportModifiers intersects the selected signal format modifiers with | |
| 104 | - | // those supported by the display. HDR never assumes an unspecified list. | |
| 110 | + | // those supported by the display. HDR never assumes an unspecified list, | |
| 111 | + | // except for a virtual output (no display) or a test readback. | |
| 105 | 112 | func (r *Renderer) exportModifiers(display []uint64) []uint64 { | |
| 106 | 113 | var out []uint64 | |
| 107 | 114 | available := r.renderMods | |
| @@ -109,7 +116,7 @@ func (r *Renderer) exportModifiers(display []uint64) []uint64 { | |||
| 109 | 116 | available = r.hdrMods | |
| 110 | 117 | } | |
| 111 | 118 | for _, m := range available { | |
| 112 | - | if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback)) || slices.Contains(display, m) { | |
| 119 | + | if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback || r.virtual)) || slices.Contains(display, m) { | |
| 113 | 120 | out = append(out, m) | |
| 114 | 121 | } | |
| 115 | 122 | } | |
| @@ -122,6 +129,9 @@ func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) { | |||
| 122 | 129 | r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32) | |
| 123 | 130 | } | |
| 124 | 131 | ||
| 132 | + | // probeModifiers lists the single-plane, exportable modifiers of format that | |
| 133 | + | // support the target usage. HDR targets need transfer-src only when | |
| 134 | + | // hdrReadback is set (GPU tests, headless --screenshot-raw; never DRM). | |
| 125 | 135 | func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 { | |
| 126 | 136 | list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT} | |
| 127 | 137 | fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)} | |
| @@ -139,7 +149,8 @@ func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) | |||
| 139 | 149 | need |= formatFeatureTransferSrc | |
| 140 | 150 | } | |
| 141 | 151 | var result []uint64 | |
| 142 | - | // SDR targets are composed and read back; HDR targets are color attachments. | |
| 152 | + | // SDR targets are composed and read back; HDR targets are color | |
| 153 | + | // attachments, and transfer sources only with hdrReadback. | |
| 143 | 154 | for _, m := range mods[:list.DrmFormatModifierCount] { | |
| 144 | 155 | if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) { | |
| 145 | 156 | result = append(result, m.DrmFormatModifier) | |
| @@ -262,5 +273,6 @@ func (r *Renderer) dropTargets() { | |||
| 262 | 273 | } | |
| 263 | 274 | r.freeTarget(t) | |
| 264 | 275 | } | |
| 265 | - | r.targets, r.current = nil, 0 | |
| 276 | + | // New targets hold no HDR frame until the next Render. | |
| 277 | + | r.targets, r.current, r.lastHDRTarget = nil, 0, -1 | |
| 266 | 278 | } | |
| @@ -47,7 +47,8 @@ const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24 | |||||
| 47 | func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) { | 47 | func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) { | ||
| 48 | r.dropTargets() | 48 | r.dropTargets() | ||
| 49 | if n > 0 && r.hdrNits > 0 && r.hdrReadback && r.physical != 0 { | 49 | if n > 0 && r.hdrNits > 0 && r.hdrReadback && r.physical != 0 { | ||
| 50 | - | // Test-only transfer-source targets have different format requirements. | 50 | + | // Readback targets (GPU tests, headless --screenshot-raw) are also |
| 51 | + | // transfer sources, which changes the format requirements. | |||
| 51 | r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32) | 52 | r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32) | ||
| 52 | } | 53 | } | ||
| 53 | if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 { | 54 | if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 { | ||
| @@ -85,6 +86,11 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err | |||||
| 85 | return out, nil | 86 | return out, nil | ||
| 86 | } | 87 | } | ||
| 87 | 88 | ||||
| 89 | + | // SetVirtualOutput marks the output as having no display (headless): an | |||
| 90 | + | // empty display modifier list then accepts every exportable HDR modifier, | |||
| 91 | + | // as it does for SDR. DRM never sets it. Call before ExportTargets. | |||
| 92 | + | func (r *Renderer) SetVirtualOutput(on bool) { r.virtual = on } | |||
| 93 | + | ||||
| 88 | // UseTarget selects the exported image the next frames draw into. | 94 | // UseTarget selects the exported image the next frames draw into. | ||
| 89 | func (r *Renderer) UseTarget(i int) { | 95 | func (r *Renderer) UseTarget(i int) { | ||
| 90 | if i >= 0 && i < len(r.targets) { | 96 | if i >= 0 && i < len(r.targets) { | ||
| @@ -101,7 +107,8 @@ func (r *Renderer) target() *target { | |||||
| 101 | } | 107 | } | ||
| 102 | 108 | ||||
| 103 | // exportModifiers intersects the selected signal format modifiers with | 109 | // exportModifiers intersects the selected signal format modifiers with | ||
| 104 | - | // those supported by the display. HDR never assumes an unspecified list. | 110 | + | // those supported by the display. HDR never assumes an unspecified list, |
| 111 | + | // except for a virtual output (no display) or a test readback. | |||
| 105 | func (r *Renderer) exportModifiers(display []uint64) []uint64 { | 112 | func (r *Renderer) exportModifiers(display []uint64) []uint64 { | ||
| 106 | var out []uint64 | 113 | var out []uint64 | ||
| 107 | available := r.renderMods | 114 | available := r.renderMods | ||
| @@ -109,7 +116,7 @@ func (r *Renderer) exportModifiers(display []uint64) []uint64 { | |||||
| 109 | available = r.hdrMods | 116 | available = r.hdrMods | ||
| 110 | } | 117 | } | ||
| 111 | for _, m := range available { | 118 | for _, m := range available { | ||
| 112 | - | if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback)) || slices.Contains(display, m) { | 119 | + | if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback || r.virtual)) || slices.Contains(display, m) { |
| 113 | out = append(out, m) | 120 | out = append(out, m) | ||
| 114 | } | 121 | } | ||
| 115 | } | 122 | } | ||
| @@ -122,6 +129,9 @@ func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) { | |||||
| 122 | r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32) | 129 | r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32) | ||
| 123 | } | 130 | } | ||
| 124 | 131 | ||||
| 132 | + | // probeModifiers lists the single-plane, exportable modifiers of format that | |||
| 133 | + | // support the target usage. HDR targets need transfer-src only when | |||
| 134 | + | // hdrReadback is set (GPU tests, headless --screenshot-raw; never DRM). | |||
| 125 | func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 { | 135 | func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 { | ||
| 126 | list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT} | 136 | list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT} | ||
| 127 | fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)} | 137 | fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)} | ||
| @@ -139,7 +149,8 @@ func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) | |||||
| 139 | need |= formatFeatureTransferSrc | 149 | need |= formatFeatureTransferSrc | ||
| 140 | } | 150 | } | ||
| 141 | var result []uint64 | 151 | var result []uint64 | ||
| 142 | - | // SDR targets are composed and read back; HDR targets are color attachments. | 152 | + | // SDR targets are composed and read back; HDR targets are color |
| 153 | + | // attachments, and transfer sources only with hdrReadback. | |||
| 143 | for _, m := range mods[:list.DrmFormatModifierCount] { | 154 | for _, m := range mods[:list.DrmFormatModifierCount] { | ||
| 144 | if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) { | 155 | if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) { | ||
| 145 | result = append(result, m.DrmFormatModifier) | 156 | result = append(result, m.DrmFormatModifier) | ||
| @@ -262,5 +273,6 @@ func (r *Renderer) dropTargets() { | |||||
| 262 | } | 273 | } | ||
| 263 | r.freeTarget(t) | 274 | r.freeTarget(t) | ||
| 264 | } | 275 | } | ||
| 265 | - | r.targets, r.current = nil, 0 | 276 | + | // New targets hold no HDR frame until the next Render. |
| 277 | + | r.targets, r.current, r.lastHDRTarget = nil, 0, -1 | |||
| 266 | } | 278 | } | ||
M internal/adapters/vulkan/targets_test.go +30 -2
| @@ -74,8 +74,8 @@ func TestHDRExportTarget(t *testing.T) { | |||
| 74 | 74 | } | |
| 75 | 75 | defer r.Close() | |
| 76 | 76 | r.SetHDR(203) | |
| 77 | - | // Only this GPU test requests transfer-src on an HDR target. | |
| 78 | - | r.hdrReadback = true | |
| 77 | + | // GPU tests and headless --screenshot-raw (SetHDRReadback); never DRM. | |
| 78 | + | r.SetHDRReadback(true) | |
| 79 | 79 | if r.physical == 0 { | |
| 80 | 80 | t.Skip("no exportable GPU") | |
| 81 | 81 | } | |
| @@ -127,6 +127,34 @@ func TestHDRExportTarget(t *testing.T) { | |||
| 127 | 127 | } | |
| 128 | 128 | } | |
| 129 | 129 | ||
| 130 | + | // A virtual (headless) HDR output has no display to list modifiers: the | |
| 131 | + | // export accepts every exportable one. A DRM output (no SetVirtualOutput) | |
| 132 | + | // still refuses an unspecified list. | |
| 133 | + | func TestVirtualHDRExportsWithoutDisplayList(t *testing.T) { | |
| 134 | + | r, err := New(64, 16) | |
| 135 | + | if err != nil { | |
| 136 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 137 | + | } | |
| 138 | + | defer r.Close() | |
| 139 | + | if r.physical == 0 || len(r.hdrMods) == 0 { | |
| 140 | + | t.Skip("no exportable HDR GPU") | |
| 141 | + | } | |
| 142 | + | r.SetHDR(203) | |
| 143 | + | if bufs, err := r.ExportTargets(1, nil); err == nil { | |
| 144 | + | bufs[0].Planes[0].File.Close() | |
| 145 | + | t.Fatal("HDR export without a display list succeeded on a non-virtual output") | |
| 146 | + | } | |
| 147 | + | r.SetVirtualOutput(true) | |
| 148 | + | bufs, err := r.ExportTargets(1, nil) | |
| 149 | + | if err != nil { | |
| 150 | + | t.Fatalf("virtual HDR export: %v", err) | |
| 151 | + | } | |
| 152 | + | defer bufs[0].Planes[0].File.Close() | |
| 153 | + | if bufs[0].Format != fourccXR30 { | |
| 154 | + | t.Fatalf("format: %#x", bufs[0].Format) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 130 | 158 | // Test-only target readback: production HDR images do not have transfer-src. | |
| 131 | 159 | func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) { | |
| 132 | 160 | t.Helper() | |
| @@ -74,8 +74,8 @@ func TestHDRExportTarget(t *testing.T) { | |||||
| 74 | } | 74 | } | ||
| 75 | defer r.Close() | 75 | defer r.Close() | ||
| 76 | r.SetHDR(203) | 76 | r.SetHDR(203) | ||
| 77 | - | // Only this GPU test requests transfer-src on an HDR target. | 77 | + | // GPU tests and headless --screenshot-raw (SetHDRReadback); never DRM. |
| 78 | - | r.hdrReadback = true | 78 | + | r.SetHDRReadback(true) |
| 79 | if r.physical == 0 { | 79 | if r.physical == 0 { | ||
| 80 | t.Skip("no exportable GPU") | 80 | t.Skip("no exportable GPU") | ||
| 81 | } | 81 | } | ||
| @@ -127,6 +127,34 @@ func TestHDRExportTarget(t *testing.T) { | |||||
| 127 | } | 127 | } | ||
| 128 | } | 128 | } | ||
| 129 | 129 | ||||
| 130 | + | // A virtual (headless) HDR output has no display to list modifiers: the | |||
| 131 | + | // export accepts every exportable one. A DRM output (no SetVirtualOutput) | |||
| 132 | + | // still refuses an unspecified list. | |||
| 133 | + | func TestVirtualHDRExportsWithoutDisplayList(t *testing.T) { | |||
| 134 | + | r, err := New(64, 16) | |||
| 135 | + | if err != nil { | |||
| 136 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 137 | + | } | |||
| 138 | + | defer r.Close() | |||
| 139 | + | if r.physical == 0 || len(r.hdrMods) == 0 { | |||
| 140 | + | t.Skip("no exportable HDR GPU") | |||
| 141 | + | } | |||
| 142 | + | r.SetHDR(203) | |||
| 143 | + | if bufs, err := r.ExportTargets(1, nil); err == nil { | |||
| 144 | + | bufs[0].Planes[0].File.Close() | |||
| 145 | + | t.Fatal("HDR export without a display list succeeded on a non-virtual output") | |||
| 146 | + | } | |||
| 147 | + | r.SetVirtualOutput(true) | |||
| 148 | + | bufs, err := r.ExportTargets(1, nil) | |||
| 149 | + | if err != nil { | |||
| 150 | + | t.Fatalf("virtual HDR export: %v", err) | |||
| 151 | + | } | |||
| 152 | + | defer bufs[0].Planes[0].File.Close() | |||
| 153 | + | if bufs[0].Format != fourccXR30 { | |||
| 154 | + | t.Fatalf("format: %#x", bufs[0].Format) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 130 | // Test-only target readback: production HDR images do not have transfer-src. | 158 | // Test-only target readback: production HDR images do not have transfer-src. | ||
| 131 | func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) { | 159 | func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) { | ||
| 132 | t.Helper() | 160 | t.Helper() | ||
M internal/app/app.go +4 -1
| @@ -49,6 +49,9 @@ type Options struct { | |||
| 49 | 49 | // before any client starts, and withdraws them on exit. | |
| 50 | 50 | Session bool | |
| 51 | 51 | ScreenshotDir string | |
| 52 | + | // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a | |
| 53 | + | // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR). | |
| 54 | + | ScreenshotRaw bool | |
| 52 | 55 | // Sizes are the headless outputs (width, height), left to right; empty | |
| 53 | 56 | // means one 1920x1080 output. With several outputs, screenshots go to | |
| 54 | 57 | // one subdirectory per output (HEADLESS-1, HEADLESS-2, ...). | |
| @@ -306,7 +309,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 306 | 309 | }) | |
| 307 | 310 | return | |
| 308 | 311 | } | |
| 309 | - | done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, apply, outputIO, curs, newRenderer, logging.For(ctx, "render")) | |
| 312 | + | done <- runHeadless(ctx, headlessOptions{sizes: sizes, shots: opts.ScreenshotDir, hdr: opts.HeadlessHDR, raw: opts.ScreenshotRaw}, apply, outputIO, curs, newRenderer, logging.For(ctx, "render")) | |
| 310 | 313 | }() | |
| 311 | 314 | ||
| 312 | 315 | if hw != nil { | |
| @@ -49,6 +49,9 @@ type Options struct { | |||||
| 49 | // before any client starts, and withdraws them on exit. | 49 | // before any client starts, and withdraws them on exit. | ||
| 50 | Session bool | 50 | Session bool | ||
| 51 | ScreenshotDir string | 51 | ScreenshotDir string | ||
| 52 | + | // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a | |||
| 53 | + | // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR). | |||
| 54 | + | ScreenshotRaw bool | |||
| 52 | // Sizes are the headless outputs (width, height), left to right; empty | 55 | // Sizes are the headless outputs (width, height), left to right; empty | ||
| 53 | // means one 1920x1080 output. With several outputs, screenshots go to | 56 | // means one 1920x1080 output. With several outputs, screenshots go to | ||
| 54 | // one subdirectory per output (HEADLESS-1, HEADLESS-2, ...). | 57 | // one subdirectory per output (HEADLESS-1, HEADLESS-2, ...). | ||
| @@ -306,7 +309,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 306 | }) | 309 | }) | ||
| 307 | return | 310 | return | ||
| 308 | } | 311 | } | ||
| 309 | - | done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, apply, outputIO, curs, newRenderer, logging.For(ctx, "render")) | 312 | + | done <- runHeadless(ctx, headlessOptions{sizes: sizes, shots: opts.ScreenshotDir, hdr: opts.HeadlessHDR, raw: opts.ScreenshotRaw}, apply, outputIO, curs, newRenderer, logging.For(ctx, "render")) |
| 310 | }() | 313 | }() | ||
| 311 | 314 | ||||
| 312 | if hw != nil { | 315 | if hw != nil { | ||
A internal/app/colorcheck_test.go +365 -0
| @@ -0,0 +1,365 @@ | |||
| 1 | + | //go:build colorcheck | |
| 2 | + | ||
| 3 | + | // On-demand colour check: a real Wayland client (examples/testpattern) shows | |
| 4 | + | // known colours on a headless NeferWL and the screenshots are compared with | |
| 5 | + | // what those colours must become. It needs a GPU and, for the PQ client, /dev/udmabuf, | |
| 6 | + | // so it is excluded from `go test ./...`, `make check` and CI. Run it with | |
| 7 | + | // `make color-check`. | |
| 8 | + | package app | |
| 9 | + | ||
| 10 | + | import ( | |
| 11 | + | "context" | |
| 12 | + | "fmt" | |
| 13 | + | "image" | |
| 14 | + | "image/png" | |
| 15 | + | "math" | |
| 16 | + | "os" | |
| 17 | + | "os/exec" | |
| 18 | + | "path/filepath" | |
| 19 | + | "strconv" | |
| 20 | + | "strings" | |
| 21 | + | "testing" | |
| 22 | + | "time" | |
| 23 | + | ||
| 24 | + | "github.com/bnema/neferwl/examples/testpattern/pattern" | |
| 25 | + | "github.com/bnema/neferwl/internal/adapters/config" | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | const ( | |
| 29 | + | colorW, colorH = 640, 360 | |
| 30 | + | // sampleRadius gives the 5×5 box sampled at each patch centre. | |
| 31 | + | sampleRadius = 2 | |
| 32 | + | // cursorRadius is the half size of the box around the output centre that | |
| 33 | + | // headless screenshots draw the cursor into. | |
| 34 | + | cursorRadius = 48 | |
| 35 | + | // pqTolerance is the per-channel PQ signal tolerance, the one of | |
| 36 | + | // TestHDRWindowedComposition; it covers the fp16 round trip. | |
| 37 | + | pqTolerance = 0.012 | |
| 38 | + | // nitTolerance is the alternative HDR tolerance, in absolute luminance, | |
| 39 | + | // for the channels expected below nitFloor. | |
| 40 | + | // | |
| 41 | + | // Near black PQ is very steep, and the compositor's fp16 linear | |
| 42 | + | // intermediate leaves a small residue there: with BT.2020 primaries | |
| 43 | + | // outside BT.709 the BT.709 intermediate has a slightly negative or | |
| 44 | + | // rounded component, and the BT.2020 green primary patch measures | |
| 45 | + | // R=0.0313 PQ instead of 0 on an RX 9070 XT. That is about 0.02 nit, | |
| 46 | + | // invisible and not a colour bug, so a channel expected below nitFloor | |
| 47 | + | // also passes when the decoded luminance differs by no more than this. | |
| 48 | + | nitTolerance = 0.03 | |
| 49 | + | // nitFloor is the expected luminance (nits) under which the luminance | |
| 50 | + | // fallback applies; brighter channels must meet pqTolerance. | |
| 51 | + | nitFloor = 0.05 | |
| 52 | + | // pqNamedTF is the wp_color_manager_v1 transfer function of an HDR output | |
| 53 | + | // (st2084_pq). | |
| 54 | + | pqNamedTF = 11 | |
| 55 | + | // colorTimeout bounds each wait for the client and for a matching frame. | |
| 56 | + | colorTimeout = 5 * time.Second | |
| 57 | + | ) | |
| 58 | + | ||
| 59 | + | // TestColorCheckSDR: an sRGB wl_shm client on an SDR output is read back | |
| 60 | + | // byte for byte (±1 per channel). | |
| 61 | + | func TestColorCheckSDR(t *testing.T) { | |
| 62 | + | dir := t.TempDir() | |
| 63 | + | socket := startColorCompositor(t, Options{ScreenshotDir: dir}) | |
| 64 | + | client := startPatternClient(t, socket, "sdr") | |
| 65 | + | patches := pattern.Patches("sdr", colorW, colorH) | |
| 66 | + | assertNoCursorOverlap(t, patches) | |
| 67 | + | ||
| 68 | + | pollImage(t, filepath.Join(dir, "latest.png"), client.ready, "", func(img image.Image) []string { | |
| 69 | + | return comparePatches(img, patches, func(p pattern.Patch, got [3]float64) string { | |
| 70 | + | for c := range 3 { | |
| 71 | + | if math.Abs(got[c]*255-float64(p.SRGB[c])) > 1 { | |
| 72 | + | return fmt.Sprintf("got %v, want sRGB %v ±1", bytes3(got), p.SRGB) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | return "" | |
| 76 | + | }) | |
| 77 | + | }) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // TestColorCheckHDR checks a virtual HDR output through the raw PQ dump | |
| 81 | + | // (latest-pq.png), with an sRGB client and with a PQ client. | |
| 82 | + | // | |
| 83 | + | // Virtual HDR must be on: output-tf other than 11 means it fell back to SDR, | |
| 84 | + | // which is a failure, not a skip. Since headless exports its targets without | |
| 85 | + | // a display modifier list, it works on any GPU that exports XR30, and the dev | |
| 86 | + | // box does. The target requires a GPU: a Vulkan error fails the test. Only a | |
| 87 | + | // missing /dev/udmabuf or linear XR30 import (PQ client) skips. | |
| 88 | + | // | |
| 89 | + | // Expected path: PQ client content is decoded to fp16 linear BT.709 (/203, | |
| 90 | + | // compose_hdr.frag), then re-encoded (×203, BT.709→BT.2020, PQ, hdr.frag); | |
| 91 | + | // sRGB content maps to PQ(M·linear·203). There is no dithering, and the | |
| 92 | + | // cursor and the capture indicator are not in the exported target. | |
| 93 | + | func TestColorCheckHDR(t *testing.T) { | |
| 94 | + | dir := t.TempDir() | |
| 95 | + | socket := startColorCompositor(t, Options{ScreenshotDir: dir, HeadlessHDR: true, ScreenshotRaw: true}) | |
| 96 | + | pq := filepath.Join(dir, "latest-pq.png") | |
| 97 | + | ||
| 98 | + | t.Run("sdr-client", func(t *testing.T) { | |
| 99 | + | client := startPatternClient(t, socket, "sdr") | |
| 100 | + | patches := pattern.Patches("sdr", colorW, colorH) | |
| 101 | + | assertNoCursorOverlap(t, patches) | |
| 102 | + | pollImage(t, pq, client.ready, "no raw PQ dump (virtual HDR off?)", func(img image.Image) []string { | |
| 103 | + | return comparePatches(img, patches, func(p pattern.Patch, got [3]float64) string { | |
| 104 | + | var want [3]float64 | |
| 105 | + | var lin [3]float64 | |
| 106 | + | for c := range 3 { | |
| 107 | + | lin[c] = pattern.SRGBToLinear(float64(p.SRGB[c]) / 255) | |
| 108 | + | } | |
| 109 | + | for r := range 3 { | |
| 110 | + | for c := range 3 { | |
| 111 | + | want[r] += pattern.BT709ToBT2020[r][c] * lin[c] | |
| 112 | + | } | |
| 113 | + | want[r] = pattern.PQEncode(want[r] * pattern.ReferenceWhite) | |
| 114 | + | } | |
| 115 | + | return comparePQ(got, want) | |
| 116 | + | }) | |
| 117 | + | }) | |
| 118 | + | }) | |
| 119 | + | ||
| 120 | + | t.Run("pq-client", func(t *testing.T) { | |
| 121 | + | if _, err := os.Stat("/dev/udmabuf"); err != nil { | |
| 122 | + | t.Skipf("no /dev/udmabuf: %v", err) | |
| 123 | + | } | |
| 124 | + | client := startPatternClient(t, socket, "hdr") | |
| 125 | + | if client.tf != pqNamedTF { | |
| 126 | + | t.Fatalf("output-tf %d, want %d: virtual HDR fell back to SDR", client.tf, pqNamedTF) | |
| 127 | + | } | |
| 128 | + | patches := pattern.Patches("hdr", colorW, colorH) | |
| 129 | + | assertNoCursorOverlap(t, patches) | |
| 130 | + | pollImage(t, pq, client.ready, "no raw PQ dump (virtual HDR off?)", func(img image.Image) []string { | |
| 131 | + | return comparePatches(img, patches, func(p pattern.Patch, got [3]float64) string { | |
| 132 | + | var want [3]float64 | |
| 133 | + | for c := range 3 { | |
| 134 | + | want[c] = pattern.PQEncode(p.Nits[c]) | |
| 135 | + | } | |
| 136 | + | return comparePQ(got, want) | |
| 137 | + | }) | |
| 138 | + | }) | |
| 139 | + | // Tone mapping itself is unit-tested: only check the 8-bit | |
| 140 | + | // screenshot is not black at the 1000-nit patch. | |
| 141 | + | pollImage(t, filepath.Join(dir, "latest.png"), client.ready, "", func(img image.Image) []string { | |
| 142 | + | c := patches[5].Centre() | |
| 143 | + | r, g, b, _ := img.At(c.X, c.Y).RGBA() | |
| 144 | + | if r|g|b == 0 { | |
| 145 | + | return []string{fmt.Sprintf("latest.png is black at the 1000-nit patch (%d,%d)", c.X, c.Y)} | |
| 146 | + | } | |
| 147 | + | return nil | |
| 148 | + | }) | |
| 149 | + | }) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // comparePQ compares one patch, per channel: it passes within pqTolerance of | |
| 153 | + | // signal or, for a channel expected below nitFloor, within nitTolerance of | |
| 154 | + | // luminance (see nitTolerance). | |
| 155 | + | func comparePQ(got, want [3]float64) string { | |
| 156 | + | for c := range 3 { | |
| 157 | + | if math.Abs(got[c]-want[c]) <= pqTolerance { | |
| 158 | + | continue | |
| 159 | + | } | |
| 160 | + | if wantNits := pattern.PQDecode(want[c]); wantNits < nitFloor && math.Abs(pattern.PQDecode(got[c])-wantNits) <= nitTolerance { | |
| 161 | + | continue | |
| 162 | + | } | |
| 163 | + | return fmt.Sprintf("got PQ %.4f %.4f %.4f (%.3f %.3f %.3f nit), want %.4f %.4f %.4f (%.3f %.3f %.3f nit); ±%v PQ, or ±%v nit under %v nit", | |
| 164 | + | got[0], got[1], got[2], pattern.PQDecode(got[0]), pattern.PQDecode(got[1]), pattern.PQDecode(got[2]), | |
| 165 | + | want[0], want[1], want[2], pattern.PQDecode(want[0]), pattern.PQDecode(want[1]), pattern.PQDecode(want[2]), | |
| 166 | + | pqTolerance, nitTolerance, nitFloor) | |
| 167 | + | } | |
| 168 | + | return "" | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func bytes3(v [3]float64) [3]int { | |
| 172 | + | return [3]int{int(math.Round(v[0] * 255)), int(math.Round(v[1] * 255)), int(math.Round(v[2] * 255))} | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // comparePatches samples the 5×5 box at the centre of every patch; check gets | |
| 176 | + | // each pixel as unit-range channels and returns a mismatch description or "". | |
| 177 | + | // It returns one line per mismatching patch, or one for a wrong image size. | |
| 178 | + | func comparePatches(img image.Image, patches []pattern.Patch, check func(p pattern.Patch, got [3]float64) string) []string { | |
| 179 | + | if b := img.Bounds(); b.Dx() != colorW || b.Dy() != colorH { | |
| 180 | + | return []string{fmt.Sprintf("image is %v, want %dx%d", b.Size(), colorW, colorH)} | |
| 181 | + | } | |
| 182 | + | var bad []string | |
| 183 | + | for i, p := range patches { | |
| 184 | + | c := p.Centre() | |
| 185 | + | box: | |
| 186 | + | for y := c.Y - sampleRadius; y <= c.Y+sampleRadius; y++ { | |
| 187 | + | for x := c.X - sampleRadius; x <= c.X+sampleRadius; x++ { | |
| 188 | + | r, g, b, _ := img.At(x, y).RGBA() | |
| 189 | + | got := [3]float64{float64(r) / 65535, float64(g) / 65535, float64(b) / 65535} | |
| 190 | + | if msg := check(p, got); msg != "" { | |
| 191 | + | bad = append(bad, fmt.Sprintf("patch %d at (%d,%d): %s", i, x, y, msg)) | |
| 192 | + | break box | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | return bad | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // assertNoCursorOverlap checks no sampling box touches the cursor, which | |
| 201 | + | // headless screenshots draw at the output centre. | |
| 202 | + | func assertNoCursorOverlap(t *testing.T, patches []pattern.Patch) { | |
| 203 | + | t.Helper() | |
| 204 | + | cursor := image.Rect(colorW/2-cursorRadius, colorH/2-cursorRadius, colorW/2+cursorRadius, colorH/2+cursorRadius) | |
| 205 | + | for i, p := range patches { | |
| 206 | + | c := p.Centre() | |
| 207 | + | box := image.Rect(c.X-sampleRadius, c.Y-sampleRadius, c.X+sampleRadius+1, c.Y+sampleRadius+1) | |
| 208 | + | if box.Overlaps(cursor) { | |
| 209 | + | t.Fatalf("patch %d sampling box %v intersects the cursor box %v", i, box, cursor) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // pollImage decodes path every 50 ms, up to colorTimeout, until check returns | |
| 215 | + | // no mismatch. Only files modified after notBefore count, so a frame of a | |
| 216 | + | // previous client is never judged (files are replaced atomically). On timeout | |
| 217 | + | // it reports every mismatch of the last attempt; missing, when not empty, | |
| 218 | + | // replaces the error of a file that does not exist. | |
| 219 | + | func pollImage(t *testing.T, path string, notBefore time.Time, missing string, check func(image.Image) []string) { | |
| 220 | + | t.Helper() | |
| 221 | + | var last []string | |
| 222 | + | for deadline := time.Now().Add(colorTimeout); ; time.Sleep(50 * time.Millisecond) { | |
| 223 | + | last = nil | |
| 224 | + | if fi, err := os.Stat(path); err != nil { | |
| 225 | + | last = []string{err.Error()} | |
| 226 | + | if missing != "" && os.IsNotExist(err) { | |
| 227 | + | last = []string{missing} | |
| 228 | + | } | |
| 229 | + | } else if !fi.ModTime().After(notBefore) { | |
| 230 | + | last = []string{fmt.Sprintf("%s not rewritten since the client started", filepath.Base(path))} | |
| 231 | + | } else if f, err := os.Open(path); err != nil { | |
| 232 | + | last = []string{err.Error()} | |
| 233 | + | } else { | |
| 234 | + | img, err := png.Decode(f) | |
| 235 | + | _ = f.Close() | |
| 236 | + | if err != nil { | |
| 237 | + | last = []string{err.Error()} | |
| 238 | + | } else if last = check(img); len(last) == 0 { | |
| 239 | + | return | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | if time.Now().After(deadline) { | |
| 243 | + | t.Fatalf("%s does not match after %v:\n %s", filepath.Base(path), colorTimeout, strings.Join(last, "\n ")) | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // startColorCompositor runs a headless NeferWL with one 640×360 output in | |
| 249 | + | // temporary runtime and state directories and returns its socket path. A Run | |
| 250 | + | // error, Vulkan included, fails the test: the target requires a GPU. | |
| 251 | + | func startColorCompositor(t *testing.T, opts Options) string { | |
| 252 | + | t.Helper() | |
| 253 | + | runtime := t.TempDir() | |
| 254 | + | t.Setenv("XDG_RUNTIME_DIR", runtime) | |
| 255 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 256 | + | opts.Backend = "headless" | |
| 257 | + | opts.NoXwayland = true | |
| 258 | + | opts.NoTerminal = true | |
| 259 | + | opts.Config = config.Defaults() | |
| 260 | + | opts.Sizes = [][2]int{{colorW, colorH}} | |
| 261 | + | ||
| 262 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 263 | + | done := make(chan error, 1) | |
| 264 | + | go func() { done <- Run(ctx, opts) }() | |
| 265 | + | stopped := false // Run's result was already received | |
| 266 | + | t.Cleanup(func() { | |
| 267 | + | cancel() | |
| 268 | + | if stopped { | |
| 269 | + | return | |
| 270 | + | } | |
| 271 | + | select { | |
| 272 | + | case err := <-done: | |
| 273 | + | if err != nil { | |
| 274 | + | t.Errorf("Run: %v", err) | |
| 275 | + | } | |
| 276 | + | case <-time.After(5 * time.Second): | |
| 277 | + | t.Error("Run did not stop") | |
| 278 | + | } | |
| 279 | + | }) | |
| 280 | + | socket := filepath.Join(runtime, "wayland-1") | |
| 281 | + | for deadline := time.Now().Add(colorTimeout); ; time.Sleep(20 * time.Millisecond) { | |
| 282 | + | if _, err := os.Stat(socket); err == nil { | |
| 283 | + | return socket | |
| 284 | + | } | |
| 285 | + | select { | |
| 286 | + | case err := <-done: | |
| 287 | + | stopped = true | |
| 288 | + | t.Fatalf("neferwl exited: %v", err) | |
| 289 | + | default: | |
| 290 | + | } | |
| 291 | + | if time.Now().After(deadline) { | |
| 292 | + | t.Fatal("no wayland socket") | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // patternClient is a running examples/testpattern. | |
| 298 | + | type patternClient struct { | |
| 299 | + | ready time.Time // just before the client was started: older files are not judged | |
| 300 | + | tf int // "output-tf" of the compositor's output (hdr mode) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | // startPatternClient builds and starts examples/testpattern with -fullscreen | |
| 304 | + | // and returns once it printed "ready 640 360". It skips the test when the | |
| 305 | + | // client reports that linear XR30 is unsupported, and stops the client when | |
| 306 | + | // the test (or subtest) ends. | |
| 307 | + | func startPatternClient(t *testing.T, socket, mode string) *patternClient { | |
| 308 | + | t.Helper() | |
| 309 | + | bin := filepath.Join(t.TempDir(), "testpattern") | |
| 310 | + | build := exec.Command("go", "build", "-o", bin, "../../examples/testpattern") | |
| 311 | + | build.Env = append(os.Environ(), "CGO_ENABLED=0") | |
| 312 | + | if out, err := build.CombinedOutput(); err != nil { | |
| 313 | + | t.Fatalf("build testpattern: %v: %s", err, out) | |
| 314 | + | } | |
| 315 | + | cmd := exec.Command(bin, "-mode", mode, "-fullscreen") | |
| 316 | + | cmd.Env = append(os.Environ(), "WAYLAND_DISPLAY="+filepath.Base(socket)) | |
| 317 | + | stderr := &lockedBuffer{} | |
| 318 | + | cmd.Stderr = stderr | |
| 319 | + | // Taken before the start so that no frame of this client predates it. | |
| 320 | + | // Frames of its windowed state (before fullscreen) are newer, so they | |
| 321 | + | // are judged: they just fail the check and are retried. | |
| 322 | + | started := time.Now() | |
| 323 | + | if err := cmd.Start(); err != nil { | |
| 324 | + | t.Fatal(err) | |
| 325 | + | } | |
| 326 | + | waited := make(chan error, 1) | |
| 327 | + | go func() { waited <- cmd.Wait() }() | |
| 328 | + | exited := false | |
| 329 | + | t.Cleanup(func() { | |
| 330 | + | if !exited { | |
| 331 | + | _ = cmd.Process.Kill() | |
| 332 | + | <-waited | |
| 333 | + | } | |
| 334 | + | }) | |
| 335 | + | ||
| 336 | + | for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(10 * time.Millisecond) { // includes the 1.2 s fullscreen delay | |
| 337 | + | log := stderr.String() | |
| 338 | + | if strings.Contains(log, fmt.Sprintf("ready %d %d\n", colorW, colorH)) { | |
| 339 | + | c := &patternClient{ready: started} | |
| 340 | + | for _, l := range strings.Split(log, "\n") { | |
| 341 | + | if v, ok := strings.CutPrefix(l, "output-tf "); ok { | |
| 342 | + | tf, err := strconv.Atoi(v) | |
| 343 | + | if err != nil { | |
| 344 | + | t.Fatalf("bad %q", l) | |
| 345 | + | } | |
| 346 | + | c.tf = tf | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | return c | |
| 350 | + | } | |
| 351 | + | select { | |
| 352 | + | case err := <-waited: | |
| 353 | + | exited = true | |
| 354 | + | log = stderr.String() | |
| 355 | + | if strings.Contains(log, "linear XR30 unsupported") { | |
| 356 | + | t.Skipf("testpattern: %s", strings.TrimSpace(log)) | |
| 357 | + | } | |
| 358 | + | t.Fatalf("testpattern exited before ready (%v): %s", err, strings.TrimSpace(log)) | |
| 359 | + | default: | |
| 360 | + | } | |
| 361 | + | if time.Now().After(deadline) { | |
| 362 | + | t.Fatalf("testpattern not ready: %s", strings.TrimSpace(log)) | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | } | |
| @@ -0,0 +1,365 @@ | |||||
| 1 | + | //go:build colorcheck | |||
| 2 | + | ||||
| 3 | + | // On-demand colour check: a real Wayland client (examples/testpattern) shows | |||
| 4 | + | // known colours on a headless NeferWL and the screenshots are compared with | |||
| 5 | + | // what those colours must become. It needs a GPU and, for the PQ client, /dev/udmabuf, | |||
| 6 | + | // so it is excluded from `go test ./...`, `make check` and CI. Run it with | |||
| 7 | + | // `make color-check`. | |||
| 8 | + | package app | |||
| 9 | + | ||||
| 10 | + | import ( | |||
| 11 | + | "context" | |||
| 12 | + | "fmt" | |||
| 13 | + | "image" | |||
| 14 | + | "image/png" | |||
| 15 | + | "math" | |||
| 16 | + | "os" | |||
| 17 | + | "os/exec" | |||
| 18 | + | "path/filepath" | |||
| 19 | + | "strconv" | |||
| 20 | + | "strings" | |||
| 21 | + | "testing" | |||
| 22 | + | "time" | |||
| 23 | + | ||||
| 24 | + | "github.com/bnema/neferwl/examples/testpattern/pattern" | |||
| 25 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | const ( | |||
| 29 | + | colorW, colorH = 640, 360 | |||
| 30 | + | // sampleRadius gives the 5×5 box sampled at each patch centre. | |||
| 31 | + | sampleRadius = 2 | |||
| 32 | + | // cursorRadius is the half size of the box around the output centre that | |||
| 33 | + | // headless screenshots draw the cursor into. | |||
| 34 | + | cursorRadius = 48 | |||
| 35 | + | // pqTolerance is the per-channel PQ signal tolerance, the one of | |||
| 36 | + | // TestHDRWindowedComposition; it covers the fp16 round trip. | |||
| 37 | + | pqTolerance = 0.012 | |||
| 38 | + | // nitTolerance is the alternative HDR tolerance, in absolute luminance, | |||
| 39 | + | // for the channels expected below nitFloor. | |||
| 40 | + | // | |||
| 41 | + | // Near black PQ is very steep, and the compositor's fp16 linear | |||
| 42 | + | // intermediate leaves a small residue there: with BT.2020 primaries | |||
| 43 | + | // outside BT.709 the BT.709 intermediate has a slightly negative or | |||
| 44 | + | // rounded component, and the BT.2020 green primary patch measures | |||
| 45 | + | // R=0.0313 PQ instead of 0 on an RX 9070 XT. That is about 0.02 nit, | |||
| 46 | + | // invisible and not a colour bug, so a channel expected below nitFloor | |||
| 47 | + | // also passes when the decoded luminance differs by no more than this. | |||
| 48 | + | nitTolerance = 0.03 | |||
| 49 | + | // nitFloor is the expected luminance (nits) under which the luminance | |||
| 50 | + | // fallback applies; brighter channels must meet pqTolerance. | |||
| 51 | + | nitFloor = 0.05 | |||
| 52 | + | // pqNamedTF is the wp_color_manager_v1 transfer function of an HDR output | |||
| 53 | + | // (st2084_pq). | |||
| 54 | + | pqNamedTF = 11 | |||
| 55 | + | // colorTimeout bounds each wait for the client and for a matching frame. | |||
| 56 | + | colorTimeout = 5 * time.Second | |||
| 57 | + | ) | |||
| 58 | + | ||||
| 59 | + | // TestColorCheckSDR: an sRGB wl_shm client on an SDR output is read back | |||
| 60 | + | // byte for byte (±1 per channel). | |||
| 61 | + | func TestColorCheckSDR(t *testing.T) { | |||
| 62 | + | dir := t.TempDir() | |||
| 63 | + | socket := startColorCompositor(t, Options{ScreenshotDir: dir}) | |||
| 64 | + | client := startPatternClient(t, socket, "sdr") | |||
| 65 | + | patches := pattern.Patches("sdr", colorW, colorH) | |||
| 66 | + | assertNoCursorOverlap(t, patches) | |||
| 67 | + | ||||
| 68 | + | pollImage(t, filepath.Join(dir, "latest.png"), client.ready, "", func(img image.Image) []string { | |||
| 69 | + | return comparePatches(img, patches, func(p pattern.Patch, got [3]float64) string { | |||
| 70 | + | for c := range 3 { | |||
| 71 | + | if math.Abs(got[c]*255-float64(p.SRGB[c])) > 1 { | |||
| 72 | + | return fmt.Sprintf("got %v, want sRGB %v ±1", bytes3(got), p.SRGB) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | return "" | |||
| 76 | + | }) | |||
| 77 | + | }) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // TestColorCheckHDR checks a virtual HDR output through the raw PQ dump | |||
| 81 | + | // (latest-pq.png), with an sRGB client and with a PQ client. | |||
| 82 | + | // | |||
| 83 | + | // Virtual HDR must be on: output-tf other than 11 means it fell back to SDR, | |||
| 84 | + | // which is a failure, not a skip. Since headless exports its targets without | |||
| 85 | + | // a display modifier list, it works on any GPU that exports XR30, and the dev | |||
| 86 | + | // box does. The target requires a GPU: a Vulkan error fails the test. Only a | |||
| 87 | + | // missing /dev/udmabuf or linear XR30 import (PQ client) skips. | |||
| 88 | + | // | |||
| 89 | + | // Expected path: PQ client content is decoded to fp16 linear BT.709 (/203, | |||
| 90 | + | // compose_hdr.frag), then re-encoded (×203, BT.709→BT.2020, PQ, hdr.frag); | |||
| 91 | + | // sRGB content maps to PQ(M·linear·203). There is no dithering, and the | |||
| 92 | + | // cursor and the capture indicator are not in the exported target. | |||
| 93 | + | func TestColorCheckHDR(t *testing.T) { | |||
| 94 | + | dir := t.TempDir() | |||
| 95 | + | socket := startColorCompositor(t, Options{ScreenshotDir: dir, HeadlessHDR: true, ScreenshotRaw: true}) | |||
| 96 | + | pq := filepath.Join(dir, "latest-pq.png") | |||
| 97 | + | ||||
| 98 | + | t.Run("sdr-client", func(t *testing.T) { | |||
| 99 | + | client := startPatternClient(t, socket, "sdr") | |||
| 100 | + | patches := pattern.Patches("sdr", colorW, colorH) | |||
| 101 | + | assertNoCursorOverlap(t, patches) | |||
| 102 | + | pollImage(t, pq, client.ready, "no raw PQ dump (virtual HDR off?)", func(img image.Image) []string { | |||
| 103 | + | return comparePatches(img, patches, func(p pattern.Patch, got [3]float64) string { | |||
| 104 | + | var want [3]float64 | |||
| 105 | + | var lin [3]float64 | |||
| 106 | + | for c := range 3 { | |||
| 107 | + | lin[c] = pattern.SRGBToLinear(float64(p.SRGB[c]) / 255) | |||
| 108 | + | } | |||
| 109 | + | for r := range 3 { | |||
| 110 | + | for c := range 3 { | |||
| 111 | + | want[r] += pattern.BT709ToBT2020[r][c] * lin[c] | |||
| 112 | + | } | |||
| 113 | + | want[r] = pattern.PQEncode(want[r] * pattern.ReferenceWhite) | |||
| 114 | + | } | |||
| 115 | + | return comparePQ(got, want) | |||
| 116 | + | }) | |||
| 117 | + | }) | |||
| 118 | + | }) | |||
| 119 | + | ||||
| 120 | + | t.Run("pq-client", func(t *testing.T) { | |||
| 121 | + | if _, err := os.Stat("/dev/udmabuf"); err != nil { | |||
| 122 | + | t.Skipf("no /dev/udmabuf: %v", err) | |||
| 123 | + | } | |||
| 124 | + | client := startPatternClient(t, socket, "hdr") | |||
| 125 | + | if client.tf != pqNamedTF { | |||
| 126 | + | t.Fatalf("output-tf %d, want %d: virtual HDR fell back to SDR", client.tf, pqNamedTF) | |||
| 127 | + | } | |||
| 128 | + | patches := pattern.Patches("hdr", colorW, colorH) | |||
| 129 | + | assertNoCursorOverlap(t, patches) | |||
| 130 | + | pollImage(t, pq, client.ready, "no raw PQ dump (virtual HDR off?)", func(img image.Image) []string { | |||
| 131 | + | return comparePatches(img, patches, func(p pattern.Patch, got [3]float64) string { | |||
| 132 | + | var want [3]float64 | |||
| 133 | + | for c := range 3 { | |||
| 134 | + | want[c] = pattern.PQEncode(p.Nits[c]) | |||
| 135 | + | } | |||
| 136 | + | return comparePQ(got, want) | |||
| 137 | + | }) | |||
| 138 | + | }) | |||
| 139 | + | // Tone mapping itself is unit-tested: only check the 8-bit | |||
| 140 | + | // screenshot is not black at the 1000-nit patch. | |||
| 141 | + | pollImage(t, filepath.Join(dir, "latest.png"), client.ready, "", func(img image.Image) []string { | |||
| 142 | + | c := patches[5].Centre() | |||
| 143 | + | r, g, b, _ := img.At(c.X, c.Y).RGBA() | |||
| 144 | + | if r|g|b == 0 { | |||
| 145 | + | return []string{fmt.Sprintf("latest.png is black at the 1000-nit patch (%d,%d)", c.X, c.Y)} | |||
| 146 | + | } | |||
| 147 | + | return nil | |||
| 148 | + | }) | |||
| 149 | + | }) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // comparePQ compares one patch, per channel: it passes within pqTolerance of | |||
| 153 | + | // signal or, for a channel expected below nitFloor, within nitTolerance of | |||
| 154 | + | // luminance (see nitTolerance). | |||
| 155 | + | func comparePQ(got, want [3]float64) string { | |||
| 156 | + | for c := range 3 { | |||
| 157 | + | if math.Abs(got[c]-want[c]) <= pqTolerance { | |||
| 158 | + | continue | |||
| 159 | + | } | |||
| 160 | + | if wantNits := pattern.PQDecode(want[c]); wantNits < nitFloor && math.Abs(pattern.PQDecode(got[c])-wantNits) <= nitTolerance { | |||
| 161 | + | continue | |||
| 162 | + | } | |||
| 163 | + | return fmt.Sprintf("got PQ %.4f %.4f %.4f (%.3f %.3f %.3f nit), want %.4f %.4f %.4f (%.3f %.3f %.3f nit); ±%v PQ, or ±%v nit under %v nit", | |||
| 164 | + | got[0], got[1], got[2], pattern.PQDecode(got[0]), pattern.PQDecode(got[1]), pattern.PQDecode(got[2]), | |||
| 165 | + | want[0], want[1], want[2], pattern.PQDecode(want[0]), pattern.PQDecode(want[1]), pattern.PQDecode(want[2]), | |||
| 166 | + | pqTolerance, nitTolerance, nitFloor) | |||
| 167 | + | } | |||
| 168 | + | return "" | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func bytes3(v [3]float64) [3]int { | |||
| 172 | + | return [3]int{int(math.Round(v[0] * 255)), int(math.Round(v[1] * 255)), int(math.Round(v[2] * 255))} | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // comparePatches samples the 5×5 box at the centre of every patch; check gets | |||
| 176 | + | // each pixel as unit-range channels and returns a mismatch description or "". | |||
| 177 | + | // It returns one line per mismatching patch, or one for a wrong image size. | |||
| 178 | + | func comparePatches(img image.Image, patches []pattern.Patch, check func(p pattern.Patch, got [3]float64) string) []string { | |||
| 179 | + | if b := img.Bounds(); b.Dx() != colorW || b.Dy() != colorH { | |||
| 180 | + | return []string{fmt.Sprintf("image is %v, want %dx%d", b.Size(), colorW, colorH)} | |||
| 181 | + | } | |||
| 182 | + | var bad []string | |||
| 183 | + | for i, p := range patches { | |||
| 184 | + | c := p.Centre() | |||
| 185 | + | box: | |||
| 186 | + | for y := c.Y - sampleRadius; y <= c.Y+sampleRadius; y++ { | |||
| 187 | + | for x := c.X - sampleRadius; x <= c.X+sampleRadius; x++ { | |||
| 188 | + | r, g, b, _ := img.At(x, y).RGBA() | |||
| 189 | + | got := [3]float64{float64(r) / 65535, float64(g) / 65535, float64(b) / 65535} | |||
| 190 | + | if msg := check(p, got); msg != "" { | |||
| 191 | + | bad = append(bad, fmt.Sprintf("patch %d at (%d,%d): %s", i, x, y, msg)) | |||
| 192 | + | break box | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | return bad | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // assertNoCursorOverlap checks no sampling box touches the cursor, which | |||
| 201 | + | // headless screenshots draw at the output centre. | |||
| 202 | + | func assertNoCursorOverlap(t *testing.T, patches []pattern.Patch) { | |||
| 203 | + | t.Helper() | |||
| 204 | + | cursor := image.Rect(colorW/2-cursorRadius, colorH/2-cursorRadius, colorW/2+cursorRadius, colorH/2+cursorRadius) | |||
| 205 | + | for i, p := range patches { | |||
| 206 | + | c := p.Centre() | |||
| 207 | + | box := image.Rect(c.X-sampleRadius, c.Y-sampleRadius, c.X+sampleRadius+1, c.Y+sampleRadius+1) | |||
| 208 | + | if box.Overlaps(cursor) { | |||
| 209 | + | t.Fatalf("patch %d sampling box %v intersects the cursor box %v", i, box, cursor) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | // pollImage decodes path every 50 ms, up to colorTimeout, until check returns | |||
| 215 | + | // no mismatch. Only files modified after notBefore count, so a frame of a | |||
| 216 | + | // previous client is never judged (files are replaced atomically). On timeout | |||
| 217 | + | // it reports every mismatch of the last attempt; missing, when not empty, | |||
| 218 | + | // replaces the error of a file that does not exist. | |||
| 219 | + | func pollImage(t *testing.T, path string, notBefore time.Time, missing string, check func(image.Image) []string) { | |||
| 220 | + | t.Helper() | |||
| 221 | + | var last []string | |||
| 222 | + | for deadline := time.Now().Add(colorTimeout); ; time.Sleep(50 * time.Millisecond) { | |||
| 223 | + | last = nil | |||
| 224 | + | if fi, err := os.Stat(path); err != nil { | |||
| 225 | + | last = []string{err.Error()} | |||
| 226 | + | if missing != "" && os.IsNotExist(err) { | |||
| 227 | + | last = []string{missing} | |||
| 228 | + | } | |||
| 229 | + | } else if !fi.ModTime().After(notBefore) { | |||
| 230 | + | last = []string{fmt.Sprintf("%s not rewritten since the client started", filepath.Base(path))} | |||
| 231 | + | } else if f, err := os.Open(path); err != nil { | |||
| 232 | + | last = []string{err.Error()} | |||
| 233 | + | } else { | |||
| 234 | + | img, err := png.Decode(f) | |||
| 235 | + | _ = f.Close() | |||
| 236 | + | if err != nil { | |||
| 237 | + | last = []string{err.Error()} | |||
| 238 | + | } else if last = check(img); len(last) == 0 { | |||
| 239 | + | return | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | if time.Now().After(deadline) { | |||
| 243 | + | t.Fatalf("%s does not match after %v:\n %s", filepath.Base(path), colorTimeout, strings.Join(last, "\n ")) | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // startColorCompositor runs a headless NeferWL with one 640×360 output in | |||
| 249 | + | // temporary runtime and state directories and returns its socket path. A Run | |||
| 250 | + | // error, Vulkan included, fails the test: the target requires a GPU. | |||
| 251 | + | func startColorCompositor(t *testing.T, opts Options) string { | |||
| 252 | + | t.Helper() | |||
| 253 | + | runtime := t.TempDir() | |||
| 254 | + | t.Setenv("XDG_RUNTIME_DIR", runtime) | |||
| 255 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 256 | + | opts.Backend = "headless" | |||
| 257 | + | opts.NoXwayland = true | |||
| 258 | + | opts.NoTerminal = true | |||
| 259 | + | opts.Config = config.Defaults() | |||
| 260 | + | opts.Sizes = [][2]int{{colorW, colorH}} | |||
| 261 | + | ||||
| 262 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 263 | + | done := make(chan error, 1) | |||
| 264 | + | go func() { done <- Run(ctx, opts) }() | |||
| 265 | + | stopped := false // Run's result was already received | |||
| 266 | + | t.Cleanup(func() { | |||
| 267 | + | cancel() | |||
| 268 | + | if stopped { | |||
| 269 | + | return | |||
| 270 | + | } | |||
| 271 | + | select { | |||
| 272 | + | case err := <-done: | |||
| 273 | + | if err != nil { | |||
| 274 | + | t.Errorf("Run: %v", err) | |||
| 275 | + | } | |||
| 276 | + | case <-time.After(5 * time.Second): | |||
| 277 | + | t.Error("Run did not stop") | |||
| 278 | + | } | |||
| 279 | + | }) | |||
| 280 | + | socket := filepath.Join(runtime, "wayland-1") | |||
| 281 | + | for deadline := time.Now().Add(colorTimeout); ; time.Sleep(20 * time.Millisecond) { | |||
| 282 | + | if _, err := os.Stat(socket); err == nil { | |||
| 283 | + | return socket | |||
| 284 | + | } | |||
| 285 | + | select { | |||
| 286 | + | case err := <-done: | |||
| 287 | + | stopped = true | |||
| 288 | + | t.Fatalf("neferwl exited: %v", err) | |||
| 289 | + | default: | |||
| 290 | + | } | |||
| 291 | + | if time.Now().After(deadline) { | |||
| 292 | + | t.Fatal("no wayland socket") | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | // patternClient is a running examples/testpattern. | |||
| 298 | + | type patternClient struct { | |||
| 299 | + | ready time.Time // just before the client was started: older files are not judged | |||
| 300 | + | tf int // "output-tf" of the compositor's output (hdr mode) | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | // startPatternClient builds and starts examples/testpattern with -fullscreen | |||
| 304 | + | // and returns once it printed "ready 640 360". It skips the test when the | |||
| 305 | + | // client reports that linear XR30 is unsupported, and stops the client when | |||
| 306 | + | // the test (or subtest) ends. | |||
| 307 | + | func startPatternClient(t *testing.T, socket, mode string) *patternClient { | |||
| 308 | + | t.Helper() | |||
| 309 | + | bin := filepath.Join(t.TempDir(), "testpattern") | |||
| 310 | + | build := exec.Command("go", "build", "-o", bin, "../../examples/testpattern") | |||
| 311 | + | build.Env = append(os.Environ(), "CGO_ENABLED=0") | |||
| 312 | + | if out, err := build.CombinedOutput(); err != nil { | |||
| 313 | + | t.Fatalf("build testpattern: %v: %s", err, out) | |||
| 314 | + | } | |||
| 315 | + | cmd := exec.Command(bin, "-mode", mode, "-fullscreen") | |||
| 316 | + | cmd.Env = append(os.Environ(), "WAYLAND_DISPLAY="+filepath.Base(socket)) | |||
| 317 | + | stderr := &lockedBuffer{} | |||
| 318 | + | cmd.Stderr = stderr | |||
| 319 | + | // Taken before the start so that no frame of this client predates it. | |||
| 320 | + | // Frames of its windowed state (before fullscreen) are newer, so they | |||
| 321 | + | // are judged: they just fail the check and are retried. | |||
| 322 | + | started := time.Now() | |||
| 323 | + | if err := cmd.Start(); err != nil { | |||
| 324 | + | t.Fatal(err) | |||
| 325 | + | } | |||
| 326 | + | waited := make(chan error, 1) | |||
| 327 | + | go func() { waited <- cmd.Wait() }() | |||
| 328 | + | exited := false | |||
| 329 | + | t.Cleanup(func() { | |||
| 330 | + | if !exited { | |||
| 331 | + | _ = cmd.Process.Kill() | |||
| 332 | + | <-waited | |||
| 333 | + | } | |||
| 334 | + | }) | |||
| 335 | + | ||||
| 336 | + | for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(10 * time.Millisecond) { // includes the 1.2 s fullscreen delay | |||
| 337 | + | log := stderr.String() | |||
| 338 | + | if strings.Contains(log, fmt.Sprintf("ready %d %d\n", colorW, colorH)) { | |||
| 339 | + | c := &patternClient{ready: started} | |||
| 340 | + | for _, l := range strings.Split(log, "\n") { | |||
| 341 | + | if v, ok := strings.CutPrefix(l, "output-tf "); ok { | |||
| 342 | + | tf, err := strconv.Atoi(v) | |||
| 343 | + | if err != nil { | |||
| 344 | + | t.Fatalf("bad %q", l) | |||
| 345 | + | } | |||
| 346 | + | c.tf = tf | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | return c | |||
| 350 | + | } | |||
| 351 | + | select { | |||
| 352 | + | case err := <-waited: | |||
| 353 | + | exited = true | |||
| 354 | + | log = stderr.String() | |||
| 355 | + | if strings.Contains(log, "linear XR30 unsupported") { | |||
| 356 | + | t.Skipf("testpattern: %s", strings.TrimSpace(log)) | |||
| 357 | + | } | |||
| 358 | + | t.Fatalf("testpattern exited before ready (%v): %s", err, strings.TrimSpace(log)) | |||
| 359 | + | default: | |||
| 360 | + | } | |||
| 361 | + | if time.Now().After(deadline) { | |||
| 362 | + | t.Fatalf("testpattern not ready: %s", strings.TrimSpace(log)) | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | } | |||
M internal/app/headless.go +42 -7
| @@ -11,20 +11,36 @@ import ( | |||
| 11 | 11 | "github.com/bnema/zerowrap" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | + | // headlessOptions are the settings of the virtual outputs. | |
| 15 | + | type headlessOptions struct { | |
| 16 | + | sizes [][2]int | |
| 17 | + | shots string | |
| 18 | + | hdr bool | |
| 19 | + | raw bool // also write latest-pq.png (headless.Options.RawHDR) | |
| 20 | + | } | |
| 21 | + | ||
| 14 | 22 | // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ... | |
| 15 | 23 | // With several outputs, screenshots go to a subdirectory per output. Modes | |
| 16 | 24 | // are fixed, so apply answers every configuration without backend work. | |
| 17 | - | func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | |
| 25 | + | // | |
| 26 | + | // The raw PQ dump (o.raw) needs readable HDR targets, set on these | |
| 27 | + | // renderers only: the DRM path never reads them back. | |
| 28 | + | // | |
| 29 | + | // Virtual outputs have no display to list modifiers, so each renderer is | |
| 30 | + | // told it drives one (vulkan.Renderer.SetVirtualOutput); without it a real | |
| 31 | + | // GPU refuses the HDR targets and the output falls back to SDR. | |
| 32 | + | func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | |
| 33 | + | newRenderer = virtualRenderer(newRenderer, o.raw) | |
| 18 | 34 | set := newOutputSet(ctx, ch.captured) | |
| 19 | 35 | set.wireSecurity(ch) | |
| 20 | 36 | inventory := ports.OutputHeads{} | |
| 21 | - | for i, size := range sizes { | |
| 37 | + | for i, size := range o.sizes { | |
| 22 | 38 | name := fmt.Sprintf("HEADLESS-%d", i+1) | |
| 23 | 39 | mode := ports.OutputMode{Width: size[0], Height: size[1], RefreshMilli: 60000, Preferred: true} | |
| 24 | 40 | inventory.Heads = append(inventory.Heads, ports.OutputHead{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1], RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true}) | |
| 25 | - | dir := shots | |
| 26 | - | if dir != "" && len(sizes) > 1 { | |
| 27 | - | dir = filepath.Join(shots, name) | |
| 41 | + | dir := o.shots | |
| 42 | + | if dir != "" && len(o.sizes) > 1 { | |
| 43 | + | dir = filepath.Join(o.shots, name) | |
| 28 | 44 | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 29 | 45 | // Outputs already started stop and release their captures. | |
| 30 | 46 | return joinErr(err, set.wait()) | |
| @@ -32,7 +48,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap | |||
| 32 | 48 | } | |
| 33 | 49 | cur := &headless.Cursor{} | |
| 34 | 50 | curs.set(name, cur) | |
| 35 | - | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured} | |
| 51 | + | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured} | |
| 36 | 52 | started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 37 | 53 | opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance | |
| 38 | 54 | return headless.Run(octx, opts, sc, cc, cu, cap) | |
| @@ -52,7 +68,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap | |||
| 52 | 68 | } | |
| 53 | 69 | apply.heads(inventory) | |
| 54 | 70 | // The pointer starts centred on the first output, like libinput's. | |
| 55 | - | curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2, false) | |
| 71 | + | curs.move("HEADLESS-1", float64(o.sizes[0][0])/2, float64(o.sizes[0][1])/2, false) | |
| 56 | 72 | for { | |
| 57 | 73 | configs, configNext := apply.configOut(ch.configured), apply.config | |
| 58 | 74 | replies, replyNext := apply.replyOut(ch.replies) | |
| @@ -100,6 +116,25 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap | |||
| 100 | 116 | } | |
| 101 | 117 | } | |
| 102 | 118 | ||
| 119 | + | // virtualRenderer wraps a renderer factory for headless outputs. Vulkan is | |
| 120 | + | // the only implementation of these settings; they are not on the port. | |
| 121 | + | // readback makes the HDR targets readable (HDRPixels). | |
| 122 | + | func virtualRenderer(newRenderer func(w, h int) (ports.Renderer, error), readback bool) func(w, h int) (ports.Renderer, error) { | |
| 123 | + | return func(w, h int) (ports.Renderer, error) { | |
| 124 | + | r, err := newRenderer(w, h) | |
| 125 | + | if err != nil { | |
| 126 | + | return nil, err | |
| 127 | + | } | |
| 128 | + | if v, ok := r.(interface{ SetVirtualOutput(bool) }); ok { | |
| 129 | + | v.SetVirtualOutput(true) | |
| 130 | + | } | |
| 131 | + | if v, ok := r.(interface{ SetHDRReadback(bool) }); ok && readback { | |
| 132 | + | v.SetHDRReadback(true) | |
| 133 | + | } | |
| 134 | + | return r, nil | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 103 | 138 | func joinErr(a, b error) error { | |
| 104 | 139 | if a != nil { | |
| 105 | 140 | return a | |
| @@ -11,20 +11,36 @@ import ( | |||||
| 11 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | + | // headlessOptions are the settings of the virtual outputs. | |||
| 15 | + | type headlessOptions struct { | |||
| 16 | + | sizes [][2]int | |||
| 17 | + | shots string | |||
| 18 | + | hdr bool | |||
| 19 | + | raw bool // also write latest-pq.png (headless.Options.RawHDR) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 14 | // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ... | 22 | // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ... | ||
| 15 | // With several outputs, screenshots go to a subdirectory per output. Modes | 23 | // With several outputs, screenshots go to a subdirectory per output. Modes | ||
| 16 | // are fixed, so apply answers every configuration without backend work. | 24 | // are fixed, so apply answers every configuration without backend work. | ||
| 17 | - | func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | 25 | + | // |
| 26 | + | // The raw PQ dump (o.raw) needs readable HDR targets, set on these | |||
| 27 | + | // renderers only: the DRM path never reads them back. | |||
| 28 | + | // | |||
| 29 | + | // Virtual outputs have no display to list modifiers, so each renderer is | |||
| 30 | + | // told it drives one (vulkan.Renderer.SetVirtualOutput); without it a real | |||
| 31 | + | // GPU refuses the HDR targets and the output falls back to SDR. | |||
| 32 | + | func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | |||
| 33 | + | newRenderer = virtualRenderer(newRenderer, o.raw) | |||
| 18 | set := newOutputSet(ctx, ch.captured) | 34 | set := newOutputSet(ctx, ch.captured) | ||
| 19 | set.wireSecurity(ch) | 35 | set.wireSecurity(ch) | ||
| 20 | inventory := ports.OutputHeads{} | 36 | inventory := ports.OutputHeads{} | ||
| 21 | - | for i, size := range sizes { | 37 | + | for i, size := range o.sizes { |
| 22 | name := fmt.Sprintf("HEADLESS-%d", i+1) | 38 | name := fmt.Sprintf("HEADLESS-%d", i+1) | ||
| 23 | mode := ports.OutputMode{Width: size[0], Height: size[1], RefreshMilli: 60000, Preferred: true} | 39 | mode := ports.OutputMode{Width: size[0], Height: size[1], RefreshMilli: 60000, Preferred: true} | ||
| 24 | inventory.Heads = append(inventory.Heads, ports.OutputHead{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1], RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true}) | 40 | inventory.Heads = append(inventory.Heads, ports.OutputHead{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1], RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true}) | ||
| 25 | - | dir := shots | 41 | + | dir := o.shots |
| 26 | - | if dir != "" && len(sizes) > 1 { | 42 | + | if dir != "" && len(o.sizes) > 1 { |
| 27 | - | dir = filepath.Join(shots, name) | 43 | + | dir = filepath.Join(o.shots, name) |
| 28 | if err := os.MkdirAll(dir, 0o755); err != nil { | 44 | if err := os.MkdirAll(dir, 0o755); err != nil { | ||
| 29 | // Outputs already started stop and release their captures. | 45 | // Outputs already started stop and release their captures. | ||
| 30 | return joinErr(err, set.wait()) | 46 | return joinErr(err, set.wait()) | ||
| @@ -32,7 +48,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap | |||||
| 32 | } | 48 | } | ||
| 33 | cur := &headless.Cursor{} | 49 | cur := &headless.Cursor{} | ||
| 34 | curs.set(name, cur) | 50 | curs.set(name, cur) | ||
| 35 | - | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured} | 51 | + | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured} |
| 36 | started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error { | 52 | started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error { | ||
| 37 | opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance | 53 | opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance | ||
| 38 | return headless.Run(octx, opts, sc, cc, cu, cap) | 54 | return headless.Run(octx, opts, sc, cc, cu, cap) | ||
| @@ -52,7 +68,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap | |||||
| 52 | } | 68 | } | ||
| 53 | apply.heads(inventory) | 69 | apply.heads(inventory) | ||
| 54 | // The pointer starts centred on the first output, like libinput's. | 70 | // The pointer starts centred on the first output, like libinput's. | ||
| 55 | - | curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2, false) | 71 | + | curs.move("HEADLESS-1", float64(o.sizes[0][0])/2, float64(o.sizes[0][1])/2, false) |
| 56 | for { | 72 | for { | ||
| 57 | configs, configNext := apply.configOut(ch.configured), apply.config | 73 | configs, configNext := apply.configOut(ch.configured), apply.config | ||
| 58 | replies, replyNext := apply.replyOut(ch.replies) | 74 | replies, replyNext := apply.replyOut(ch.replies) | ||
| @@ -100,6 +116,25 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap | |||||
| 100 | } | 116 | } | ||
| 101 | } | 117 | } | ||
| 102 | 118 | ||||
| 119 | + | // virtualRenderer wraps a renderer factory for headless outputs. Vulkan is | |||
| 120 | + | // the only implementation of these settings; they are not on the port. | |||
| 121 | + | // readback makes the HDR targets readable (HDRPixels). | |||
| 122 | + | func virtualRenderer(newRenderer func(w, h int) (ports.Renderer, error), readback bool) func(w, h int) (ports.Renderer, error) { | |||
| 123 | + | return func(w, h int) (ports.Renderer, error) { | |||
| 124 | + | r, err := newRenderer(w, h) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return nil, err | |||
| 127 | + | } | |||
| 128 | + | if v, ok := r.(interface{ SetVirtualOutput(bool) }); ok { | |||
| 129 | + | v.SetVirtualOutput(true) | |||
| 130 | + | } | |||
| 131 | + | if v, ok := r.(interface{ SetHDRReadback(bool) }); ok && readback { | |||
| 132 | + | v.SetHDRReadback(true) | |||
| 133 | + | } | |||
| 134 | + | return r, nil | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 103 | func joinErr(a, b error) error { | 138 | func joinErr(a, b error) error { | ||
| 104 | if a != nil { | 139 | if a != nil { | ||
| 105 | return a | 140 | return a | ||
A internal/app/headless_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/vulkan" | |
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // The headless renderer factory tells a real renderer it drives a virtual | |
| 11 | + | // output: without it ExportTargets(1, nil) refuses HDR targets on a real GPU | |
| 12 | + | // and a headless output falls back to SDR. Skips without Vulkan or without a | |
| 13 | + | // device that exports dmabufs (lavapipe in CI). | |
| 14 | + | func TestVirtualRendererExportsHDRTargets(t *testing.T) { | |
| 15 | + | newRenderer := func(w, h int) (ports.Renderer, error) { | |
| 16 | + | r, err := vulkan.New(w, h) | |
| 17 | + | if err != nil { | |
| 18 | + | return nil, err | |
| 19 | + | } | |
| 20 | + | return r, nil | |
| 21 | + | } | |
| 22 | + | r, err := virtualRenderer(newRenderer, false)(64, 16) | |
| 23 | + | if err != nil { | |
| 24 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 25 | + | } | |
| 26 | + | defer r.Close() | |
| 27 | + | // A device that cannot export at all says nothing about the wiring. | |
| 28 | + | sdr, err := r.ExportTargets(1, nil) | |
| 29 | + | if err != nil { | |
| 30 | + | t.Skipf("no exportable target: %v", err) | |
| 31 | + | } | |
| 32 | + | for _, b := range sdr { | |
| 33 | + | for _, p := range b.Planes { | |
| 34 | + | _ = p.File.Close() | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | r.SetHDR(203) | |
| 38 | + | bufs, err := r.ExportTargets(1, nil) | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatalf("virtual HDR export: %v", err) | |
| 41 | + | } | |
| 42 | + | for _, b := range bufs { | |
| 43 | + | for _, p := range b.Planes { | |
| 44 | + | _ = p.File.Close() | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/vulkan" | |||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // The headless renderer factory tells a real renderer it drives a virtual | |||
| 11 | + | // output: without it ExportTargets(1, nil) refuses HDR targets on a real GPU | |||
| 12 | + | // and a headless output falls back to SDR. Skips without Vulkan or without a | |||
| 13 | + | // device that exports dmabufs (lavapipe in CI). | |||
| 14 | + | func TestVirtualRendererExportsHDRTargets(t *testing.T) { | |||
| 15 | + | newRenderer := func(w, h int) (ports.Renderer, error) { | |||
| 16 | + | r, err := vulkan.New(w, h) | |||
| 17 | + | if err != nil { | |||
| 18 | + | return nil, err | |||
| 19 | + | } | |||
| 20 | + | return r, nil | |||
| 21 | + | } | |||
| 22 | + | r, err := virtualRenderer(newRenderer, false)(64, 16) | |||
| 23 | + | if err != nil { | |||
| 24 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 25 | + | } | |||
| 26 | + | defer r.Close() | |||
| 27 | + | // A device that cannot export at all says nothing about the wiring. | |||
| 28 | + | sdr, err := r.ExportTargets(1, nil) | |||
| 29 | + | if err != nil { | |||
| 30 | + | t.Skipf("no exportable target: %v", err) | |||
| 31 | + | } | |||
| 32 | + | for _, b := range sdr { | |||
| 33 | + | for _, p := range b.Planes { | |||
| 34 | + | _ = p.File.Close() | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | r.SetHDR(203) | |||
| 38 | + | bufs, err := r.ExportTargets(1, nil) | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatalf("virtual HDR export: %v", err) | |||
| 41 | + | } | |||
| 42 | + | for _, b := range bufs { | |||
| 43 | + | for _, p := range b.Planes { | |||
| 44 | + | _ = p.File.Close() | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | } | |||
M internal/mocks/ports/devices_mock.go +46 -0
| @@ -452,6 +452,52 @@ func (_c *MockRenderer_ExportTargets_Call) RunAndReturn(run func(n int, modifier | |||
| 452 | 452 | return _c | |
| 453 | 453 | } | |
| 454 | 454 | ||
| 455 | + | // HDRPixels provides a mock function for the type MockRenderer | |
| 456 | + | func (_mock *MockRenderer) HDRPixels() *image.RGBA64 { | |
| 457 | + | ret := _mock.Called() | |
| 458 | + | ||
| 459 | + | if len(ret) == 0 { | |
| 460 | + | panic("no return value specified for HDRPixels") | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | var r0 *image.RGBA64 | |
| 464 | + | if returnFunc, ok := ret.Get(0).(func() *image.RGBA64); ok { | |
| 465 | + | r0 = returnFunc() | |
| 466 | + | } else { | |
| 467 | + | if ret.Get(0) != nil { | |
| 468 | + | r0 = ret.Get(0).(*image.RGBA64) | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | return r0 | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | // MockRenderer_HDRPixels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HDRPixels' | |
| 475 | + | type MockRenderer_HDRPixels_Call struct { | |
| 476 | + | *mock.Call | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | // HDRPixels is a helper method to define mock.On call | |
| 480 | + | func (_e *MockRenderer_Expecter) HDRPixels() *MockRenderer_HDRPixels_Call { | |
| 481 | + | return &MockRenderer_HDRPixels_Call{Call: _e.mock.On("HDRPixels")} | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | func (_c *MockRenderer_HDRPixels_Call) Run(run func()) *MockRenderer_HDRPixels_Call { | |
| 485 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 486 | + | run() | |
| 487 | + | }) | |
| 488 | + | return _c | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | func (_c *MockRenderer_HDRPixels_Call) Return(rGBA64 *image.RGBA64) *MockRenderer_HDRPixels_Call { | |
| 492 | + | _c.Call.Return(rGBA64) | |
| 493 | + | return _c | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | func (_c *MockRenderer_HDRPixels_Call) RunAndReturn(run func() *image.RGBA64) *MockRenderer_HDRPixels_Call { | |
| 497 | + | _c.Call.Return(run) | |
| 498 | + | return _c | |
| 499 | + | } | |
| 500 | + | ||
| 455 | 501 | // Pixels provides a mock function for the type MockRenderer | |
| 456 | 502 | func (_mock *MockRenderer) Pixels() *image.RGBA { | |
| 457 | 503 | ret := _mock.Called() | |
| @@ -452,6 +452,52 @@ func (_c *MockRenderer_ExportTargets_Call) RunAndReturn(run func(n int, modifier | |||||
| 452 | return _c | 452 | return _c | ||
| 453 | } | 453 | } | ||
| 454 | 454 | ||||
| 455 | + | // HDRPixels provides a mock function for the type MockRenderer | |||
| 456 | + | func (_mock *MockRenderer) HDRPixels() *image.RGBA64 { | |||
| 457 | + | ret := _mock.Called() | |||
| 458 | + | ||||
| 459 | + | if len(ret) == 0 { | |||
| 460 | + | panic("no return value specified for HDRPixels") | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | var r0 *image.RGBA64 | |||
| 464 | + | if returnFunc, ok := ret.Get(0).(func() *image.RGBA64); ok { | |||
| 465 | + | r0 = returnFunc() | |||
| 466 | + | } else { | |||
| 467 | + | if ret.Get(0) != nil { | |||
| 468 | + | r0 = ret.Get(0).(*image.RGBA64) | |||
| 469 | + | } | |||
| 470 | + | } | |||
| 471 | + | return r0 | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | // MockRenderer_HDRPixels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HDRPixels' | |||
| 475 | + | type MockRenderer_HDRPixels_Call struct { | |||
| 476 | + | *mock.Call | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | // HDRPixels is a helper method to define mock.On call | |||
| 480 | + | func (_e *MockRenderer_Expecter) HDRPixels() *MockRenderer_HDRPixels_Call { | |||
| 481 | + | return &MockRenderer_HDRPixels_Call{Call: _e.mock.On("HDRPixels")} | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | func (_c *MockRenderer_HDRPixels_Call) Run(run func()) *MockRenderer_HDRPixels_Call { | |||
| 485 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 486 | + | run() | |||
| 487 | + | }) | |||
| 488 | + | return _c | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | func (_c *MockRenderer_HDRPixels_Call) Return(rGBA64 *image.RGBA64) *MockRenderer_HDRPixels_Call { | |||
| 492 | + | _c.Call.Return(rGBA64) | |||
| 493 | + | return _c | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | func (_c *MockRenderer_HDRPixels_Call) RunAndReturn(run func() *image.RGBA64) *MockRenderer_HDRPixels_Call { | |||
| 497 | + | _c.Call.Return(run) | |||
| 498 | + | return _c | |||
| 499 | + | } | |||
| 500 | + | ||||
| 455 | // Pixels provides a mock function for the type MockRenderer | 501 | // Pixels provides a mock function for the type MockRenderer | ||
| 456 | func (_mock *MockRenderer) Pixels() *image.RGBA { | 502 | func (_mock *MockRenderer) Pixels() *image.RGBA { | ||
| 457 | ret := _mock.Called() | 503 | ret := _mock.Called() | ||
M internal/ports/devices.go +5 -0
| @@ -62,6 +62,11 @@ type Renderer interface { | |||
| 62 | 62 | // waits for the GPU: debug only. Nil when it cannot (every capture | |
| 63 | 63 | // slot leased, or the copy failed). | |
| 64 | 64 | Pixels() *image.RGBA | |
| 65 | + | // HDRPixels reads the last HDR frame's XRGB2101010 target back, each | |
| 66 | + | // 10-bit code scaled to 16 bits (c<<6 | c>>4), A = 0xffff. It waits for | |
| 67 | + | // the GPU: debug only. Nil when the output is SDR, the target is not | |
| 68 | + | // readable, a slot is busy or the copy failed. | |
| 69 | + | HDRPixels() *image.RGBA64 | |
| 65 | 70 | // BeginCapture submits a GPU copy of the last rendered frame into a | |
| 66 | 71 | // free slot and returns without waiting. ErrCaptureBusy when every | |
| 67 | 72 | // slot is leased; another error when the copy cannot be tracked (no | |
| @@ -62,6 +62,11 @@ type Renderer interface { | |||||
| 62 | // waits for the GPU: debug only. Nil when it cannot (every capture | 62 | // waits for the GPU: debug only. Nil when it cannot (every capture | ||
| 63 | // slot leased, or the copy failed). | 63 | // slot leased, or the copy failed). | ||
| 64 | Pixels() *image.RGBA | 64 | Pixels() *image.RGBA | ||
| 65 | + | // HDRPixels reads the last HDR frame's XRGB2101010 target back, each | |||
| 66 | + | // 10-bit code scaled to 16 bits (c<<6 | c>>4), A = 0xffff. It waits for | |||
| 67 | + | // the GPU: debug only. Nil when the output is SDR, the target is not | |||
| 68 | + | // readable, a slot is busy or the copy failed. | |||
| 69 | + | HDRPixels() *image.RGBA64 | |||
| 65 | // BeginCapture submits a GPU copy of the last rendered frame into a | 70 | // BeginCapture submits a GPU copy of the last rendered frame into a | ||
| 66 | // free slot and returns without waiting. ErrCaptureBusy when every | 71 | // free slot and returns without waiting. ErrCaptureBusy when every | ||
| 67 | // slot is leased; another error when the copy cannot be tracked (no | 72 | // slot is leased; another error when the copy cannot be tracked (no | ||