截图模块初始化

This commit is contained in:
zhongluofeng
2026-07-31 18:31:13 +08:00
parent 66575c6166
commit 89e5b7bed5
7 changed files with 2082 additions and 0 deletions
+475
View File
@@ -0,0 +1,475 @@
//! Windows 屏幕捕获核心(仅 Windows 编译)
//!
//! 实现:
//! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素
//! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口)
//! - 窗口拾取:WindowFromPoint + GetAncestor(GA_ROOT) 取顶层窗口
//! - 顶层窗口枚举:EnumWindows
//! - 像素 → PNG / CF_DIB 转换
//!
//! 设计说明:
//! - 不使用 Windows Graphics Capture:避免引入 windows crateWinRT)增大体积,
//! 且 WGC 默认绘制黄色捕获边框,截图工具不可接受。
//! - 一次性捕获使用 BitBlt/PrintWindow,延迟低、无用户提示。
//! - 32bpp 捕获后强制 alpha=255BitBlt 取出的 alpha 通道未定义(常为 0),
//! 直接编码 PNG 会得到全透明图,故强制不透明。
use std::sync::Mutex;
use windows_sys::Win32::Foundation::{HWND, POINT, RECT};
use windows_sys::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits,
PatBlt, ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BLACKNESS, DIB_RGB_COLORS,
RGBQUAD, SRCCOPY,
};
use windows_sys::Win32::Storage::Xps::PrintWindow;
use windows_sys::Win32::System::DataExchange::{
CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData,
};
use windows_sys::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE};
use windows_sys::Win32::System::Ole::CF_DIB;
use windows_sys::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetAncestor, GetSystemMetrics, GetWindowLongPtrW, GetWindowRect,
GetWindowTextLengthW, GetWindowTextW, GWL_EXSTYLE, GA_ROOT, WS_EX_TOOLWINDOW, WindowFromPoint,
};
use super::{CaptureData, ScreenRect, WindowInfo};
/// 捕获结果(PNG 字节 + 原始 BGRA 像素,像素用于剪贴板 DIB 构造,避免重复解码)
pub struct CapturedImage {
pub width: i32,
pub height: i32,
pub png: Vec<u8>,
/// 32bpp BGRAtop-down(与 GetDIBits negative height 一致)
pub bgra: Vec<u8>,
}
impl From<RECT> for ScreenRect {
fn from(r: RECT) -> Self {
Self {
x: r.left,
y: r.top,
width: r.right - r.left,
height: r.bottom - r.top,
}
}
}
/// 全屏捕获静态存储(供覆盖层选区后裁剪取用)
static FULLSCREEN_CAPTURE: Mutex<Option<CapturedImage>> = Mutex::new(None);
const SM_XVIRTUALSCREEN: i32 = 76;
const SM_YVIRTUALSCREEN: i32 = 77;
const SM_CXVIRTUALSCREEN: i32 = 78;
const SM_CYVIRTUALSCREEN: i32 = 79;
const PW_RENDERFULLCONTENT: u32 = 0x00000002;
const BI_RGB: u32 = 0;
/// 将 BGRA top-down 像素编码为 PNG(强制 alpha=255
fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String> {
use image::codecs::png::PngEncoder;
use image::ImageEncoder;
if width <= 0 || height <= 0 {
return Err("无效尺寸".into());
}
let w = width as usize;
let h = height as usize;
let mut rgba = vec![0u8; w * h * 4];
for i in 0..(w * h) {
rgba[i * 4] = bgra[i * 4 + 2]; // R
rgba[i * 4 + 1] = bgra[i * 4 + 1]; // G
rgba[i * 4 + 2] = bgra[i * 4]; // B
rgba[i * 4 + 3] = 255; // 强制不透明
}
let mut buf = Vec::with_capacity(w * h * 4 / 3);
PngEncoder::new(&mut buf)
.write_image(&rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8)
.map_err(|e| format!("PNG 编码失败: {}", e))?;
Ok(buf)
}
/// 从 HBITMAP 提取 32bpp BGRA top-down 像素
unsafe fn extract_pixels(
hdc_mem: isize,
hbm: isize,
width: i32,
height: i32,
) -> Result<Vec<u8>, String> {
let bi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: width,
biHeight: -height, // 负值 = top-down
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: 0,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
},
bmiColors: [RGBQUAD {
rgbBlue: 0,
rgbGreen: 0,
rgbRed: 0,
rgbReserved: 0,
}],
};
let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4];
let got = GetDIBits(
hdc_mem,
hbm,
0,
height as u32,
pixels.as_mut_ptr() as *mut _,
&bi as *const _ as *mut _,
DIB_RGB_COLORS,
);
if got == 0 {
return Err("GetDIBits 失败".into());
}
Ok(pixels)
}
/// 捕获整个虚拟屏(所有显示器拼接为一张图)
pub fn capture_virtual_screen() -> Result<CapturedImage, String> {
unsafe {
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
let y = GetSystemMetrics(SM_YVIRTUALSCREEN);
let w = GetSystemMetrics(SM_CXVIRTUALSCREEN);
let h = GetSystemMetrics(SM_CYVIRTUALSCREEN);
if w <= 0 || h <= 0 {
return Err("无法获取虚拟屏尺寸".into());
}
let hdc_screen = GetDC(0);
if hdc_screen == 0 {
return Err("GetDC 失败".into());
}
let hdc_mem = CreateCompatibleDC(hdc_screen);
if hdc_mem == 0 {
ReleaseDC(0, hdc_screen);
return Err("CreateCompatibleDC 失败".into());
}
let hbm = CreateCompatibleBitmap(hdc_screen, w, h);
if hbm == 0 {
DeleteDC(hdc_mem);
ReleaseDC(0, hdc_screen);
return Err("CreateCompatibleBitmap 失败".into());
}
let old = SelectObject(hdc_mem, hbm);
let ok = BitBlt(hdc_mem, 0, 0, w, h, hdc_screen, x, y, SRCCOPY);
let result = if ok == 0 {
Err("BitBlt 失败".into())
} else {
extract_pixels(hdc_mem, hbm, w, h).and_then(|bgra| {
bgra_to_png(&bgra, w, h).map(|png| CapturedImage {
width: w,
height: h,
png,
bgra,
})
})
};
SelectObject(hdc_mem, old);
DeleteObject(hbm);
DeleteDC(hdc_mem);
ReleaseDC(0, hdc_screen);
result
}
}
/// 捕获指定窗口(PrintWindow + PW_RENDERFULLCONTENT,覆盖硬件加速窗口)
pub fn capture_window(hwnd: isize) -> Result<CapturedImage, String> {
unsafe {
let mut rect: RECT = std::mem::zeroed();
if GetWindowRect(hwnd, &mut rect) == 0 {
return Err("GetWindowRect 失败".into());
}
let w = rect.right - rect.left;
let h = rect.bottom - rect.top;
if w <= 0 || h <= 0 {
return Err("窗口尺寸无效".into());
}
let hdc_screen = GetDC(0);
if hdc_screen == 0 {
return Err("GetDC 失败".into());
}
let hdc_mem = CreateCompatibleDC(hdc_screen);
let hbm = CreateCompatibleBitmap(hdc_screen, w, h);
let old = SelectObject(hdc_mem, hbm);
// 先用黑色填充(PrintWindow 对部分窗口不绘制透明区域)
PatBlt(hdc_mem, 0, 0, w, h, BLACKNESS);
let ok = PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT);
let result = if ok == 0 {
Err("PrintWindow 失败(可能窗口无响应或权限不足)".into())
} else {
extract_pixels(hdc_mem, hbm, w, h).and_then(|bgra| {
bgra_to_png(&bgra, w, h).map(|png| CapturedImage {
width: w,
height: h,
png,
bgra,
})
})
};
SelectObject(hdc_mem, old);
DeleteObject(hbm);
DeleteDC(hdc_mem);
ReleaseDC(0, hdc_screen);
result
}
}
/// 获取指定屏幕坐标下的顶层窗口(窗口拾取)
///
/// 入参 x/y 为物理屏幕坐标(前端需按显示器 scaleFactor 从逻辑坐标换算)。
pub fn window_from_point(x: i32, y: i32) -> Option<WindowInfo> {
unsafe {
let pt = POINT { x, y };
let mut hwnd = WindowFromPoint(pt);
if hwnd == 0 {
return None;
}
// 取顶层父窗口(WindowFromPoint 可能返回子窗口)
let root = GetAncestor(hwnd, GA_ROOT);
if root != 0 {
hwnd = root;
}
if windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible(hwnd) == 0 {
return None;
}
let mut rect: RECT = std::mem::zeroed();
if GetWindowRect(hwnd, &mut rect) == 0 {
return None;
}
let srect = ScreenRect::from(rect);
if srect.width <= 0 || srect.height <= 0 {
return None;
}
Some(WindowInfo {
hwnd,
title: get_window_title(hwnd),
rect: srect,
})
}
}
/// 枚举所有可见、有标题的顶层窗口(供窗口列表选择)
pub fn enum_visible_windows() -> Vec<WindowInfo> {
extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 {
unsafe {
let vec = &mut *(lparam as *mut Vec<WindowInfo>);
if windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible(hwnd) == 0 {
return 1;
}
let mut rect: RECT = std::mem::zeroed();
if GetWindowRect(hwnd, &mut rect) == 0 {
return 1;
}
let srect = ScreenRect::from(rect);
if srect.width < 20 || srect.height < 20 {
return 1;
}
// 跳过工具窗口(本应用 OSD / 托盘菜单 / 截图覆盖层)
let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
if ex & (WS_EX_TOOLWINDOW as isize) != 0 {
return 1;
}
let title = get_window_title(hwnd);
if title.is_empty() {
return 1;
}
vec.push(WindowInfo {
hwnd,
title,
rect: srect,
});
}
1
}
let mut out: Vec<WindowInfo> = Vec::new();
unsafe {
EnumWindows(Some(enum_proc), &mut out as *mut _ as isize);
}
out
}
unsafe fn get_window_title(hwnd: HWND) -> String {
let len = GetWindowTextLengthW(hwnd);
if len <= 0 {
return String::new();
}
let mut buf = vec![0u16; (len as usize) + 1];
let got = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
if got <= 0 {
return String::new();
}
String::from_utf16_lossy(&buf[..got as usize])
}
/// 裁剪已存储的全屏捕获(按物理像素坐标)
pub fn crop_stored(x: i32, y: i32, w: i32, h: i32) -> Result<CaptureData, String> {
let guard = FULLSCREEN_CAPTURE.lock().map_err(|e| e.to_string())?;
let img = guard.as_ref().ok_or("无已存储的全屏捕获")?;
if x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > img.width || y + h > img.height {
return Err("裁剪区域越界".into());
}
let sw = img.width as usize;
let sub = crop_bgra(&img.bgra, sw, x, y, w, h)?;
let png = bgra_to_png(&sub, w, h)?;
Ok(CaptureData {
png_base64: base64_encode(&png),
width: w,
height: h,
})
}
fn crop_bgra(
src: &[u8],
src_width: usize,
x: i32,
y: i32,
w: i32,
h: i32,
) -> Result<Vec<u8>, String> {
let mut out = vec![0u8; (w as usize) * (h as usize) * 4];
for row in 0..(h as usize) {
let src_off = ((y as usize) + row) * src_width * 4 + (x as usize) * 4;
let dst_off = row * (w as usize) * 4;
out[dst_off..dst_off + (w as usize) * 4]
.copy_from_slice(&src[src_off..src_off + (w as usize) * 4]);
}
Ok(out)
}
/// 存储全屏捕获(覆盖层选区裁剪时取用)
pub fn store_fullscreen(img: CapturedImage) -> CaptureData {
let data = CaptureData {
png_base64: base64_encode(&img.png),
width: img.width,
height: img.height,
};
if let Ok(mut g) = FULLSCREEN_CAPTURE.lock() {
*g = Some(img);
}
data
}
/// 取出并清除全屏捕获
pub fn take_fullscreen() -> Option<CaptureData> {
let mut g = FULLSCREEN_CAPTURE.lock().ok()?;
let img = g.take()?;
Some(CaptureData {
png_base64: base64_encode(&img.png),
width: img.width,
height: img.height,
})
}
// ===== 输出:剪贴板 / 文件 =====
/// 将 PNG base64 解码为像素,构造 CF_DIB 并写入剪贴板
pub fn copy_png_to_clipboard(png_base64: &str) -> Result<(), String> {
let png = base64_decode(png_base64).ok_or("base64 解码失败")?;
let img = image::load_from_memory(&png)
.map_err(|e| format!("PNG 解码失败: {}", e))?
.to_rgba8();
let (w, h) = (img.width() as i32, img.height() as i32);
let dib = rgba_to_dib(&img, w, h);
write_dib_to_clipboard(&dib)
}
/// RGBA → CF_DIBBITMAPINFOHEADER + BGRA bottom-upalpha 强制 255
fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec<u8> {
let header_size = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
let row_size = (width as usize) * 4;
let mut dib = Vec::with_capacity(header_size as usize + row_size * height as usize);
let header = BITMAPINFOHEADER {
biSize: header_size,
biWidth: width,
biHeight: height, // 正值 = bottom-up
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: (row_size * height as usize) as u32,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
};
let raw = unsafe {
std::slice::from_raw_parts(
&header as *const _ as *const u8,
std::mem::size_of::<BITMAPINFOHEADER>(),
)
};
dib.extend_from_slice(raw);
// bottom-up 行序,BGRA 像素,alpha 强制 255
for y in (0..height as usize).rev() {
for x in 0..width as usize {
let p = rgba.get_pixel(x as u32, y as u32).0;
dib.push(p[2]); // B
dib.push(p[1]); // G
dib.push(p[0]); // R
dib.push(255); // A
}
}
dib
}
fn write_dib_to_clipboard(dib: &[u8]) -> Result<(), String> {
unsafe {
if OpenClipboard(0) == 0 {
return Err("OpenClipboard 失败".into());
}
let r = (|| {
if EmptyClipboard() == 0 {
return Err("EmptyClipboard 失败".to_string());
}
let hglob = GlobalAlloc(GMEM_MOVEABLE, dib.len());
if hglob.is_null() {
return Err("GlobalAlloc 失败".to_string());
}
let ptr = GlobalLock(hglob) as *mut u8;
if ptr.is_null() {
return Err("GlobalLock 失败".to_string());
}
std::ptr::copy_nonoverlapping(dib.as_ptr(), ptr, dib.len());
GlobalUnlock(hglob);
if SetClipboardData(CF_DIB as u32, hglob as isize) == 0 {
return Err("SetClipboardData 失败".to_string());
}
Ok(())
})();
CloseClipboard();
r
}
}
/// 将 PNG base64 写入文件
pub fn save_png_to_file(png_base64: &str, path: &str) -> Result<(), String> {
let png = base64_decode(png_base64).ok_or("base64 解码失败")?;
std::fs::write(path, &png).map_err(|e| format!("写入文件失败: {}", e))
}
pub fn base64_encode(b: &[u8]) -> String {
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
STANDARD.encode(b)
}
fn base64_decode(s: &str) -> Option<Vec<u8>> {
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
STANDARD.decode(s).ok()
}
+177
View File
@@ -0,0 +1,177 @@
//! Tauri 命令:截图模块
//!
//! 命令清单:
//! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态,返回元数据 + base64
//! - screenshot_take_fullscreen:取出并清除静态存储的全屏捕获
//! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获
//! - screenshot_window_from_point:拾取指定屏幕坐标下的顶层窗口
//! - screenshot_enum_windows:枚举可见顶层窗口
//! - screenshot_capture_window:按 hwnd 捕获指定窗口
//! - screenshot_set_editor_image / screenshot_get_editor_image:编辑器图片传递
//! - screenshot_copy_image:写入剪贴板(CF_DIB
//! - screenshot_save_png:写入文件
use super::{CaptureData, WindowInfo};
/// 捕获整个虚拟屏(多显示器拼接),存入静态供后续裁剪,并返回 base64
#[tauri::command]
pub async fn screenshot_capture_fullscreen() -> Result<CaptureData, String> {
#[cfg(windows)]
{
// 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度
let img = tauri::async_runtime::spawn_blocking(|| {
super::capture::capture_virtual_screen()
})
.await
.map_err(|e| format!("捕获任务失败: {}", e))??;
Ok(super::capture::store_fullscreen(img))
}
#[cfg(not(windows))]
{
Err("截图仅支持 Windows".into())
}
}
/// 取出并清除静态存储的全屏捕获(覆盖层取消时清理用)
#[tauri::command]
pub async fn screenshot_take_fullscreen() -> Result<Option<CaptureData>, String> {
#[cfg(windows)]
{
Ok(super::capture::take_fullscreen())
}
#[cfg(not(windows))]
{
Ok(None)
}
}
/// 按物理像素坐标裁剪已存储的全屏捕获
#[tauri::command]
pub async fn screenshot_crop_stored(
x: i32,
y: i32,
w: i32,
h: i32,
) -> Result<CaptureData, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
super::capture::crop_stored(x, y, w, h)
})
.await
.map_err(|e| format!("裁剪任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = (x, y, w, h);
Err("截图仅支持 Windows".into())
}
}
/// 拾取指定物理屏幕坐标下的顶层窗口
#[tauri::command]
pub async fn screenshot_window_from_point(
x: i32,
y: i32,
) -> Result<Option<WindowInfo>, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
Ok(super::capture::window_from_point(x, y))
})
.await
.map_err(|e| format!("查询失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = (x, y);
Ok(None)
}
}
/// 枚举所有可见顶层窗口
#[tauri::command]
pub async fn screenshot_enum_windows() -> Result<Vec<WindowInfo>, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(|| super::capture::enum_visible_windows())
.await
.map_err(|e| format!("枚举失败: {}", e))
}
#[cfg(not(windows))]
{
Ok(vec![])
}
}
/// 按 hwnd 捕获指定窗口
#[tauri::command]
pub async fn screenshot_capture_window(hwnd: isize) -> Result<CaptureData, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
let img = super::capture::capture_window(hwnd)?;
Ok(super::CaptureData {
png_base64: super::capture::base64_encode(&img.png),
width: img.width,
height: img.height,
})
})
.await
.map_err(|e| format!("捕获任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = hwnd;
Err("截图仅支持 Windows".into())
}
}
/// 存入编辑器图片(base64 PNG
#[tauri::command]
pub async fn screenshot_set_editor_image(png_base64: String) -> Result<(), String> {
super::set_editor_image(png_base64);
Ok(())
}
/// 取出编辑器图片(编辑器窗口加载时调用,取出即清除)
#[tauri::command]
pub async fn screenshot_get_editor_image() -> Result<Option<String>, String> {
Ok(super::take_editor_image())
}
/// 将 PNG base64 写入系统剪贴板(转 CF_DIB)
#[tauri::command]
pub async fn screenshot_copy_image(png_base64: String) -> Result<(), String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
super::capture::copy_png_to_clipboard(&png_base64)
})
.await
.map_err(|e| format!("剪贴板任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = png_base64;
Err("剪贴板仅支持 Windows".into())
}
}
/// 将 PNG base64 写入文件
#[tauri::command]
pub async fn screenshot_save_png(png_base64: String, path: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(windows)]
{
super::capture::save_png_to_file(&png_base64, &path)
}
#[cfg(not(windows))]
{
let _ = (png_base64, path);
Err("文件写入仅支持 Windows".to_string())
}
})
.await
.map_err(|e| format!("保存任务失败: {}", e))?
}
+52
View File
@@ -0,0 +1,52 @@
//! 截图模块
//!
//! 跨平台数据结构定义 + Windows 捕获实现(capture.rs)。
//! 捕获引擎选型见 capture.rs 顶部说明:BitBlt + PrintWindow,不使用 WGC。
use std::sync::Mutex;
#[cfg(windows)]
pub mod capture;
pub mod commands;
/// 前端可见的捕获数据
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CaptureData {
pub png_base64: String,
pub width: i32,
pub height: i32,
}
/// 窗口信息(窗口拾取 / 枚举)
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WindowInfo {
pub hwnd: isize,
pub title: String,
pub rect: ScreenRect,
}
#[derive(serde::Serialize, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub struct ScreenRect {
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
}
/// 编辑器图片静态存储(覆盖层裁剪后存入 → 编辑器窗口加载取出)
static EDITOR_IMAGE: Mutex<Option<String>> = Mutex::new(None);
/// 存储编辑器图片(base64 PNG
pub fn set_editor_image(png_base64: String) {
if let Ok(mut g) = EDITOR_IMAGE.lock() {
*g = Some(png_base64);
}
}
/// 取出并清除编辑器图片
pub fn take_editor_image() -> Option<String> {
EDITOR_IMAGE.lock().ok()?.take()
}