vulkan: import wl_shm buffers as udmabuf
This commit is contained in:
parent
47e15c6083
commit
a3d3a62af3
14 changed files with 545 additions and 99 deletions
130
src/gfx_apis/vulkan/dmabuf_buffer.rs
Normal file
130
src/gfx_apis/vulkan/dmabuf_buffer.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
use {
|
||||
crate::{
|
||||
gfx_api::GfxBuffer,
|
||||
gfx_apis::vulkan::{VulkanError, device::VulkanDevice},
|
||||
utils::on_drop::OnDrop,
|
||||
},
|
||||
ash::{
|
||||
Device,
|
||||
vk::{
|
||||
self, BufferCreateInfo, BufferUsageFlags, ExternalMemoryBufferCreateInfo,
|
||||
ExternalMemoryHandleTypeFlags, ImportMemoryFdInfoKHR, MemoryAllocateInfo,
|
||||
MemoryFdPropertiesKHR, MemoryPropertyFlags,
|
||||
},
|
||||
},
|
||||
std::{any::Any, rc::Rc},
|
||||
uapi::OwnedFd,
|
||||
};
|
||||
|
||||
pub struct VulkanDmabufBuffer {
|
||||
pub(super) device: Rc<VulkanDevice>,
|
||||
pub(super) size: u64,
|
||||
pub(super) offset: u64,
|
||||
pub(super) buffer: vk::Buffer,
|
||||
pub(super) memory: vk::DeviceMemory,
|
||||
}
|
||||
|
||||
impl VulkanDevice {
|
||||
pub fn create_dmabuf_buffer(
|
||||
self: &Rc<Self>,
|
||||
dmabuf: &Rc<OwnedFd>,
|
||||
offset: u64,
|
||||
size: u64,
|
||||
) -> Result<Rc<VulkanDmabufBuffer>, VulkanError> {
|
||||
let mut memory_fd_properties = MemoryFdPropertiesKHR::default();
|
||||
unsafe {
|
||||
self.external_memory_fd
|
||||
.get_memory_fd_properties(
|
||||
ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
||||
dmabuf.raw(),
|
||||
&mut memory_fd_properties,
|
||||
)
|
||||
.map_err(VulkanError::MemoryFdProperties)?
|
||||
}
|
||||
let buffer = {
|
||||
let mut external_info = ExternalMemoryBufferCreateInfo::default()
|
||||
.handle_types(ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
||||
let create_info = BufferCreateInfo::default()
|
||||
.size(size)
|
||||
.usage(BufferUsageFlags::TRANSFER_SRC)
|
||||
.push_next(&mut external_info);
|
||||
unsafe {
|
||||
self.device
|
||||
.create_buffer(&create_info, None)
|
||||
.map_err(VulkanError::CreateBuffer)?
|
||||
}
|
||||
};
|
||||
let destroy_buffer = OnDrop(|| unsafe { self.device.destroy_buffer(buffer, None) });
|
||||
let requirements = unsafe { self.device.get_buffer_memory_requirements(buffer) };
|
||||
let memory_type = self.find_memory_type(
|
||||
MemoryPropertyFlags::HOST_VISIBLE,
|
||||
requirements.memory_type_bits & memory_fd_properties.memory_type_bits,
|
||||
);
|
||||
let Some(memory_type) = memory_type else {
|
||||
return Err(VulkanError::MemoryType);
|
||||
};
|
||||
let fd =
|
||||
uapi::fcntl_dupfd_cloexec(dmabuf.raw(), 0).map_err(|e| VulkanError::Dupfd(e.into()))?;
|
||||
let memory = {
|
||||
let mut import_info = ImportMemoryFdInfoKHR::default()
|
||||
.fd(fd.raw())
|
||||
.handle_type(ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
||||
let allocate_info = MemoryAllocateInfo::default()
|
||||
.allocation_size(requirements.size)
|
||||
.memory_type_index(memory_type)
|
||||
.push_next(&mut import_info);
|
||||
unsafe {
|
||||
self.device
|
||||
.allocate_memory(&allocate_info, None)
|
||||
.map_err(VulkanError::AllocateMemory)?
|
||||
}
|
||||
};
|
||||
fd.unwrap();
|
||||
let free_memory = OnDrop(|| unsafe { self.device.free_memory(memory, None) });
|
||||
unsafe {
|
||||
self.device
|
||||
.bind_buffer_memory(buffer, memory, 0)
|
||||
.map_err(VulkanError::BindBufferMemory)?;
|
||||
}
|
||||
free_memory.forget();
|
||||
destroy_buffer.forget();
|
||||
Ok(Rc::new(VulkanDmabufBuffer {
|
||||
device: self.clone(),
|
||||
size,
|
||||
offset,
|
||||
buffer,
|
||||
memory,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VulkanDmabufBuffer {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
self.device.device.free_memory(self.memory, None);
|
||||
self.device.device.destroy_buffer(self.buffer, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VulkanDmabufBuffer {
|
||||
fn assert_device(&self, device: &Device) {
|
||||
assert_eq!(
|
||||
self.device.device.handle(),
|
||||
device.handle(),
|
||||
"Mixed vulkan device use"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl GfxBuffer for VulkanDmabufBuffer {}
|
||||
|
||||
impl dyn GfxBuffer {
|
||||
pub(super) fn into_vk(self: Rc<Self>, device: &Device) -> Rc<VulkanDmabufBuffer> {
|
||||
let buffer: Rc<VulkanDmabufBuffer> = (self as Rc<dyn Any>)
|
||||
.downcast()
|
||||
.expect("Non-vulkan buffer passed into vulkan");
|
||||
buffer.assert_device(device);
|
||||
buffer
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ use {
|
|||
format::Format,
|
||||
gfx_api::{
|
||||
AcquireSync, AsyncShmGfxTexture, AsyncShmGfxTextureCallback,
|
||||
AsyncShmGfxTextureTransferCancellable, GfxApiOpt, GfxBlendBuffer, GfxError,
|
||||
AsyncShmGfxTextureTransferCancellable, GfxApiOpt, GfxBlendBuffer, GfxBuffer, GfxError,
|
||||
GfxFramebuffer, GfxImage, GfxInternalFramebuffer, GfxStagingBuffer, GfxTexture,
|
||||
PendingShmTransfer, ReleaseSync, ShmGfxTexture, ShmMemory, SyncFile,
|
||||
},
|
||||
|
|
@ -672,6 +672,20 @@ impl AsyncShmGfxTexture for VulkanImage {
|
|||
Ok(pending)
|
||||
}
|
||||
|
||||
fn async_upload_from_buffer(
|
||||
self: Rc<Self>,
|
||||
buf: &Rc<dyn GfxBuffer>,
|
||||
callback: Rc<dyn AsyncShmGfxTextureCallback>,
|
||||
damage: Region,
|
||||
) -> Result<Option<PendingShmTransfer>, GfxError> {
|
||||
let VulkanImageMemory::Internal(shm) = &self.ty else {
|
||||
unreachable!();
|
||||
};
|
||||
let buf = buf.clone().into_vk(&self.renderer.device.device);
|
||||
let pending = shm.async_transfer2(&self, buf, damage, callback)?;
|
||||
Ok(pending)
|
||||
}
|
||||
|
||||
fn sync_upload(self: Rc<Self>, mem: &[Cell<u8>], damage: Region) -> Result<(), GfxError> {
|
||||
let VulkanImageMemory::Internal(shm) = &self.ty else {
|
||||
unreachable!();
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use {
|
|||
utils::{errorfmt::ErrorFmt, on_drop::OnDrop},
|
||||
},
|
||||
ash::vk::{
|
||||
AccessFlags2, BufferImageCopy2, BufferMemoryBarrier2, CommandBufferBeginInfo,
|
||||
AccessFlags2, Buffer, BufferImageCopy2, BufferMemoryBarrier2, CommandBufferBeginInfo,
|
||||
CommandBufferSubmitInfo, CommandBufferUsageFlags, CopyBufferToImageInfo2,
|
||||
CopyImageToBufferInfo2, DependencyInfoKHR, DeviceSize, Extent3D, ImageAspectFlags,
|
||||
ImageCreateInfo, ImageLayout, ImageSubresourceLayers, ImageSubresourceRange, ImageTiling,
|
||||
|
|
@ -136,8 +136,14 @@ impl VulkanShmImage {
|
|||
ptr::copy_nonoverlapping(buf, mem, total_size as usize);
|
||||
}
|
||||
})?;
|
||||
let (cmd, fence, sync_file, point) =
|
||||
self.submit_buffer_image_copy(img, &staging, cpy, false, TransferType::Upload)?;
|
||||
let (cmd, fence, sync_file, point) = self.submit_buffer_image_copy(
|
||||
img,
|
||||
staging.buffer,
|
||||
staging.size,
|
||||
cpy,
|
||||
false,
|
||||
TransferType::Upload,
|
||||
)?;
|
||||
let future = img.renderer.eng.spawn(
|
||||
"await upload",
|
||||
await_upload(point, img.clone(), cmd, sync_file, fence, staging),
|
||||
|
|
@ -149,7 +155,8 @@ impl VulkanShmImage {
|
|||
pub(super) fn submit_buffer_image_copy(
|
||||
&self,
|
||||
img: &Rc<VulkanImage>,
|
||||
staging: &VulkanStagingBuffer,
|
||||
buffer: Buffer,
|
||||
size: DeviceSize,
|
||||
regions: &[BufferImageCopy2],
|
||||
use_transfer_queue: bool,
|
||||
tt: TransferType,
|
||||
|
|
@ -164,9 +171,9 @@ impl VulkanShmImage {
|
|||
> {
|
||||
let memory_barrier = |sam, ssm, dam, dsm| {
|
||||
BufferMemoryBarrier2::default()
|
||||
.buffer(staging.buffer)
|
||||
.buffer(buffer)
|
||||
.offset(0)
|
||||
.size(staging.size)
|
||||
.size(size)
|
||||
.src_access_mask(sam)
|
||||
.src_stage_mask(ssm)
|
||||
.dst_access_mask(dam)
|
||||
|
|
@ -274,7 +281,7 @@ impl VulkanShmImage {
|
|||
match tt {
|
||||
TransferType::Upload => {
|
||||
let cpy_info = CopyBufferToImageInfo2::default()
|
||||
.src_buffer(staging.buffer)
|
||||
.src_buffer(buffer)
|
||||
.dst_image(img.image)
|
||||
.dst_image_layout(ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.regions(regions);
|
||||
|
|
@ -282,7 +289,7 @@ impl VulkanShmImage {
|
|||
}
|
||||
TransferType::Download => {
|
||||
let cpy_info = CopyImageToBufferInfo2::default()
|
||||
.dst_buffer(staging.buffer)
|
||||
.dst_buffer(buffer)
|
||||
.src_image(img.image)
|
||||
.src_image_layout(ImageLayout::TRANSFER_SRC_OPTIMAL)
|
||||
.regions(regions);
|
||||
|
|
@ -432,6 +439,7 @@ impl VulkanRenderer {
|
|||
io_job: Default::default(),
|
||||
copy_job: Default::default(),
|
||||
staging: Default::default(),
|
||||
buffer: Default::default(),
|
||||
client_mem: Default::default(),
|
||||
callback: Default::default(),
|
||||
callback_id: Cell::new(0),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use {
|
|||
gfx_apis::vulkan::{
|
||||
VulkanError,
|
||||
command::VulkanCommandBuffer,
|
||||
dmabuf_buffer::VulkanDmabufBuffer,
|
||||
fence::VulkanFence,
|
||||
image::{QueueFamily, QueueState, QueueTransfer, VulkanImage, VulkanImageMemory},
|
||||
renderer::image_barrier,
|
||||
|
|
@ -29,7 +30,7 @@ use {
|
|||
ImageSubresourceLayers, Offset3D, PipelineStageFlags2, SubmitInfo2,
|
||||
},
|
||||
std::{
|
||||
cell::{Cell, RefCell},
|
||||
cell::{Cell, RefCell, RefMut},
|
||||
rc::Rc,
|
||||
slice,
|
||||
},
|
||||
|
|
@ -41,6 +42,7 @@ pub struct VulkanShmImageAsyncData {
|
|||
pub(super) io_job: Cell<Option<Box<IoTransferJob>>>,
|
||||
pub(super) copy_job: Cell<Option<Box<CopyTransferJob>>>,
|
||||
pub(super) staging: CloneCell<Option<Rc<VulkanStagingShell>>>,
|
||||
pub(super) buffer: CloneCell<Option<Rc<VulkanDmabufBuffer>>>,
|
||||
pub(super) client_mem: CloneCell<Option<Rc<dyn ShmMemory>>>,
|
||||
pub(super) callback: Cell<Option<Rc<dyn AsyncShmGfxTextureCallback>>>,
|
||||
pub(super) callback_id: Cell<u64>,
|
||||
|
|
@ -53,7 +55,10 @@ pub struct VulkanShmImageAsyncData {
|
|||
impl VulkanShmImageAsyncData {
|
||||
fn complete(&self, result: Result<(), VulkanError>) {
|
||||
self.busy.set(false);
|
||||
self.staging.take().unwrap().busy.set(false);
|
||||
if let Some(staging) = self.staging.take() {
|
||||
staging.busy.set(false);
|
||||
}
|
||||
self.buffer.take();
|
||||
self.client_mem.take();
|
||||
if let Some(cb) = self.callback.take() {
|
||||
cb.completed(result.map_err(|e| e.into()));
|
||||
|
|
@ -68,6 +73,43 @@ pub(super) enum TransferType {
|
|||
}
|
||||
|
||||
impl VulkanShmImage {
|
||||
pub fn async_transfer2(
|
||||
&self,
|
||||
img: &Rc<VulkanImage>,
|
||||
buffer: Rc<VulkanDmabufBuffer>,
|
||||
damage: Region,
|
||||
callback: Rc<dyn AsyncShmGfxTextureCallback>,
|
||||
) -> Result<Option<PendingShmTransfer>, VulkanError> {
|
||||
self.async_transfer_(img, damage, callback, |data, damage| {
|
||||
self.try_async_transfer2(img, buffer, data, damage)
|
||||
})
|
||||
}
|
||||
|
||||
fn try_async_transfer2(
|
||||
&self,
|
||||
img: &Rc<VulkanImage>,
|
||||
buffer: Rc<VulkanDmabufBuffer>,
|
||||
data: &VulkanShmImageAsyncData,
|
||||
mut damage: Region,
|
||||
) -> Result<(), VulkanError> {
|
||||
if data.busy.get() {
|
||||
return Err(VulkanError::AsyncCopyBusy);
|
||||
}
|
||||
if self.size > buffer.size {
|
||||
return Err(VulkanError::InvalidBufferSize);
|
||||
}
|
||||
data.busy.set(true);
|
||||
data.data_copied.set(true);
|
||||
data.buffer.set(Some(buffer.clone()));
|
||||
if img.contents_are_undefined.get() {
|
||||
damage = Region::new(Rect::new_sized(0, 0, img.width as _, img.height as _).unwrap());
|
||||
}
|
||||
self.calculate_copies(img, data, damage, buffer.offset);
|
||||
self.async_release_from_gfx_queue(img, data, TransferType::Upload)?;
|
||||
self.async_upload_copy_buffer_to_image(img, data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn async_transfer(
|
||||
&self,
|
||||
img: &Rc<VulkanImage>,
|
||||
|
|
@ -76,12 +118,24 @@ impl VulkanShmImage {
|
|||
damage: Region,
|
||||
callback: Rc<dyn AsyncShmGfxTextureCallback>,
|
||||
tt: TransferType,
|
||||
) -> Result<Option<PendingShmTransfer>, VulkanError> {
|
||||
self.async_transfer_(img, damage, callback, |data, damage| {
|
||||
self.try_async_transfer(img, staging, data, client_mem, damage, tt)
|
||||
})
|
||||
}
|
||||
|
||||
fn async_transfer_(
|
||||
&self,
|
||||
img: &Rc<VulkanImage>,
|
||||
damage: Region,
|
||||
callback: Rc<dyn AsyncShmGfxTextureCallback>,
|
||||
f: impl FnOnce(&VulkanShmImageAsyncData, Region) -> Result<(), VulkanError>,
|
||||
) -> Result<Option<PendingShmTransfer>, VulkanError> {
|
||||
if damage.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let data = self.async_data.as_ref().unwrap();
|
||||
let res = self.try_async_transfer(img, staging, data, client_mem, damage, tt);
|
||||
let res = f(data, damage);
|
||||
match res {
|
||||
Ok(()) => {
|
||||
let id = img.renderer.allocate_point();
|
||||
|
|
@ -135,53 +189,7 @@ impl VulkanShmImage {
|
|||
damage = Region::new(Rect::new_sized(0, 0, img.width as _, img.height as _).unwrap());
|
||||
}
|
||||
|
||||
let copies = &mut *data.regions.borrow_mut();
|
||||
copies.clear();
|
||||
|
||||
let mut copy = |x, y, width, height| {
|
||||
let buffer_offset = (y as u32 * img.stride + x as u32 * self.shm_info.bpp) as u64;
|
||||
let copy = BufferImageCopy2::default()
|
||||
.buffer_offset(buffer_offset)
|
||||
.image_offset(Offset3D { x, y, z: 0 })
|
||||
.image_extent(Extent3D {
|
||||
width,
|
||||
height,
|
||||
depth: 1,
|
||||
})
|
||||
.image_subresource(ImageSubresourceLayers {
|
||||
aspect_mask: ImageAspectFlags::COLOR,
|
||||
mip_level: 0,
|
||||
base_array_layer: 0,
|
||||
layer_count: 1,
|
||||
})
|
||||
.buffer_image_height(img.height)
|
||||
.buffer_row_length(img.stride / self.shm_info.bpp);
|
||||
copies.push(copy);
|
||||
};
|
||||
let (width_mask, height_mask) = img.renderer.device.transfer_granularity_mask;
|
||||
let width_mask = width_mask as i32;
|
||||
let height_mask = height_mask as i32;
|
||||
for damage in damage.rects() {
|
||||
if damage.x2() < 0 || damage.y2() < 0 {
|
||||
continue;
|
||||
}
|
||||
let x1 = damage.x1().max(0) & !width_mask;
|
||||
let y1 = damage.y1().max(0) & !height_mask;
|
||||
let x2 = ((damage.x2() + width_mask) & !width_mask).min(img.width as i32);
|
||||
let y2 = ((damage.y2() + height_mask) & !height_mask).min(img.height as i32);
|
||||
let Some(damage) = Rect::new(x1, y1, x2, y2) else {
|
||||
continue;
|
||||
};
|
||||
if damage.is_empty() {
|
||||
continue;
|
||||
}
|
||||
copy(
|
||||
damage.x1(),
|
||||
damage.y1(),
|
||||
damage.width() as u32,
|
||||
damage.height() as u32,
|
||||
);
|
||||
}
|
||||
let copies = &mut *self.calculate_copies(img, data, damage, 0);
|
||||
|
||||
self.async_release_from_gfx_queue(img, data, tt)?;
|
||||
|
||||
|
|
@ -209,6 +217,67 @@ impl VulkanShmImage {
|
|||
})
|
||||
}
|
||||
|
||||
fn calculate_copies<'a>(
|
||||
&self,
|
||||
img: &Rc<VulkanImage>,
|
||||
data: &'a VulkanShmImageAsyncData,
|
||||
damage: Region,
|
||||
extra_offset: u64,
|
||||
) -> RefMut<'a, Vec<BufferImageCopy2<'static>>> {
|
||||
let mut copies_ref = data.regions.borrow_mut();
|
||||
let copies = &mut *copies_ref;
|
||||
copies.clear();
|
||||
let mut copy = |x, y, width, height| {
|
||||
let buffer_offset = (y as u32 * img.stride + x as u32 * self.shm_info.bpp) as u64;
|
||||
let copy = BufferImageCopy2::default()
|
||||
.buffer_offset(buffer_offset + extra_offset)
|
||||
.image_offset(Offset3D { x, y, z: 0 })
|
||||
.image_extent(Extent3D {
|
||||
width,
|
||||
height,
|
||||
depth: 1,
|
||||
})
|
||||
.image_subresource(ImageSubresourceLayers {
|
||||
aspect_mask: ImageAspectFlags::COLOR,
|
||||
mip_level: 0,
|
||||
base_array_layer: 0,
|
||||
layer_count: 1,
|
||||
})
|
||||
.buffer_image_height(img.height)
|
||||
.buffer_row_length(img.stride / self.shm_info.bpp);
|
||||
copies.push(copy);
|
||||
};
|
||||
let (width_mask, height_mask) = img.renderer.device.transfer_granularity_mask;
|
||||
let width_mask = width_mask as i32;
|
||||
let height_mask = height_mask as i32;
|
||||
for damage in damage.rects() {
|
||||
if damage.x2() < 0 || damage.y2() < 0 {
|
||||
continue;
|
||||
}
|
||||
let x1 = damage.x1().max(0);
|
||||
let y1 = damage.y1().max(0);
|
||||
let x2 = damage.x2().min(img.width as i32);
|
||||
let y2 = damage.y2().min(img.height as i32);
|
||||
let x1 = x1 & !width_mask;
|
||||
let y1 = y1 & !height_mask;
|
||||
let x2 = ((x2 + width_mask) & !width_mask).min(img.width as i32);
|
||||
let y2 = ((y2 + height_mask) & !height_mask).min(img.height as i32);
|
||||
let Some(damage) = Rect::new(x1, y1, x2, y2) else {
|
||||
continue;
|
||||
};
|
||||
if damage.is_empty() {
|
||||
continue;
|
||||
}
|
||||
copy(
|
||||
damage.x1(),
|
||||
damage.y1(),
|
||||
damage.width() as u32,
|
||||
damage.height() as u32,
|
||||
);
|
||||
}
|
||||
copies_ref
|
||||
}
|
||||
|
||||
fn async_release_from_gfx_queue(
|
||||
&self,
|
||||
img: &Rc<VulkanImage>,
|
||||
|
|
@ -451,10 +520,19 @@ impl VulkanShmImage {
|
|||
}
|
||||
img.renderer.check_defunct()?;
|
||||
let regions = &*data.regions.borrow();
|
||||
let staging = data.staging.get().unwrap().staging.get().unwrap();
|
||||
staging.upload(|_, _| ())?;
|
||||
let (buffer, size) = match data.staging.get() {
|
||||
Some(s) => {
|
||||
let staging = s.staging.get().unwrap();
|
||||
staging.upload(|_, _| ())?;
|
||||
(staging.buffer, staging.size)
|
||||
}
|
||||
_ => {
|
||||
let host_buffer = data.buffer.get().unwrap();
|
||||
(host_buffer.buffer, host_buffer.size)
|
||||
}
|
||||
};
|
||||
let (cmd, fence, sync_file, point) =
|
||||
self.submit_buffer_image_copy(img, &staging, regions, true, TransferType::Upload)?;
|
||||
self.submit_buffer_image_copy(img, buffer, size, regions, true, TransferType::Upload)?;
|
||||
img.queue_state.set(QueueState::Releasing);
|
||||
let future = img.renderer.eng.spawn(
|
||||
"await async upload",
|
||||
|
|
@ -481,8 +559,14 @@ impl VulkanShmImage {
|
|||
return Ok(());
|
||||
}
|
||||
img.renderer.check_defunct()?;
|
||||
let (cmd, fence, sync_file, point) =
|
||||
self.submit_buffer_image_copy(img, &staging, copies, true, TransferType::Download)?;
|
||||
let (cmd, fence, sync_file, point) = self.submit_buffer_image_copy(
|
||||
img,
|
||||
staging.buffer,
|
||||
staging.size,
|
||||
copies,
|
||||
true,
|
||||
TransferType::Download,
|
||||
)?;
|
||||
img.queue_state.set(QueueState::Releasing);
|
||||
let future = img.renderer.eng.spawn(
|
||||
"await async image to buffer copy",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue