From e7eb8d79715b0001ee64e550b355ee74ec73e5a1 Mon Sep 17 00:00:00 2001 From: mskutin <11622907+mskutin@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:47:43 +0800 Subject: [PATCH] fix: select video areas within captured windows --- README.md | 2 +- control/Cargo.lock | 2 +- control/Cargo.toml | 2 +- docs/capture-sources.md | 12 +- docs/release-notes.md | 10 +- package.json | 2 +- scripts/ui-test.cjs | 121 ++++++++++++- src-tauri/Cargo.lock | 4 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/capture.rs | 59 +++++- src-tauri/src/capture_source.rs | 153 +++++++++++++--- src-tauri/src/capture_windows.rs | 302 ++++++++++++++++++++++++++----- src-tauri/src/engine.rs | 42 +++++ src-tauri/src/lib.rs | 15 ++ src-tauri/src/lighting.rs | 133 +++++++++++++- src-tauri/src/sharing.rs | 18 +- src-tauri/tauri.conf.json | 2 +- src/ui/capture-sources.js | 213 +++++++++++++++------- src/ui/index.html | 29 ++- src/ui/style.css | 7 + 20 files changed, 956 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index cf9c3ca..d6486cf 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Each mode remembers its tuning. Browsing views and previewing effects does not s | Mode | What you can do | | --- | --- | | **Color** | Choose a solid color with the wheel, RGB/hex input, palettes, or approximate white temperature. Tune bias lighting with per-channel gains and saved calibration notes. | -| **Screen** | Sample a full display, a selected window, or a rectangular region. Adjust black-bar detection, RGB balance, gamma, and saturation. | +| **Screen** | Sample a full display, a selected window, or a rectangular region. Preview a window and select just its video area to exclude browser toolbars and white margins. Adjust black-bar detection, RGB balance, gamma, and saturation. | | **Music** | React to computer audio or a microphone with spectrum, bass, level, and wave styles, plus beat response and gain/noise-gate/release tuning. | | **Blend** | Combine screen colors with audio-reactive brightness; keep the available source running if the other fails. | | **Effects** | Run Color flow, Aurora, Breathe, Comet, and Ember, or create layered effects with gradients, waves, breathing, and color sequences. | diff --git a/control/Cargo.lock b/control/Cargo.lock index a6029d1..9795c29 100644 --- a/control/Cargo.lock +++ b/control/Cargo.lock @@ -37,7 +37,7 @@ dependencies = [ [[package]] name = "dynamiclight-control" -version = "0.16.1" +version = "0.16.2" dependencies = [ "dirs", "getrandom 0.3.4", diff --git a/control/Cargo.toml b/control/Cargo.toml index 6fbdd51..361ee1d 100644 --- a/control/Cargo.toml +++ b/control/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dynamiclight-control" -version = "0.16.1" +version = "0.16.2" edition = "2021" license = "MIT" rust-version = "1.99" diff --git a/docs/capture-sources.md b/docs/capture-sources.md index cb80564..de7eb5e 100644 --- a/docs/capture-sources.md +++ b/docs/capture-sources.md @@ -1,12 +1,18 @@ # Window and region capture -In Screen or Blend, choose **Capture source**. Full display keeps the existing display selection. Rectangle captures a selected portion of that display; drag its outline or enter left/top/width/height percentages. The diagram previews geometry without taking a screenshot. Once started, the regular LED preview shows sampled edge colors. +In Screen or Blend, choose **Capture source**. Full display keeps the existing display selection. Rectangle captures a selected portion of that display; drag its outline or enter left/top/width/height percentages. The display diagram previews geometry without taking a screenshot. Once started, the regular LED preview shows sampled edge colors. + +For a video playing inside a browser page, choose **Application window**, refresh the window list, and select the browser. Click **Take window preview**, then drag a rectangle around the video itself. **Apply source**, then **Start / Retry**. The lights sample the selected area's edges; including white page margins or toolbars can produce white LEDs even when the video is orange. Alternatively, enable **Select an area within this window** and enter percentages without taking a preview. Uncheck it to use the whole window, for example with fullscreen video. + +The preview button explicitly captures one still image of the chosen window. It is downscaled in memory, displayed only in this dialog, and discarded on close or source change; it is never saved, exported, or sent to the network. Opening the dialog and refreshing window names do not take screenshots. Taking a preview does not start LED output or authorize ongoing capture. Windows uses the same capture backend for the preview and lighting. + +Window capture follows the visible contents of the browser window, not a pinned tab or a detected video element. Switching tabs changes what is captured. A selected area keeps its relative position and size when the window resizes; take another preview and reselect the video if the page layout changes. Black-bar detection can trim dark letterboxing inside the selected area; it does not remove white browser UI. Window selection lists available applications and titles locally. Choose **Refresh windows**, select the intended window, and Apply. Applying a new source stops capture; Start / Retry is required to capture it. Movement and resizing are followed. Minimization, closure, or a revoked source interrupts screen capture. Blend can continue with its available audio source. Restore/reselect the window and Retry; opt-in capture recovery retains its usual behavior. Window IDs are bound to their process and the current DynamicLight session. Re-select a window after restarting DynamicLight. This prevents a saved window ID being silently reused for another application. Local profiles retain source bindings; exported scenes exclude window identities, titles, and region/display bindings. -Rectangle coordinates are normalized to the selected display and rounded out to physical pixel boundaries. The rectangle therefore retains its relative coverage at different scale factors. Display resolution/rearrangement still uses the existing source-change recovery policy. The selected physical display determines LED edge allocation; a window or rectangle changes sampling coordinates without reassigning LEDs to different physical edges. +Rectangle coordinates are normalized to the selected display, or to the selected window for a window area, and rounded out to physical pixel boundaries. The rectangle therefore retains its relative coverage at different scale factors. Display resolution/rearrangement still uses the existing source-change recovery policy. The selected physical display determines LED edge allocation; a window or rectangle changes sampling coordinates without reassigning LEDs to different physical edges. ## Platform behavior @@ -16,4 +22,4 @@ Rectangle coordinates are normalized to the selected display and rounded out to Protected video, DRM, secure desktops, permission restrictions, and unsupported GPU paths can yield black or unavailable frames. The application does not bypass these protections. Captured pixels are sampled locally; only RGB lighting colors reach USB. Window titles and source IDs are excluded from diagnostic exports and the automation status API. -Native validation includes rectangle rounding at multiple resolutions, source-change consent revocation, and an interactive Windows test that queries the audio device before repeatedly starting/stopping capture of its own generated fixture window, resizes it, then verifies capture terminates on closure. No USB or audio recording is used by that test. Each Windows lighting worker initializes the multithreaded Windows Runtime before audio discovery and retains it across capture sessions, preventing the audio library's default single-threaded COM initialization from blocking later window capture with `0x80010106`. A separate regression test checks that worker lifetime without opening capture or audio devices. Physical multi-DPI, protected video, macOS and Linux window behavior remain in the platform validation trackers. +Native validation includes rectangle rounding at multiple resolutions, padded RGBA/BGRA sampling, source-change consent revocation, and export privacy. An interactive Windows test queries the audio device before repeatedly starting/stopping capture of its own generated fixture window. It reproduces white margins around an orange video, verifies selected-area output and one-shot preview colors, checks live red-to-blue changes, grows and shrinks the window, then verifies capture terminates on closure. No USB or audio recording is used by that test. Each Windows lighting worker initializes the multithreaded Windows Runtime before audio discovery and retains it across capture sessions, preventing the audio library's default single-threaded COM initialization from blocking later window capture with `0x80010106`. A separate regression test checks that worker lifetime without opening capture or audio devices. Physical multi-DPI, protected video, macOS and Linux window behavior remain in the platform validation trackers. diff --git a/docs/release-notes.md b/docs/release-notes.md index 6a1c3db..2420cf3 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,10 +1,12 @@ -DynamicLight 0.16.1 fixes Windows selected-window capture failing with “Cannot change thread mode after it is set” (0x80010106) after audio initialization. +DynamicLight 0.16.2 adds video-area selection inside captured windows. A video playing inside a browser page can now drive the LEDs without white page margins and browser toolbars dominating the colors. -Each lighting worker now initializes the Windows Runtime before audio discovery and keeps it initialized across capture sessions. Switching between Music, Screen, and Blend no longer creates that thread-mode conflict. Unrelated capture errors also no longer incorrectly suggest that your Windows version is too old. +In Screen or Blend, open **Capture source → Application window**, select the browser, and click **Take window preview**. Drag around the video, then **Apply source → Start / Retry**. You can also select an area using percentages without taking a preview. Uncheck the area option for whole-window or fullscreen capture. The window follows its visible tab; after a page layout change, reselect the video area. -Validation includes 100 passing native tests, a regression test verified to fail before the fix, and a real Windows capture fixture covering audio-device discovery, repeated capture startup/teardown, resizing, and window closure. The fixture records only its own generated window; it does not use USB or record audio. +Previews are explicit, one-shot captures kept in memory while the source dialog is open. They do not start the lights or ongoing capture. Source bindings and crop coordinates stay local and are excluded from portable scene exports. Windows previews use the same capture backend as lighting, and undefined texture padding is excluded from both. -All features from 0.16.0 remain available, including Studio effects and zones, RGB recording/replay, routines, independent controllers, game telemetry, declarative plugins, the CLI, and paired HTTPS phone controls. +Validation includes native sampling and privacy tests, browser UI regression tests, and a real Windows capture fixture that reproduces white margins around an orange video, verifies the cropped colors, follows live color changes, resizes in both directions, and stops on closure. The fixture captures only its own generated window; it does not use USB or record audio. + +The Windows capture initialization fix from 0.16.1 remains included, along with Studio effects and zones, RGB recording/replay, routines, independent controllers, game telemetry, declarative plugins, the CLI, and paired HTTPS phone controls. Use **Settings → App updates → Check for updates → Install and restart** on Windows, writable macOS app installations, and Linux AppImage. Debian packages use the terminal installer or package manager. After restarting, select the intended window again and explicitly start capture. Windows selected-window capture requires Windows 10 1903 or newer. diff --git a/package.json b/package.json index c5d13f3..43e0e07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dynamiclight", - "version": "0.16.1", + "version": "0.16.2", "private": true, "license": "MIT", "author": "mskutin", diff --git a/scripts/ui-test.cjs b/scripts/ui-test.cjs index 4b29008..b5078fe 100644 --- a/scripts/ui-test.cjs +++ b/scripts/ui-test.cjs @@ -225,6 +225,7 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || "playwright"); progress: null, }, updateInstalls: 0, + windowPreviewCalls: [], state, calls, emitEvent: (name, payload) => listeners[name]?.({ payload }), @@ -369,7 +370,32 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || "playwright"); session: "test", }, }, + { + name: "Other window", + source: { + kind: "window", + id: 789, + pid: 456, + app: "Player", + session: "test", + }, + }, ]; + if (cmd === "capture_window_preview") { + fixture.windowPreviewCalls.push(structuredClone(args.source)); + if (fixture.deferWindowPreview) + await new Promise((resolve) => { + fixture.resolveWindowPreview = resolve; + }); + if (fixture.failWindowPreview) + throw new Error("Window preview unavailable. Enter an area manually."); + const width = 40, height = 30, rgba = []; + for (let y = 0; y < height; ++y) + for (let x = 0; x < width; ++x) + rgba.push(...(x >= 10 && x < 30 && y >= 6 && y < 24 + ? [240, 90, 20, 255] : [255, 255, 255, 255])); + return { width, height, rgba }; + } if (cmd === "studio_library") { if (args.next) fixture.studioLibrary = args.next; return structuredClone( @@ -1863,14 +1889,103 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || "playwright"); await page.locator("#source-kind").selectOption("window"); await page.locator("#source-refresh").click(); await page.locator("#source-windows").selectOption("0"); + await page.locator("#source-crop").check(); + for (const [key, value] of Object.entries({ left: "10", top: "10", width: "80", height: "80" })) + await page.locator("#source-" + key).fill(value); + assert.equal( + await page.evaluate(() => fixture.windowPreviewCalls.length), + 0, + "Opening, refreshing, selecting a window and editing its area never capture an image", + ); + await page.locator("#source-snapshot").click(); + await page.waitForFunction(() => document.querySelector("#source-preview").width === 40); + assert.deepEqual( + await page.locator("#source-preview").evaluate((canvas) => { + const ctx = canvas.getContext("2d"); + return [Array.from(ctx.getImageData(1, 1, 1, 1).data), + Array.from(ctx.getImageData(20, 15, 1, 1).data)]; + }), + [[255, 255, 255, 255], [240, 90, 20, 255]], + "Explicit preview displays the video colors inside the white browser page", + ); + await page.locator("#source-preview").scrollIntoViewIfNeeded(); + const previewBox = await page.locator("#source-preview").boundingBox(); + await page.mouse.move(previewBox.x + previewBox.width * .25, previewBox.y + previewBox.height * .2); + await page.mouse.down(); + await page.mouse.move(previewBox.x + previewBox.width * .75, previewBox.y + previewBox.height * .8, { steps: 5 }); + await page.mouse.up(); + const videoArea = { left: 250, top: 200, width: 500, height: 600 }; + const readAreaFields = () => page.evaluate(() => Object.fromEntries( + ["left", "top", "width", "height"].map((key) => [key, Number(document.querySelector("#source-" + key).value) * 10]), + )); + assert.deepEqual(await readAreaFields(), videoArea, "Dragging stores normalized window coordinates"); + await page.screenshot({ path: ".local/ui-window-video-area.png" }); + await page.locator("#source-refresh").click(); + await page.waitForFunction(() => !document.querySelector("#source-refresh").disabled); + assert.equal(await page.locator("#source-windows").inputValue(), "0"); + assert.equal(await page.locator("#source-crop").isChecked(), true); + assert.deepEqual(await readAreaFields(), videoArea, "Refreshing preserves the selected video area"); await page.locator("#source-apply").click(); await page.waitForFunction( () => !document.querySelector("#source-dialog").open, ); - assert.equal( - await page.evaluate(() => fixture.state.settings.captureSource.id), - 123, + assert.deepEqual( + await page.evaluate(() => fixture.state.settings.captureSource), + { kind: "window", id: 123, pid: 456, app: "Player", session: "test", crop: videoArea }, ); + await page.locator("#source-open").click(); + await page.waitForFunction(() => document.querySelector("#source-dialog").open); + assert.equal(await page.locator("#source-windows").inputValue(), "0"); + assert.equal(await page.locator("#source-crop").isChecked(), true); + assert.deepEqual(await readAreaFields(), videoArea, "Reopening restores the saved area"); + assert.equal(await page.evaluate(() => fixture.windowPreviewCalls.length), 1); + assert.equal(await page.locator("#source-preview").evaluate((canvas) => canvas.width), 600, + "Reopening does not retain the private window image"); + await page.setViewportSize({ width: 540, height: 780 }); + assert(await page.locator("#source-dialog").evaluate((dialog) => dialog.scrollWidth <= dialog.clientWidth), + "Window video area controls fit a narrow window"); + await page.screenshot({ path: ".local/ui-window-video-area-narrow.png" }); + await page.setViewportSize({ width: 1280, height: 1000 }); + + // An in-flight native result must never reveal an old source after selection changes or closing. + await page.evaluate(() => { fixture.deferWindowPreview = true; }); + await page.locator("#source-snapshot").click(); + await page.waitForFunction(() => fixture.windowPreviewCalls.length === 2); + await page.locator("#source-windows").selectOption("1"); + await page.evaluate(async () => { + fixture.resolveWindowPreview(); + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + assert.equal(await page.locator("#source-preview").evaluate((canvas) => canvas.width), 600, + "Changing windows discards a late preview"); + assert.equal(await page.locator("#source-crop").isChecked(), false); + await page.locator("#source-snapshot").click(); + await page.waitForFunction(() => fixture.windowPreviewCalls.length === 3); + await page.locator("#source-cancel").click(); + await page.evaluate(async () => { + fixture.resolveWindowPreview(); + await new Promise((resolve) => requestAnimationFrame(resolve)); + fixture.deferWindowPreview = false; + }); + await page.locator("#source-open").click(); + await page.waitForFunction(() => document.querySelector("#source-dialog").open); + assert.equal(await page.locator("#source-preview").evaluate((canvas) => canvas.width), 600, + "Closing discards a late preview, even after reopening"); + assert.deepEqual(await readAreaFields(), videoArea, "Cancel preserves the saved area"); + await page.evaluate(() => { fixture.failWindowPreview = true; }); + await page.locator("#source-snapshot").click(); + await page.waitForFunction(() => /Enter an area manually/.test(document.querySelector("#source-status").textContent)); + await page.locator("#source-left").fill("20"); + await page.locator("#source-apply").click(); + await page.waitForFunction(() => !document.querySelector("#source-dialog").open); + assert.equal(await page.evaluate(() => fixture.state.settings.captureSource.crop.left), 200, + "Manual area selection remains usable when preview fails"); + await page.locator("#source-open").click(); + await page.locator("#source-crop").uncheck(); + await page.locator("#source-apply").click(); + await page.waitForFunction(() => !document.querySelector("#source-dialog").open); + assert.equal(await page.evaluate(() => fixture.state.settings.captureSource.crop), undefined, + "Disabling the area returns to the whole selected window"); await page.locator('[data-view="settings"]').click(); // Appearance preferences cover every supported palette and both color schemes. await page.setViewportSize({ width: 1120, height: 780 }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d46de36..2872610 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1227,7 +1227,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "dynamiclight" -version = "0.16.1" +version = "0.16.2" dependencies = [ "cc", "chrono", @@ -1256,7 +1256,7 @@ dependencies = [ [[package]] name = "dynamiclight-control" -version = "0.16.1" +version = "0.16.2" dependencies = [ "dirs 6.0.0", "getrandom 0.3.4", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7229387..2c30c12 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dynamiclight" -version = "0.16.1" +version = "0.16.2" description = "Local USB monitor backlight controller" authors = ["mskutin"] license = "MIT" diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs index b9bdd61..5a07eb5 100644 --- a/src-tauri/src/capture.rs +++ b/src-tauri/src/capture.rs @@ -11,6 +11,63 @@ pub struct Screen { pub height: u32, pub primary: bool, } +#[derive(Serialize)] +pub struct WindowPreview { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} +/// Called only after an explicit preview request. Pixels stay in memory and +/// never become a portable profile or diagnostic artifact. +pub fn window_preview(source: crate::capture_source::Source) -> Result { + source.validate()?; + check_session()?; + let window = source.window()?; + #[cfg(target_os = "windows")] + { + crate::capture_windows::WindowCapture::new(window.id().map_err(|e| e.to_string())?)? + .preview() + } + #[cfg(not(target_os = "windows"))] + { + let requested_width = window.width().map_err(|e| e.to_string())?; + let requested_height = window.height().map_err(|e| e.to_string())?; + if requested_width == 0 + || requested_height == 0 + || u64::from(requested_width) * u64::from(requested_height) > 40_000_000 + { + return Err("Window has no pixels or exceeds the 40 megapixel capture limit".into()); + } + let image = window.capture_image().map_err(|e| e.to_string())?; + let (source_width, source_height) = (image.width(), image.height()); + if source_width == 0 + || source_height == 0 + || u64::from(source_width) * u64::from(source_height) > 40_000_000 + { + return Err("Window has no pixels or exceeds the 40 megapixel capture limit".into()); + } + let scale = (600.0 / f64::from(source_width)) + .min(340.0 / f64::from(source_height)) + .min(1.0); + let width = (f64::from(source_width) * scale).floor().max(1.0) as u32; + let height = (f64::from(source_height) * scale).floor().max(1.0) as u32; + let mut rgba = vec![0; width as usize * height as usize * 4]; + for y in 0..height { + let source_y = u64::from(y) * u64::from(source_height) / u64::from(height); + for x in 0..width { + let source_x = u64::from(x) * u64::from(source_width) / u64::from(width); + let input = (source_y * u64::from(source_width) + source_x) as usize * 4; + let output = (y * width + x) as usize * 4; + rgba[output..output + 4].copy_from_slice(&image.as_raw()[input..input + 4]); + } + } + Ok(WindowPreview { + width, + height, + rgba, + }) + } +} pub fn screens() -> Result, String> { check_session()?; Monitor::all() @@ -154,7 +211,7 @@ impl ScreenCapture { { let image = window.capture_image().map_err(|e| e.to_string())?; self.dimensions = (image.width(), image.height()); - Ok(lighting::sample_geometry( + Ok(lighting::sample_window( image.as_raw(), ( image.width(), diff --git a/src-tauri/src/capture_source.rs b/src-tauri/src/capture_source.rs index e36ee27..3886af0 100644 --- a/src-tauri/src/capture_source.rs +++ b/src-tauri/src/capture_source.rs @@ -13,6 +13,35 @@ static SESSION: std::sync::LazyLock = std::sync::LazyLock::new(|| { ) }); +/// Coordinates relative to a source, in thousandths of its width and height. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Rectangle { + pub left: u16, + pub top: u16, + pub width: u16, + pub height: u16, +} +impl Rectangle { + fn validate(&self) -> Result<(), String> { + if self.width == 0 + || self.height == 0 + || u32::from(self.left) + u32::from(self.width) > 1000 + || u32::from(self.top) + u32::from(self.height) > 1000 + { + return Err("Choose a nonempty rectangle inside the capture source".into()); + } + Ok(()) + } + fn pixels(&self, width: u32, height: u32) -> (u32, u32, u32, u32) { + let x = u64::from(width) * u64::from(self.left) / 1000; + let y = u64::from(height) * u64::from(self.top) / 1000; + let right = (u64::from(width) * u64::from(self.left + self.width)).div_ceil(1000); + let bottom = (u64::from(height) * u64::from(self.top + self.height)).div_ceil(1000); + (x as u32, y as u32, (right - x) as u32, (bottom - y) as u32) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] pub enum Source { @@ -28,6 +57,8 @@ pub enum Source { pid: u32, app: String, session: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + crop: Option, }, } impl Default for Source { @@ -43,20 +74,24 @@ impl Source { top, width, height, - } if *width == 0 - || *height == 0 - || u32::from(*left) + u32::from(*width) > 1000 - || u32::from(*top) + u32::from(*height) > 1000 => - { - Err("Choose a nonempty rectangle inside the display".into()) + } => Rectangle { + left: *left, + top: *top, + width: *width, + height: *height, } + .validate(), Self::Window { id, pid, app, session, - } if *id == 0 || *pid == 0 || app.len() > 500 || session.len() > 80 => { - Err("Invalid selected window".into()) + crop, + } => { + if *id == 0 || *pid == 0 || app.len() > 500 || session.len() > 80 { + return Err("Invalid selected window".into()); + } + crop.as_ref().map_or(Ok(()), Rectangle::validate) } _ => Ok(()), } @@ -66,21 +101,24 @@ impl Source { if width == 0 || height == 0 { return Err("Source has no pixels".into()); } - if let Self::Region { - left, - top, - width: w, - height: h, - } = self - { - let x = u64::from(width) * u64::from(*left) / 1000; - let y = u64::from(height) * u64::from(*top) / 1000; - let right = (u64::from(width) * u64::from(*left + *w)).div_ceil(1000); - let bottom = (u64::from(height) * u64::from(*top + *h)).div_ceil(1000); - Ok((x as u32, y as u32, (right - x) as u32, (bottom - y) as u32)) - } else { - Ok((0, 0, width, height)) - } + Ok(match self { + Self::Region { + left, + top, + width: w, + height: h, + } => Rectangle { + left: *left, + top: *top, + width: *w, + height: *h, + } + .pixels(width, height), + Self::Window { + crop: Some(crop), .. + } => crop.pixels(width, height), + _ => (0, 0, width, height), + }) } pub fn window(&self) -> Result { let Self::Window { @@ -88,6 +126,7 @@ impl Source { pid, app, session, + .. } = self else { return Err("Choose an application window".into()); @@ -134,6 +173,7 @@ pub fn windows() -> Result, String> { pid, app, session: SESSION.clone(), + crop: None, }, }); if choices.len() >= 200 { @@ -147,6 +187,70 @@ pub fn windows() -> Result, String> { mod tests { use super::*; #[test] + fn legacy_window_sources_and_display_regions_keep_their_format() { + let legacy = serde_json::json!({ + "kind": "window", "id": 1, "pid": 2, "app": "browser", "session": "local" + }); + let source: Source = serde_json::from_value(legacy.clone()).unwrap(); + assert_eq!(source.rectangle(1920, 1080).unwrap(), (0, 0, 1920, 1080)); + assert_eq!(serde_json::to_value(source).unwrap(), legacy); + let region = serde_json::json!({ + "kind": "region", "left": 250, "top": 0, "width": 500, "height": 1000 + }); + let source: Source = serde_json::from_value(region.clone()).unwrap(); + assert_eq!(serde_json::to_value(source).unwrap(), region); + } + #[test] + fn window_crops_are_bounded_and_scale_with_the_window() { + let mut source = Source::Window { + id: 1, + pid: 2, + app: "browser".into(), + session: "local".into(), + crop: Some(Rectangle { + left: 333, + top: 250, + width: 667, + height: 750, + }), + }; + for (w, h) in [(1, 1), (1920, 1080), (3840, 2160)] { + let (x, y, cw, ch) = source.rectangle(w, h).unwrap(); + assert_eq!((x + cw, y + ch), (w, h)); + assert!(cw > 0 && ch > 0); + } + let Source::Window { crop, .. } = &mut source else { + unreachable!() + }; + *crop = Some(Rectangle { + left: 999, + top: 0, + width: 2, + height: 1000, + }); + assert!(source.validate().is_err()); + let Source::Window { crop, .. } = &mut source else { + unreachable!() + }; + *crop = Some(Rectangle { + left: 0, + top: 0, + width: 1000, + height: 0, + }); + assert!(source.validate().is_err()); + let Source::Window { crop, .. } = &mut source else { + unreachable!() + }; + *crop = Some(Rectangle { + left: u16::MAX, + top: 0, + width: u16::MAX, + height: 1000, + }); + assert!(source.validate().is_err()); + } + #[test] fn region_rounding_is_bounded_at_all_display_scales() { for (w, h) in [(1, 1), (1920, 1080), (3840, 2160)] { let source = Source::Region { @@ -180,7 +284,8 @@ mod tests { id: 1, pid: 1, app: "app".into(), - session: "old-session".into() + session: "old-session".into(), + crop: None, } .window() .unwrap_err() diff --git a/src-tauri/src/capture_windows.rs b/src-tauri/src/capture_windows.rs index 2242c07..cb7eeb3 100644 --- a/src-tauri/src/capture_windows.rs +++ b/src-tauri/src/capture_windows.rs @@ -328,6 +328,7 @@ pub struct WindowCapture { staging: Option, size: SizeInt32, pixels: (u32, u32), + content: (u32, u32), crop: lighting::Crop, started: std::time::Instant, _apartment: Apartment, @@ -408,6 +409,7 @@ impl WindowCapture { staging: None, size, pixels: (0, 0), + content: (0, 0), crop: lighting::Crop::default(), started: std::time::Instant::now(), _apartment: apartment, @@ -420,6 +422,42 @@ impl WindowCapture { .map_err(|e| format!("Window capture ended: {e}. Select the source and Retry.")) } fn read(&mut self, settings: &Settings, aspect: f32) -> windows::core::Result> { + self.poll()?; + if let Some(colors) = self.mapped_frame(|pixels, frame, crop| { + lighting::sample_window(pixels, frame, settings, crop, aspect) + })? { + return Ok(colors); + } + if self.started.elapsed() > std::time::Duration::from_secs(5) { + return Err(windows::core::Error::from_hresult( + windows::Win32::Foundation::E_FAIL, + )); + } + Ok(vec![[0; 3]; settings.count]) + } + /// Explicit, short-lived preview of this same selected-window session. + /// The thumbnail stays in memory and retains the OS capture indicator. + pub fn preview(&mut self) -> Result { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + self.poll() + .map_err(|e| format!("Window preview unavailable: {e}"))?; + if let Some(preview) = self + .mapped_frame(|pixels, (width, height, stride, _), _| { + window_thumbnail(pixels, width, height, stride) + }) + .map_err(|e| format!("Window preview unavailable: {e}"))? + { + return Ok(preview); + } + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return Err("Window preview timed out. Restore the window and try again.".into()); + } + std::thread::sleep(remaining.min(std::time::Duration::from_millis(16))); + } + } + fn poll(&mut self) -> windows::core::Result<()> { let hwnd = windows::Win32::Foundation::HWND(self.window as *mut c_void); let mut pid = 0; unsafe { @@ -460,6 +498,18 @@ impl WindowCapture { windows::Win32::Foundation::E_INVALIDARG, )); } + // The pooled texture may be larger than this frame's content. + // Pixels outside ContentSize are undefined and must not reach + // the edge sampler or preview, including during a resize. + self.content = ( + (content.Width as u32).min(desc.Width), + (content.Height as u32).min(desc.Height), + ); + if self.content.0 == 0 || self.content.1 == 0 { + return Err(windows::core::Error::from_hresult( + windows::Win32::Foundation::E_INVALIDARG, + )); + } if self.staging.is_none() || self.pixels != (desc.Width, desc.Height) { self.pixels = (desc.Width, desc.Height); desc.Usage = D3D11_USAGE_STAGING; @@ -492,34 +542,69 @@ impl WindowCapture { Err(e) if e.code().is_ok() || e.code() == windows::Win32::Foundation::E_POINTER => {} Err(e) => return Err(e), } + Ok(()) + } + fn mapped_frame( + &mut self, + read: impl FnOnce(&[u8], (u32, u32, usize, bool), &mut lighting::Crop) -> T, + ) -> windows::core::Result> { let Some(staging) = self.staging.as_ref() else { - if self.started.elapsed() > std::time::Duration::from_secs(5) { - return Err(windows::core::Error::from_hresult( - windows::Win32::Foundation::E_FAIL, - )); - } - return Ok(vec![[0; 3]; settings.count]); + return Ok(None); }; + struct Mapping<'a>(&'a ID3D11DeviceContext, &'a ID3D11Texture2D); + impl Drop for Mapping<'_> { + fn drop(&mut self) { + unsafe { self.0.Unmap(self.1, 0) }; + } + } let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); unsafe { self.context .Map(staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))?; + let _mapping = Mapping(&self.context, staging); let pixels = std::slice::from_raw_parts( mapped.pData.cast::(), - mapped.RowPitch as usize * self.pixels.1 as usize, + mapped.RowPitch as usize * self.content.1 as usize, ); - let colors = lighting::sample_geometry( + Ok(Some(read( pixels, - (self.pixels.0, self.pixels.1, mapped.RowPitch as usize, true), - settings, + ( + self.content.0, + self.content.1, + mapped.RowPitch as usize, + true, + ), &mut self.crop, - aspect, - ); - self.context.Unmap(staging, 0); - Ok(colors) + ))) } } } +fn window_thumbnail( + pixels: &[u8], + width: u32, + height: u32, + stride: usize, +) -> crate::capture::WindowPreview { + let scale = (600.0 / f64::from(width)) + .min(340.0 / f64::from(height)) + .min(1.0); + let w = (f64::from(width) * scale).floor().max(1.0) as u32; + let h = (f64::from(height) * scale).floor().max(1.0) as u32; + let mut rgba = Vec::with_capacity(w as usize * h as usize * 4); + for y in 0..h { + let py = (u64::from(y) * u64::from(height) / u64::from(h)) as usize; + for x in 0..w { + let px = (u64::from(x) * u64::from(width) / u64::from(w)) as usize; + let p = py * stride + px * 4; + rgba.extend_from_slice(&[pixels[p + 2], pixels[p + 1], pixels[p], 255]); + } + } + crate::capture::WindowPreview { + width: w, + height: h, + rgba, + } +} impl Drop for WindowCapture { fn drop(&mut self) { let _ = self.item.RemoveClosed(self.closed_token); @@ -538,7 +623,84 @@ mod window_tests { }, time::Duration, }; - use windows::{core::w, Win32::UI::WindowsAndMessaging::*}; + use windows::{ + core::w, + Win32::{ + Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM}, + UI::WindowsAndMessaging::*, + }, + }; + #[test] + fn window_preview_is_bounded_and_excludes_strided_padding() { + let (width, height, stride) = (1600u32, 1200u32, 1664usize * 4); + let mut pixels = vec![255; stride * (height as usize + 8)]; + for y in 0..height as usize { + for x in 0..width as usize { + pixels[y * stride + x * 4..y * stride + x * 4 + 4] + .copy_from_slice(&[17, 80, 230, 255]); + } + } + let preview = window_thumbnail(&pixels, width, height, stride); + assert_eq!((preview.width, preview.height), (453, 340)); + assert_eq!(preview.rgba.len(), 453 * 340 * 4); + assert!(preview + .rgba + .as_chunks::<4>() + .0 + .iter() + .all(|p| *p == [230, 80, 17, 255])); + } + unsafe extern "system" fn fixture_proc( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + unsafe { + if message == WM_PAINT { + let mut paint = PAINTSTRUCT::default(); + let dc = BeginPaint(hwnd, &mut paint); + let mut bounds = RECT::default(); + if GetClientRect(hwnd, &mut bounds).is_ok() { + let white = CreateSolidBrush(COLORREF(0x00ff_ffff)); + FillRect(dc, &bounds, white); + let _ = DeleteObject(white.into()); + let video = RECT { + left: bounds.right / 4, + top: bounds.bottom / 4, + right: bounds.right * 3 / 4, + bottom: bounds.bottom * 3 / 4, + }; + let brush = + CreateSolidBrush(COLORREF(GetWindowLongPtrW(hwnd, GWLP_USERDATA) as u32)); + FillRect(dc, &video, brush); + let _ = DeleteObject(brush.into()); + } + let _ = EndPaint(hwnd, &paint); + return LRESULT(0); + } + DefWindowProcW(hwnd, message, wparam, lparam) + } + } + fn wait_for_colors(capture: &mut WindowCapture, settings: &Settings, expected: Rgb) { + let mut frame = vec![]; + for _ in 0..100 { + frame = capture.colors(settings, 16.0 / 9.0).unwrap(); + if !frame.is_empty() + && frame.iter().all(|color| { + color + .iter() + .zip(expected) + .all(|(actual, expected)| actual.abs_diff(expected) <= 3) + }) + { + assert_eq!(frame.len(), settings.count); + return; + } + std::thread::sleep(Duration::from_millis(30)); + } + panic!("Expected {expected:?}, captured {frame:?}"); + } struct Fixture { stop: Arc, thread: Option>, @@ -563,21 +725,37 @@ mod window_tests { let done = stop.clone(); let (tx, rx) = mpsc::channel(); let thread = std::thread::spawn(move || unsafe { + let instance = HINSTANCE( + windows::Win32::System::LibraryLoader::GetModuleHandleW(None) + .unwrap() + .0, + ); + let class = w!("DynamicLightCaptureColorFixture"); + let registered = RegisterClassW(&WNDCLASSW { + lpfnWndProc: Some(fixture_proc), + hInstance: instance, + lpszClassName: class, + style: CS_HREDRAW | CS_VREDRAW, + ..Default::default() + }); + assert_ne!(registered, 0); let window = CreateWindowExW( WS_EX_TOOLWINDOW, - w!("STATIC"), + class, w!("DynamicLight capture fixture"), - WS_OVERLAPPEDWINDOW | WS_VISIBLE, + WS_POPUP | WS_VISIBLE, 50, 50, 480, 320, None, None, - None, + Some(instance), None, ) .unwrap(); + SetWindowLongPtrW(window, GWLP_USERDATA, 0x0014_78f0); + let _ = InvalidateRect(Some(window), None, false); tx.send(window.0 as usize).unwrap(); while !done.load(Ordering::Acquire) { let mut msg = MSG::default(); @@ -588,6 +766,7 @@ mod window_tests { std::thread::sleep(Duration::from_millis(10)); } DestroyWindow(window).unwrap(); + UnregisterClassW(class, Some(instance)).unwrap(); }); let fixture = Fixture { stop, @@ -604,40 +783,67 @@ mod window_tests { } let mut capture = WindowCapture::new(hwnd.0 as usize as u32).unwrap(); let settings = Settings::default(); - let mut frame = vec![]; - for _ in 0..100 { - frame = capture.colors(&settings, 16.0 / 9.0).unwrap(); - if frame.iter().flatten().any(|v| *v > 0) { - break; + // White browser-like margins are expected to dominate whole-window + // edge sampling, even with a saturated orange video in the middle. + wait_for_colors(&mut capture, &settings, [255, 255, 255]); + let preview = capture.preview().unwrap(); + assert!(preview.width <= 600 && preview.height <= 340); + let center = ((preview.height / 2 * preview.width + preview.width / 2) * 4) as usize; + assert_eq!(&preview.rgba[center..center + 4], &[240, 120, 20, 255]); + assert_eq!(&preview.rgba[..4], &[255; 4]); + let settings = Settings { + capture_source: crate::capture_source::Source::Window { + id: hwnd.0 as usize as u32, + pid: std::process::id(), + app: "Fixture".into(), + session: "Fixture".into(), + crop: Some(crate::capture_source::Rectangle { + left: 300, + top: 300, + width: 400, + height: 400, + }), + }, + ..settings + }; + wait_for_colors(&mut capture, &settings, [240, 120, 20]); + // Require newly painted frames, not only nonzero output. This catches + // stale frame reuse and an accidental BGRA/RGBA channel reversal. + for (paint, expected) in [(0x000a_0af0, [240, 10, 10]), (0x00f0_0a0a, [10, 10, 240])] { + unsafe { + SetWindowLongPtrW(hwnd, GWLP_USERDATA, paint); + let _ = InvalidateRect(Some(hwnd), None, false); } - std::thread::sleep(Duration::from_millis(30)); - } - assert_eq!(frame.len(), settings.count); - assert!( - frame.iter().flatten().any(|v| *v > 0), - "Fixture window produced no frame" - ); - let before = capture.size; - unsafe { - SetWindowPos( - hwnd, - None, - 0, - 0, - 620, - 400, - SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE, - ) - .unwrap(); + wait_for_colors(&mut capture, &settings, expected); } - for _ in 0..100 { - capture.colors(&settings, 16.0 / 9.0).unwrap(); - if capture.size != before { - break; + for (width, height) in [(840, 560), (360, 240)] { + let before = capture.size; + unsafe { + SetWindowPos( + hwnd, + None, + 0, + 0, + width, + height, + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE, + ) + .unwrap(); } - std::thread::sleep(Duration::from_millis(30)); + for _ in 0..100 { + capture.colors(&settings, 16.0 / 9.0).unwrap(); + if capture.size != before { + break; + } + std::thread::sleep(Duration::from_millis(30)); + } + assert_ne!(capture.size, before); + wait_for_colors(&mut capture, &settings, [10, 10, 240]); + let preview = capture.preview().unwrap(); + assert!(preview.width <= 600 && preview.height <= 340); + let center = ((preview.height / 2 * preview.width + preview.width / 2) * 4) as usize; + assert_eq!(&preview.rgba[center..center + 4], &[10, 10, 240, 255]); } - assert_ne!(capture.size, before); drop(fixture); for _ in 0..100 { if capture.colors(&settings, 16.0 / 9.0).is_err() { diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index af7a0b9..b330975 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -2519,6 +2519,48 @@ mod tests { ); } #[test] + fn changing_only_window_crop_requires_fresh_capture_consent() { + let dir = tempfile::tempdir().unwrap(); + let mut source = crate::capture_source::Source::Window { + id: 1, + pid: 2, + app: "local-browser".into(), + session: "local-session".into(), + crop: None, + }; + let mut e = simulated( + Settings { + mode: "screen".into(), + capture_source: source.clone(), + ..Default::default() + }, + dir.path().join("settings.json"), + ); + e.life.requested = true; + e.life.capture_allowed = true; + e.state.capturing = true; + let crate::capture_source::Source::Window { crop, .. } = &mut source else { + unreachable!() + }; + *crop = Some(crate::capture_source::Rectangle { + left: 200, + top: 200, + width: 600, + height: 600, + }); + e.action(Action::PatchSettings { + patch: SettingsPatch(serde_json::Map::from_iter([( + "captureSource".into(), + serde_json::to_value(&source).unwrap(), + )])), + persist: true, + }) + .unwrap(); + assert_eq!(e.state.settings.capture_source, source); + assert!(!e.state.capturing && !e.life.capture_allowed); + assert!(e.state.capture_requires_start); + } + #[test] fn offline_replay_stops_capture_and_respects_stop_and_comfort() { let dir = tempfile::tempdir().unwrap(); let mut e = simulated( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7082812..8e2a9c8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -158,6 +158,20 @@ async fn capture_windows_list() -> Result, String> { .map_err(|e| e.to_string())? } #[tauri::command] +async fn capture_window_preview( + source: capture_source::Source, +) -> Result { + // Explicit one-shot capture from the source picker. A fresh thread avoids + // inheriting an STA apartment from an unrelated blocking-pool task. + tauri::async_runtime::spawn_blocking(move || { + std::thread::spawn(move || capture::window_preview(source)) + .join() + .map_err(|_| "Window preview failed".to_string())? + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] fn quit(app: tauri::AppHandle) { app.exit(0); } @@ -207,6 +221,7 @@ pub fn run() { control, displays, capture_windows_list, + capture_window_preview, quit, platform_name, app_version, diff --git a/src-tauri/src/lighting.rs b/src-tauri/src/lighting.rs index 08f1031..377784f 100644 --- a/src-tauri/src/lighting.rs +++ b/src-tauri/src/lighting.rs @@ -150,11 +150,7 @@ pub fn sample_geometry( aspect: f32, ) -> Vec { let (width, height, stride, bgra) = frame; - if width == 0 - || height == 0 - || stride < width as usize * 4 - || data.len() < stride * height as usize - { + if !valid_frame(data, width, height, stride) { return vec![[0; 3]; settings.count]; } let (left, top) = crop.update(data, width, height, stride, settings.exclude_bars); @@ -188,6 +184,45 @@ pub fn sample_geometry( }) .collect() } +fn valid_frame(data: &[u8], width: u32, height: u32, stride: usize) -> bool { + if width == 0 || height == 0 { + return false; + } + let Some(row_bytes) = (width as usize).checked_mul(4) else { + return false; + }; + stride >= row_bytes + && (height as usize - 1) + .checked_mul(stride) + .and_then(|offset| offset.checked_add(row_bytes)) + .is_some_and(|required| data.len() >= required) +} +/// Sample an explicitly selected part of a window before optional black-bar +/// detection. The strided view avoids copying the full captured window. +pub fn sample_window( + data: &[u8], + frame: (u32, u32, usize, bool), + settings: &Settings, + crop: &mut Crop, + aspect: f32, +) -> Vec { + let (width, height, stride, bgra) = frame; + if !valid_frame(data, width, height, stride) { + return vec![[0; 3]; settings.count]; + } + let Ok((left, top, width, height)) = settings.capture_source.rectangle(width, height) else { + return vec![[0; 3]; settings.count]; + }; + // The original frame validation and bounded rectangle make this offset safe. + let offset = top as usize * stride + left as usize * 4; + sample_geometry( + &data[offset..], + (width, height, stride, bgra), + settings, + crop, + aspect, + ) +} pub fn transform(colors: &[Rgb], previous: &[[f32; 3]], s: &Settings, dt: f32) -> Vec<[f32; 3]> { colors .iter() @@ -230,6 +265,94 @@ pub fn reorder(colors: &[Rgb], reverse: bool, offset: usize) -> Vec { #[cfg(test)] mod tests { use super::*; + fn window_settings(crop: Option) -> Settings { + Settings { + count: 24, + sides: 4, + capture_source: crate::capture_source::Source::Window { + id: 1, + pid: 2, + app: "browser".into(), + session: "local".into(), + crop, + }, + ..Default::default() + } + } + #[test] + fn window_video_crop_excludes_white_browser_edges() { + let (w, h) = (200usize, 100usize); + let orange = [240, 90, 12]; + let mut pixels = vec![255; w * h * 4]; + for y in 20..80 { + for x in 40..160 { + pixels[(y * w + x) * 4..(y * w + x) * 4 + 3].copy_from_slice(&orange); + } + } + let full = sample_window( + &pixels, + (w as u32, h as u32, w * 4, false), + &window_settings(None), + &mut Crop::default(), + 16.0 / 9.0, + ); + assert!(full.iter().all(|color| *color == [255; 3])); + let video = window_settings(Some(crate::capture_source::Rectangle { + left: 200, + top: 200, + width: 600, + height: 600, + })); + let cropped = sample_window( + &pixels, + (w as u32, h as u32, w * 4, false), + &video, + &mut Crop::default(), + 16.0 / 9.0, + ); + assert!(cropped.iter().all(|color| *color == orange)); + } + #[test] + fn window_crop_handles_rgba_bgra_padding_and_the_final_row() { + let (w, h, stride) = (6usize, 3usize, 28usize); + let s = window_settings(Some(crate::capture_source::Rectangle { + left: 500, + top: 334, + width: 500, + height: 666, + })); + for bgra in [false, true] { + // No trailing padding is needed after the last row. The cropped + // view starts partway into a row and still includes that last row. + let mut pixels = vec![255; (h - 1) * stride + w * 4]; + for y in 1..h { + for x in 3..w { + let encoded = if bgra { + [12, 90, 240, 255] + } else { + [240, 90, 12, 255] + }; + pixels[y * stride + x * 4..y * stride + x * 4 + 4].copy_from_slice(&encoded); + } + } + let colors = sample_window( + &pixels, + (w as u32, h as u32, stride, bgra), + &s, + &mut Crop::default(), + 16.0 / 9.0, + ); + assert!(colors.iter().all(|color| *color == [240, 90, 12])); + let truncated = sample_window( + &pixels[..pixels.len() - 1], + (w as u32, h as u32, stride, bgra), + &s, + &mut Crop::default(), + 16.0 / 9.0, + ); + assert!(truncated.iter().all(|color| *color == [0; 3])); + } + } #[test] fn cropping_keeps_the_physical_edge_allocation() { let (w, h) = (256usize, 128usize); diff --git a/src-tauri/src/sharing.rs b/src-tauri/src/sharing.rs index 7ef92d0..abe9768 100644 --- a/src-tauri/src/sharing.rs +++ b/src-tauri/src/sharing.rs @@ -380,11 +380,27 @@ mod tests { } #[test] fn selected_export_roundtrips_without_local_state_and_creates_missing_directory() { - let base = library(); + let mut base = library(); + base.profiles[0].source.capture_source = crate::capture_source::Source::Window { + id: 1, + pid: 2, + app: "private-window-app".into(), + session: "private-window-session".into(), + crop: Some(crate::capture_source::Rectangle { + left: 200, + top: 200, + width: 600, + height: 600, + }), + }; let bundle = portable(&base, &["desk".into()], &["test".into()]).unwrap(); let text = serde_json::to_string(&bundle).unwrap(); for private in [ "private-screen-path", + "private-window-app", + "private-window-session", + "captureSource", + "crop", "shortcut", "favorite", "source", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 32d7260..da91719 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "DynamicLight", - "version": "0.16.1", + "version": "0.16.2", "identifier": "com.dynamiclight.desktop", "build": { "frontendDist": "../src/ui" diff --git a/src/ui/capture-sources.js b/src/ui/capture-sources.js index 624f3a1..53c5ff9 100644 --- a/src/ui/capture-sources.js +++ b/src/ui/capture-sources.js @@ -1,102 +1,182 @@ "use strict"; window.CaptureSources = (() => { const el = (id) => document.getElementById("source-" + id); - let draft, - windows = [], - anchor; + const keys = ["left", "top", "width", "height"]; + let draft, windows = [], anchor, preview, previewRequest = 0, previewBusy = false; + const isArea = () => + el("kind").value === "region" || + (el("kind").value === "window" && el("crop").checked); + const sameWindow = (a, b) => + a.kind === "window" && b.kind === "window" && + ["id", "pid", "app", "session"].every((key) => a[key] === b[key]); + function clearPreview() { + ++previewRequest; + preview = undefined; + anchor = undefined; + previewBusy = false; + } + function fields(area = {}) { + for (const key of keys) + el(key).value = + (area[key] ?? (key === "width" || key === "height" ? 1000 : 0)) / 10; + } function draw() { const kind = el("kind").value; - el("region").hidden = kind !== "region"; + el("region").hidden = !["region", "window"].includes(kind); + for (const key of keys) el(key).disabled = kind === "window" && !isArea(); el("window").hidden = kind !== "window"; + el("snapshot").disabled = previewBusy || el("windows").value === ""; + el("area-help").textContent = kind === "window" + ? preview + ? "Still preview. Outline edges determine the LED colors." + : "No image captured. Percentages are relative to the entire window." + : "Diagram only. Percentages are relative to the selected display."; const canvas = el("preview"), - ctx = canvas.getContext("2d"), css = getComputedStyle(document.documentElement); - ctx.fillStyle = css.getPropertyValue("--stage"); - ctx.fillRect(0, 0, 600, 340); - ctx.strokeStyle = css.getPropertyValue("--muted"); - ctx.strokeRect(1, 1, 598, 338); - const values = ["left", "top", "width", "height"].map( - (k) => Number(el(k).value) / 100, - ); - const [x, y, w, h] = kind === "region" ? values : [0, 0, 1, 1]; + canvas.width = preview?.width || 600; + canvas.height = preview?.height || 340; + const ctx = canvas.getContext("2d"), + width = canvas.width, + height = canvas.height; + if (preview) ctx.drawImage(preview, 0, 0); + else { + ctx.fillStyle = css.getPropertyValue("--stage"); + ctx.fillRect(0, 0, width, height); + } + const [x, y, w, h] = isArea() + ? keys.map((k) => Number(el(k).value) / 100) + : [0, 0, 1, 1]; ctx.strokeStyle = css.getPropertyValue("--accent"); - ctx.lineWidth = 4; - ctx.strokeRect(x * 600, y * 340, w * 600, h * 340); + ctx.lineWidth = 3 * width / (canvas.getBoundingClientRect().width || width); + ctx.strokeRect(x * width, y * height, w * width, h * height); } - function render() { - el("kind").value = draft.kind; - for (const key of ["left", "top", "width", "height"]) - el(key).value = - (draft[key] ?? (key === "width" || key === "height" ? 1000 : 0)) / 10; + function populateWindows(source) { el("windows").replaceChildren( new Option("Select a window…", ""), ...windows.map((w, i) => new Option(w.name, String(i))), ); - const selected = windows.findIndex( - (w) => JSON.stringify(w.source) === JSON.stringify(draft), - ); + const selected = windows.findIndex((w) => sameWindow(w.source, source)); el("windows").value = selected >= 0 ? String(selected) : ""; - if (draft.kind === "window" && selected < 0) + if (source.kind === "window" && selected < 0) el("status").textContent = "Refresh windows and select the source again."; - draw(); + } + function selectedWindow() { + if (el("windows").value === "") throw Error("Select an available window."); + return structuredClone(windows[Number(el("windows").value)].source); + } + function readArea() { + const area = {}; + for (const key of keys) { + const field = el(key); + if (!field.reportValidity()) + throw Error("Use percentages inside the selected source."); + area[key] = Math.round(Number(field.value) * 10); + } + if (area.left + area.width > 1000 || area.top + area.height > 1000) + throw Error("The rectangle must stay inside the selected source."); + return area; } function read() { const kind = el("kind").value; if (kind === "display") return { kind }; if (kind === "window") { - if (el("windows").value === "") - throw Error("Select an available window."); - return structuredClone(windows[Number(el("windows").value)].source); + const source = selectedWindow(); + if (el("crop").checked) source.crop = readArea(); + return source; } - const source = { kind }; - for (const key of ["left", "top", "width", "height"]) { - const field = el(key); - if (!field.reportValidity()) - throw Error("Use percentages inside the selected display."); - source[key] = Math.round(Number(field.value) * 10); - } - if (source.left + source.width > 1000 || source.top + source.height > 1000) - throw Error("The rectangle must stay inside the selected display."); - return source; + return { kind, ...readArea() }; } function sync() { const source = settings.captureSource || { kind: "display" }; - el("label").textContent = - source.kind === "window" - ? "Application window" - : source.kind === "region" - ? "Selected rectangle" - : "Full display"; + el("label").textContent = source.kind === "window" + ? source.crop ? "Area within application window" : "Application window" + : source.kind === "region" ? "Selected rectangle" : "Full display"; } function init() { el("open").onclick = async () => { try { await save(); draft = structuredClone(settings.captureSource || { kind: "display" }); + clearPreview(); el("status").textContent = ""; - render(); + el("kind").value = draft.kind; + el("crop").checked = !!draft.crop; + fields(draft.kind === "window" ? draft.crop : draft); + populateWindows(draft); + draw(); el("dialog").showModal(); } catch (e) { notice(String(e), true); } }; el("cancel").onclick = () => el("dialog").close(); - el("kind").onchange = draw; - for (const key of ["left", "top", "width", "height"]) - el(key).oninput = draw; + el("dialog").addEventListener("close", () => { + clearPreview(); + draw(); + }); + el("kind").onchange = () => { + clearPreview(); + el("status").textContent = ""; + fields(); + el("crop").checked = false; + draw(); + }; + el("windows").onchange = () => { + clearPreview(); + fields(); + el("crop").checked = false; + el("status").textContent = ""; + draw(); + }; + el("crop").onchange = draw; + for (const key of keys) el(key).oninput = draw; el("refresh").onclick = async () => { el("refresh").disabled = true; + const selected = el("windows").value === "" ? draft : selectedWindow(); + clearPreview(); + const refreshId = previewRequest; + draw(); try { - windows = await invoke("capture_windows_list"); - draft = { kind: el("kind").value }; - render(); + const nextWindows = await invoke("capture_windows_list"); + if (!el("dialog").open || refreshId !== previewRequest) return; + windows = nextWindows; + populateWindows(selected); el("status").textContent = windows.length ? "Choose a window. Titles stay on this computer." : "No available windows. Open the app to capture, then refresh."; } catch (e) { - el("status").textContent = String(e); + if (refreshId === previewRequest) el("status").textContent = String(e); } finally { el("refresh").disabled = false; + draw(); + } + }; + el("snapshot").onclick = async () => { + let requestId; + try { + const source = selectedWindow(); + clearPreview(); + requestId = previewRequest; + previewBusy = true; + draw(); + el("status").textContent = "Capturing one preview of the selected window…"; + const result = await invoke("capture_window_preview", { source }); + if (requestId !== previewRequest || !el("dialog").open) return; + preview = document.createElement("canvas"); + preview.width = result.width; + preview.height = result.height; + preview.getContext("2d").putImageData( + new ImageData(new Uint8ClampedArray(result.rgba), result.width, result.height), + 0, 0, + ); + el("status").textContent = "Drag over the video to select it. Take another preview if the layout changes."; + } catch (e) { + if (requestId === previewRequest) el("status").textContent = String(e); + } finally { + if (requestId === previewRequest) { + previewBusy = false; + draw(); + } } }; el("apply").onclick = async () => { @@ -110,9 +190,7 @@ window.CaptureSources = (() => { acceptSettings(result); updateUI(); el("dialog").close(); - notice( - "Capture source saved. Press Start / Retry to begin capturing it.", - ); + notice("Capture source saved. Press Start / Retry to begin capturing it."); } catch (e) { el("status").textContent = String(e); } @@ -120,34 +198,33 @@ window.CaptureSources = (() => { const point = (e) => { const r = el("preview").getBoundingClientRect(); return [ - Math.round( - Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * 1000, - ), - Math.round( - Math.max(0, Math.min(1, (e.clientY - r.top) / r.height)) * 1000, - ), + Math.round(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * 1000), + Math.round(Math.max(0, Math.min(1, (e.clientY - r.top) / r.height)) * 1000), ]; }; el("preview").onpointerdown = (e) => { - if (el("kind").value !== "region") return; + if (!["region", "window"].includes(el("kind").value)) return; + if (el("kind").value === "window") { + if (el("windows").value === "") return; + el("crop").checked = true; + } anchor = point(e); el("preview").setPointerCapture(e.pointerId); + draw(); }; el("preview").onpointermove = (e) => { if (!el("preview").hasPointerCapture(e.pointerId) || !anchor) return; const p = point(e); const left = Math.min(anchor[0], p[0], 999), top = Math.min(anchor[1], p[1], 999); - const values = { - left, - top, + fields({ + left, top, width: Math.max(1, Math.abs(p[0] - anchor[0])), height: Math.max(1, Math.abs(p[1] - anchor[1])), - }; - for (const [key, value] of Object.entries(values)) - el(key).value = value / 10; + }); draw(); }; + el("preview").onlostpointercapture = () => { anchor = undefined; }; sync(); } return { init, sync }; diff --git a/src/ui/index.html b/src/ui/index.html index 9b3d83d..64f780d 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -1509,16 +1509,29 @@

Capture source

- The outline previews the sampled area; no screenshot is taken here. - Selected edges map to the calibrated LED route. Changing source stops - capture until you press Start / Retry. + Lights follow the edges of the selected area. For a video inside a + browser page, select just the video to exclude white margins and toolbars. + Changing source stops capture until you press Start / Retry.

+ +

-

- Window capture follows movement and resizing. Minimized, closed, or + Areas keep their relative position when the window resizes; reselect the + video after changing the page layout. Fullscreen video can use the whole + window. Minimized, closed, or revoked sources stop or fall back to Music in Blend; Retry after restoring them. Select windows again after restarting DynamicLight. Protected video may be black. macOS requires screen-recording diff --git a/src/ui/style.css b/src/ui/style.css index 23973ca..f721e3b 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -1721,6 +1721,13 @@ dd { width: 100%; min-width: 0; } +#source-dialog .source-crop-toggle { + flex-direction: row; + align-items: center; +} +#source-dialog .source-crop-toggle input { + width: auto; +} #sequence-dialog, #routine-dialog {