Skip to main content

zvidlib_core/
transfer.rs

1//! Frame transfer contracts shared by CPU, native OpenGL, and browser WebGL backends.
2//!
3//! Graphics objects remain opaque and caller-owned unless explicitly marked otherwise. Backends
4//! perform the actual GL/WebGL calls through [`GraphicsAdapter`], after this module validates the
5//! context identity, execution owner, current-context state, context-loss state, and transfer
6//! policy.
7
8use crate::{
9    ColorRange, Error, ErrorKind, PixelFormat, Plane, Result, TransferMode, VideoDimensions,
10    VideoFrame,
11};
12
13/// The graphics API that created a resource.
14#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
15pub enum GraphicsApi {
16    NativeOpenGl,
17    WebGl,
18}
19
20/// Stable identity supplied by a context adapter. It is never inferred from a GL object name.
21#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
22pub struct ContextIdentity(pub u64);
23
24/// Identity of the thread, worker, or executor allowed to use a context.
25#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
26pub struct ExecutionOwner(pub u64);
27
28/// Kind of caller- or library-supplied graphics object.
29#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
30pub enum ResourceKind {
31    Texture2d,
32    Framebuffer,
33}
34
35/// Determines whether zvidlib may ask the adapter to delete a resource.
36#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
37pub enum ResourceOwnership {
38    Caller,
39    Library,
40}
41
42/// An opaque native GL or browser WebGL image resource.
43#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
44pub struct GraphicsResource {
45    api: GraphicsApi,
46    context: ContextIdentity,
47    owner: ExecutionOwner,
48    kind: ResourceKind,
49    handle: u64,
50    dimensions: VideoDimensions,
51    pixel_format: PixelFormat,
52    color_range: ColorRange,
53    orientation: Orientation,
54    ownership: ResourceOwnership,
55}
56
57impl GraphicsResource {
58    #[allow(clippy::too_many_arguments)]
59    pub const fn new(
60        api: GraphicsApi,
61        context: ContextIdentity,
62        owner: ExecutionOwner,
63        kind: ResourceKind,
64        handle: u64,
65        dimensions: VideoDimensions,
66        pixel_format: PixelFormat,
67        color_range: ColorRange,
68        orientation: Orientation,
69        ownership: ResourceOwnership,
70    ) -> Self {
71        Self {
72            api,
73            context,
74            owner,
75            kind,
76            handle,
77            dimensions,
78            pixel_format,
79            color_range,
80            orientation,
81            ownership,
82        }
83    }
84
85    pub const fn api(self) -> GraphicsApi {
86        self.api
87    }
88    pub const fn context(self) -> ContextIdentity {
89        self.context
90    }
91    pub const fn owner(self) -> ExecutionOwner {
92        self.owner
93    }
94    pub const fn kind(self) -> ResourceKind {
95        self.kind
96    }
97    pub const fn handle(self) -> u64 {
98        self.handle
99    }
100    pub const fn dimensions(self) -> VideoDimensions {
101        self.dimensions
102    }
103    pub const fn pixel_format(self) -> PixelFormat {
104        self.pixel_format
105    }
106    pub const fn color_range(self) -> ColorRange {
107        self.color_range
108    }
109    pub const fn orientation(self) -> Orientation {
110        self.orientation
111    }
112    pub const fn ownership(self) -> ResourceOwnership {
113        self.ownership
114    }
115
116    /// Releases a library-owned resource. Caller-owned handles are deliberately left untouched.
117    pub fn release(self, adapter: &mut dyn GraphicsAdapter) -> Result<()> {
118        validate_resource(adapter, self)?;
119        if self.ownership == ResourceOwnership::Library {
120            adapter.delete(self)?;
121        }
122        Ok(())
123    }
124}
125
126/// Location of the first logical row in memory or a graphics resource.
127#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
128pub enum Orientation {
129    TopLeft,
130    BottomLeft,
131}
132
133/// Scaling filter requested by a transfer.
134#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
135pub enum ScaleFilter {
136    Nearest,
137    Linear,
138}
139
140/// Whether numeric color values must be converted.
141#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
142pub enum ColorConversion {
143    None,
144    FullToLimited,
145    LimitedToFull,
146}
147
148/// An explicit, inspectable operation in a transfer plan.
149#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
150pub enum TransferStage {
151    RepackStride,
152    Scale { filter: ScaleFilter },
153    FlipVertical,
154    ConvertPixelFormat { from: PixelFormat, to: PixelFormat },
155    ConvertColor { conversion: ColorConversion },
156}
157
158/// Runtime support and the cost/stages of a proposed transfer.
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct TransferCapability {
161    pub mode: TransferMode,
162    pub stages: Vec<TransferStage>,
163    pub reason: Option<String>,
164}
165
166impl TransferCapability {
167    fn supported(mode: TransferMode, stages: Vec<TransferStage>) -> Self {
168        Self {
169            mode,
170            stages,
171            reason: None,
172        }
173    }
174
175    fn unsupported(reason: impl Into<String>) -> Self {
176        Self {
177            mode: TransferMode::Unsupported,
178            stages: Vec::new(),
179            reason: Some(reason.into()),
180        }
181    }
182
183    pub fn is_supported(&self) -> bool {
184        self.mode != TransferMode::Unsupported
185    }
186}
187
188/// Cost policy for a frame transfer. `require` is strict and never silently falls back.
189#[derive(Clone, Copy, Debug, Eq, PartialEq)]
190pub struct TransferPolicy {
191    required: Option<TransferMode>,
192    scale_filter: ScaleFilter,
193}
194
195impl TransferPolicy {
196    pub const fn any() -> Self {
197        Self {
198            required: None,
199            scale_filter: ScaleFilter::Nearest,
200        }
201    }
202    pub const fn require(mode: TransferMode) -> Self {
203        Self {
204            required: Some(mode),
205            scale_filter: ScaleFilter::Nearest,
206        }
207    }
208    pub const fn with_scale_filter(mut self, filter: ScaleFilter) -> Self {
209        self.scale_filter = filter;
210        self
211    }
212    pub const fn required(self) -> Option<TransferMode> {
213        self.required
214    }
215}
216
217impl Default for TransferPolicy {
218    fn default() -> Self {
219        Self::any()
220    }
221}
222
223/// A borrowed CPU source with explicit orientation. Plane strides live on [`Plane`].
224#[derive(Clone, Copy, Debug)]
225pub struct CpuFrameSource<'a> {
226    pub frame: &'a VideoFrame,
227    pub orientation: Orientation,
228}
229
230/// A borrowed mutable destination plane with an explicit row stride.
231#[derive(Debug)]
232pub struct CpuPlaneDestination<'a> {
233    pub data: &'a mut [u8],
234    pub stride: usize,
235}
236
237/// A borrowed CPU destination. It does not take ownership of caller memory.
238#[derive(Debug)]
239pub struct CpuFrameDestination<'a> {
240    pub dimensions: VideoDimensions,
241    pub pixel_format: PixelFormat,
242    pub color_range: ColorRange,
243    pub orientation: Orientation,
244    pub planes: Vec<CpuPlaneDestination<'a>>,
245}
246
247#[derive(Clone, Copy, Debug)]
248pub enum FrameSource<'a> {
249    Cpu(CpuFrameSource<'a>),
250    Graphics(GraphicsResource),
251}
252
253#[derive(Debug)]
254pub enum FrameDestination<'a> {
255    Cpu(CpuFrameDestination<'a>),
256    Graphics(GraphicsResource),
257}
258
259/// Platform adapter that performs native GL or WebGL operations.
260///
261/// Implementations must not retain borrowed CPU memory after a call returns. The portable layer
262/// invokes these methods only after checking context identity, execution owner, current state,
263/// context loss, API identity, and policy.
264pub trait GraphicsAdapter {
265    fn api(&self) -> GraphicsApi;
266    fn context_identity(&self) -> ContextIdentity;
267    fn execution_owner(&self) -> ExecutionOwner;
268    fn is_current(&self) -> bool;
269    fn is_context_lost(&self) -> bool;
270    fn capability(
271        &self,
272        source: FrameSource<'_>,
273        destination: &FrameDestination<'_>,
274    ) -> TransferMode;
275    fn upload(
276        &mut self,
277        source: CpuFrameSource<'_>,
278        destination: GraphicsResource,
279        stages: &[TransferStage],
280    ) -> Result<()>;
281    fn readback(
282        &mut self,
283        source: GraphicsResource,
284        destination: CpuFrameDestination<'_>,
285        stages: &[TransferStage],
286    ) -> Result<()>;
287    fn copy(
288        &mut self,
289        source: GraphicsResource,
290        destination: GraphicsResource,
291        stages: &[TransferStage],
292    ) -> Result<()>;
293    fn delete(&mut self, resource: GraphicsResource) -> Result<()>;
294}
295
296/// Inspects a transfer without executing it.
297pub fn inspect_transfer(
298    adapter: Option<&dyn GraphicsAdapter>,
299    source: FrameSource<'_>,
300    destination: &FrameDestination<'_>,
301    policy: TransferPolicy,
302) -> TransferCapability {
303    let mut stages = stages_for(source, destination, policy.scale_filter);
304    let mode = match (source, destination) {
305        (FrameSource::Cpu(_), FrameDestination::Cpu(_)) => TransferMode::CpuCopy,
306        _ => {
307            let Some(adapter) = adapter else {
308                return TransferCapability::unsupported(
309                    "a graphics transfer requires a context adapter",
310                );
311            };
312            if let Err(error) = validate_endpoints(adapter, source, destination) {
313                return TransferCapability::unsupported(error.message());
314            }
315            adapter.capability(source, destination)
316        }
317    };
318    if mode == TransferMode::Unsupported {
319        return TransferCapability::unsupported("the backend does not support this transfer");
320    }
321    if let Some(required) = policy.required {
322        if required == TransferMode::Unsupported || required != mode {
323            return TransferCapability::unsupported(format!(
324                "strict policy requires {required:?}, but the backend reports {mode:?}"
325            ));
326        }
327    }
328    if mode == TransferMode::Shared && !stages.is_empty() {
329        return TransferCapability::unsupported(
330            "the backend reported a shared transfer that requires conversion stages",
331        );
332    }
333    if mode == TransferMode::Shared {
334        stages.clear();
335    }
336    TransferCapability::supported(mode, stages)
337}
338
339/// Validates, plans, and executes a CPU/GL/WebGL transfer.
340pub fn execute_transfer(
341    mut adapter: Option<&mut dyn GraphicsAdapter>,
342    source: FrameSource<'_>,
343    destination: FrameDestination<'_>,
344    policy: TransferPolicy,
345) -> Result<TransferCapability> {
346    let capability = inspect_transfer(adapter.as_deref(), source, &destination, policy);
347    if !capability.is_supported() {
348        return Err(Error::new(
349            ErrorKind::Unsupported,
350            capability
351                .reason
352                .clone()
353                .unwrap_or_else(|| "unsupported frame transfer".into()),
354        ));
355    }
356    match (source, destination) {
357        (FrameSource::Cpu(source), FrameDestination::Cpu(destination)) => {
358            transfer_cpu(source, destination, policy.scale_filter)?
359        }
360        (FrameSource::Cpu(source), FrameDestination::Graphics(destination)) => adapter
361            .as_mut()
362            .expect("validated adapter")
363            .upload(source, destination, &capability.stages)?,
364        (FrameSource::Graphics(source), FrameDestination::Cpu(destination)) => adapter
365            .as_mut()
366            .expect("validated adapter")
367            .readback(source, destination, &capability.stages)?,
368        (FrameSource::Graphics(source), FrameDestination::Graphics(destination)) => adapter
369            .as_mut()
370            .expect("validated adapter")
371            .copy(source, destination, &capability.stages)?,
372    }
373    Ok(capability)
374}
375
376fn validate_endpoints(
377    adapter: &dyn GraphicsAdapter,
378    source: FrameSource<'_>,
379    destination: &FrameDestination<'_>,
380) -> Result<()> {
381    if adapter.is_context_lost() {
382        return Err(graphics("the graphics context is lost"));
383    }
384    if !adapter.is_current() {
385        return Err(graphics("the graphics context is not current"));
386    }
387    if let FrameSource::Graphics(resource) = source {
388        validate_resource(adapter, resource)?;
389    }
390    if let FrameDestination::Graphics(resource) = destination {
391        validate_resource(adapter, *resource)?;
392    }
393    Ok(())
394}
395
396fn validate_resource(adapter: &dyn GraphicsAdapter, resource: GraphicsResource) -> Result<()> {
397    if adapter.is_context_lost() {
398        return Err(graphics("the graphics context is lost"));
399    }
400    if !adapter.is_current() {
401        return Err(graphics("the graphics context is not current"));
402    }
403    if resource.api != adapter.api() {
404        return Err(graphics("graphics resource API does not match the adapter"));
405    }
406    if resource.context != adapter.context_identity() {
407        return Err(graphics("graphics resource belongs to a different context"));
408    }
409    if resource.owner != adapter.execution_owner() {
410        return Err(graphics(
411            "graphics resource belongs to a different execution owner",
412        ));
413    }
414    Ok(())
415}
416
417fn stages_for(
418    source: FrameSource<'_>,
419    destination: &FrameDestination<'_>,
420    filter: ScaleFilter,
421) -> Vec<TransferStage> {
422    let (source_dimensions, source_format, source_range, source_orientation, source_stride) =
423        source_metadata(source);
424    let (
425        destination_dimensions,
426        destination_format,
427        destination_range,
428        destination_orientation,
429        destination_stride,
430    ) = destination_metadata(destination);
431    let mut stages = Vec::new();
432    if source_stride != destination_stride {
433        stages.push(TransferStage::RepackStride);
434    }
435    if source_dimensions != destination_dimensions {
436        stages.push(TransferStage::Scale { filter });
437    }
438    if source_orientation != destination_orientation {
439        stages.push(TransferStage::FlipVertical);
440    }
441    if source_format != destination_format {
442        stages.push(TransferStage::ConvertPixelFormat {
443            from: source_format,
444            to: destination_format,
445        });
446    }
447    if source_range != destination_range {
448        let conversion = match (source_range, destination_range) {
449            (ColorRange::Full, ColorRange::Limited) => ColorConversion::FullToLimited,
450            (ColorRange::Limited, ColorRange::Full) => ColorConversion::LimitedToFull,
451            _ => ColorConversion::None,
452        };
453        stages.push(TransferStage::ConvertColor { conversion });
454    }
455    stages
456}
457
458fn source_metadata(
459    source: FrameSource<'_>,
460) -> (
461    VideoDimensions,
462    PixelFormat,
463    ColorRange,
464    Orientation,
465    Option<usize>,
466) {
467    match source {
468        FrameSource::Cpu(cpu) => (
469            cpu.frame.dimensions,
470            cpu.frame.pixel_format,
471            cpu.frame.color_range,
472            cpu.orientation,
473            cpu.frame.planes.first().map(|plane| plane.stride),
474        ),
475        FrameSource::Graphics(resource) => (
476            resource.dimensions,
477            resource.pixel_format,
478            resource.color_range,
479            resource.orientation,
480            None,
481        ),
482    }
483}
484
485fn destination_metadata(
486    destination: &FrameDestination<'_>,
487) -> (
488    VideoDimensions,
489    PixelFormat,
490    ColorRange,
491    Orientation,
492    Option<usize>,
493) {
494    match destination {
495        FrameDestination::Cpu(cpu) => (
496            cpu.dimensions,
497            cpu.pixel_format,
498            cpu.color_range,
499            cpu.orientation,
500            cpu.planes.first().map(|plane| plane.stride),
501        ),
502        FrameDestination::Graphics(resource) => (
503            resource.dimensions,
504            resource.pixel_format,
505            resource.color_range,
506            resource.orientation,
507            None,
508        ),
509    }
510}
511
512fn transfer_cpu(
513    source: CpuFrameSource<'_>,
514    mut destination: CpuFrameDestination<'_>,
515    filter: ScaleFilter,
516) -> Result<()> {
517    if filter == ScaleFilter::Linear && source.frame.dimensions != destination.dimensions {
518        return Err(Error::new(
519            ErrorKind::Unsupported,
520            "linear CPU scaling is not implemented",
521        ));
522    }
523    validate_cpu_destination(&destination)?;
524    if !is_packed(source.frame.pixel_format) || !is_packed(destination.pixel_format) {
525        return Err(Error::new(
526            ErrorKind::Unsupported,
527            "CPU conversion currently supports packed RGBA, BGRA, RGB, and gray frames",
528        ));
529    }
530    let source_width = usize::try_from(source.frame.dimensions.width)
531        .map_err(|_| limit("source width cannot be represented"))?;
532    let source_height = usize::try_from(source.frame.dimensions.height)
533        .map_err(|_| limit("source height cannot be represented"))?;
534    let destination_width = usize::try_from(destination.dimensions.width)
535        .map_err(|_| limit("destination width cannot be represented"))?;
536    let destination_height = usize::try_from(destination.dimensions.height)
537        .map_err(|_| limit("destination height cannot be represented"))?;
538    let source_plane = &source.frame.planes[0];
539    let destination_plane = &mut destination.planes[0];
540    for destination_y in 0..destination_height {
541        let logical_source_y = destination_y
542            .checked_mul(source_height)
543            .ok_or_else(|| limit("vertical scale overflow"))?
544            / destination_height;
545        let source_y = orient_row(logical_source_y, source_height, source.orientation);
546        let output_y = orient_row(destination_y, destination_height, destination.orientation);
547        for destination_x in 0..destination_width {
548            let source_x = destination_x
549                .checked_mul(source_width)
550                .ok_or_else(|| limit("horizontal scale overflow"))?
551                / destination_width;
552            let mut rgba = read_pixel(source_plane, source.frame.pixel_format, source_x, source_y)?;
553            convert_range(&mut rgba, source.frame.color_range, destination.color_range);
554            write_pixel(
555                destination_plane,
556                destination.pixel_format,
557                destination_x,
558                output_y,
559                rgba,
560            )?;
561        }
562    }
563    Ok(())
564}
565
566fn validate_cpu_destination(destination: &CpuFrameDestination<'_>) -> Result<()> {
567    if destination.planes.len() != 1 || !is_packed(destination.pixel_format) {
568        return Err(Error::new(
569            ErrorKind::Unsupported,
570            "CPU destination must contain one supported packed plane",
571        ));
572    }
573    let width = usize::try_from(destination.dimensions.width)
574        .map_err(|_| limit("destination width cannot be represented"))?;
575    let height = usize::try_from(destination.dimensions.height)
576        .map_err(|_| limit("destination height cannot be represented"))?;
577    let row_bytes = width
578        .checked_mul(bytes_per_pixel(destination.pixel_format)?)
579        .ok_or_else(|| limit("destination row size overflow"))?;
580    let plane = &destination.planes[0];
581    if plane.stride < row_bytes {
582        return Err(invalid("destination stride is too small"));
583    }
584    let length = plane
585        .stride
586        .checked_mul(height)
587        .ok_or_else(|| limit("destination plane size overflow"))?;
588    if plane.data.len() < length {
589        return Err(invalid(
590            "destination plane is shorter than its stride and height",
591        ));
592    }
593    Ok(())
594}
595
596fn is_packed(format: PixelFormat) -> bool {
597    matches!(
598        format,
599        PixelFormat::Rgba8 | PixelFormat::Bgra8 | PixelFormat::Rgb8 | PixelFormat::Gray8
600    )
601}
602
603fn bytes_per_pixel(format: PixelFormat) -> Result<usize> {
604    match format {
605        PixelFormat::Rgba8 | PixelFormat::Bgra8 => Ok(4),
606        PixelFormat::Rgb8 => Ok(3),
607        PixelFormat::Gray8 => Ok(1),
608        _ => Err(Error::new(
609            ErrorKind::Unsupported,
610            "pixel format is not a packed CPU format",
611        )),
612    }
613}
614
615fn orient_row(row: usize, height: usize, orientation: Orientation) -> usize {
616    match orientation {
617        Orientation::TopLeft => row,
618        Orientation::BottomLeft => height - 1 - row,
619    }
620}
621
622fn read_pixel(plane: &Plane, format: PixelFormat, x: usize, y: usize) -> Result<[u8; 4]> {
623    let offset = y
624        .checked_mul(plane.stride)
625        .and_then(|row| row.checked_add(x.checked_mul(bytes_per_pixel(format).ok()?)?))
626        .ok_or_else(|| limit("source pixel offset overflow"))?;
627    let data = &plane.data;
628    match format {
629        PixelFormat::Rgba8 => Ok([
630            data[offset],
631            data[offset + 1],
632            data[offset + 2],
633            data[offset + 3],
634        ]),
635        PixelFormat::Bgra8 => Ok([
636            data[offset + 2],
637            data[offset + 1],
638            data[offset],
639            data[offset + 3],
640        ]),
641        PixelFormat::Rgb8 => Ok([data[offset], data[offset + 1], data[offset + 2], 255]),
642        PixelFormat::Gray8 => Ok([data[offset], data[offset], data[offset], 255]),
643        _ => Err(Error::new(
644            ErrorKind::Unsupported,
645            "pixel format is not a packed CPU format",
646        )),
647    }
648}
649
650fn write_pixel(
651    plane: &mut CpuPlaneDestination<'_>,
652    format: PixelFormat,
653    x: usize,
654    y: usize,
655    rgba: [u8; 4],
656) -> Result<()> {
657    let offset = y
658        .checked_mul(plane.stride)
659        .and_then(|row| row.checked_add(x.checked_mul(bytes_per_pixel(format).ok()?)?))
660        .ok_or_else(|| limit("destination pixel offset overflow"))?;
661    match format {
662        PixelFormat::Rgba8 => plane.data[offset..offset + 4].copy_from_slice(&rgba),
663        PixelFormat::Bgra8 => {
664            plane.data[offset..offset + 4].copy_from_slice(&[rgba[2], rgba[1], rgba[0], rgba[3]])
665        }
666        PixelFormat::Rgb8 => plane.data[offset..offset + 3].copy_from_slice(&rgba[..3]),
667        PixelFormat::Gray8 => {
668            plane.data[offset] = ((u16::from(rgba[0]) * 77
669                + u16::from(rgba[1]) * 150
670                + u16::from(rgba[2]) * 29
671                + 128)
672                >> 8) as u8
673        }
674        _ => {
675            return Err(Error::new(
676                ErrorKind::Unsupported,
677                "pixel format is not a packed CPU format",
678            ));
679        }
680    }
681    Ok(())
682}
683
684fn convert_range(rgba: &mut [u8; 4], source: ColorRange, destination: ColorRange) {
685    if source == destination {
686        return;
687    }
688    for component in &mut rgba[..3] {
689        *component = match (source, destination) {
690            (ColorRange::Full, ColorRange::Limited) => {
691                (16 + (u32::from(*component) * 219 + 127) / 255) as u8
692            }
693            (ColorRange::Limited, ColorRange::Full) => {
694                ((u32::from(component.saturating_sub(16)) * 255 + 109) / 219).min(255) as u8
695            }
696            _ => *component,
697        };
698    }
699}
700
701fn graphics(message: &str) -> Error {
702    Error::new(ErrorKind::Graphics, message)
703}
704fn invalid(message: &str) -> Error {
705    Error::new(ErrorKind::InvalidInput, message)
706}
707fn limit(message: &str) -> Error {
708    Error::new(ErrorKind::ResourceLimit, message)
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use crate::Limits;
715
716    fn dimensions(width: u32, height: u32) -> VideoDimensions {
717        VideoDimensions::new(width, height, &Limits::default()).unwrap()
718    }
719
720    fn rgba_frame(width: u32, height: u32, stride: usize, data: Vec<u8>) -> VideoFrame {
721        VideoFrame::new(
722            dimensions(width, height),
723            PixelFormat::Rgba8,
724            ColorRange::Full,
725            vec![Plane { data, stride }],
726            &Limits::default(),
727        )
728        .unwrap()
729    }
730
731    fn resource(api: GraphicsApi, ownership: ResourceOwnership) -> GraphicsResource {
732        GraphicsResource::new(
733            api,
734            ContextIdentity(7),
735            ExecutionOwner(9),
736            ResourceKind::Texture2d,
737            42,
738            dimensions(2, 2),
739            PixelFormat::Rgba8,
740            ColorRange::Full,
741            Orientation::BottomLeft,
742            ownership,
743        )
744    }
745
746    #[derive(Debug)]
747    struct FakeAdapter {
748        api: GraphicsApi,
749        context: ContextIdentity,
750        owner: ExecutionOwner,
751        current: bool,
752        lost: bool,
753        mode: TransferMode,
754        calls: Vec<&'static str>,
755    }
756
757    impl FakeAdapter {
758        fn new(api: GraphicsApi, mode: TransferMode) -> Self {
759            Self {
760                api,
761                context: ContextIdentity(7),
762                owner: ExecutionOwner(9),
763                current: true,
764                lost: false,
765                mode,
766                calls: Vec::new(),
767            }
768        }
769    }
770
771    impl GraphicsAdapter for FakeAdapter {
772        fn api(&self) -> GraphicsApi {
773            self.api
774        }
775        fn context_identity(&self) -> ContextIdentity {
776            self.context
777        }
778        fn execution_owner(&self) -> ExecutionOwner {
779            self.owner
780        }
781        fn is_current(&self) -> bool {
782            self.current
783        }
784        fn is_context_lost(&self) -> bool {
785            self.lost
786        }
787        fn capability(
788            &self,
789            _source: FrameSource<'_>,
790            _destination: &FrameDestination<'_>,
791        ) -> TransferMode {
792            self.mode
793        }
794        fn upload(
795            &mut self,
796            _source: CpuFrameSource<'_>,
797            _destination: GraphicsResource,
798            _stages: &[TransferStage],
799        ) -> Result<()> {
800            self.calls.push("upload");
801            Ok(())
802        }
803        fn readback(
804            &mut self,
805            _source: GraphicsResource,
806            mut destination: CpuFrameDestination<'_>,
807            _stages: &[TransferStage],
808        ) -> Result<()> {
809            destination.planes[0].data.fill(23);
810            self.calls.push("readback");
811            Ok(())
812        }
813        fn copy(
814            &mut self,
815            _source: GraphicsResource,
816            _destination: GraphicsResource,
817            _stages: &[TransferStage],
818        ) -> Result<()> {
819            self.calls.push("copy");
820            Ok(())
821        }
822        fn delete(&mut self, _resource: GraphicsResource) -> Result<()> {
823            self.calls.push("delete");
824            Ok(())
825        }
826    }
827
828    #[test]
829    fn cpu_transfer_handles_stride_scaling_orientation_and_format() {
830        let source = rgba_frame(
831            2,
832            2,
833            12,
834            vec![
835                255, 0, 0, 255, 0, 255, 0, 255, 9, 9, 9, 9, 0, 0, 255, 255, 255, 255, 255, 255, 9,
836                9, 9, 9,
837            ],
838        );
839        let mut output = vec![0; 4];
840        let destination = CpuFrameDestination {
841            dimensions: dimensions(1, 1),
842            pixel_format: PixelFormat::Bgra8,
843            color_range: ColorRange::Full,
844            orientation: Orientation::BottomLeft,
845            planes: vec![CpuPlaneDestination {
846                data: &mut output,
847                stride: 4,
848            }],
849        };
850        let capability = execute_transfer(
851            None,
852            FrameSource::Cpu(CpuFrameSource {
853                frame: &source,
854                orientation: Orientation::TopLeft,
855            }),
856            FrameDestination::Cpu(destination),
857            TransferPolicy::any(),
858        )
859        .unwrap();
860        assert_eq!(output, [0, 0, 255, 255]);
861        assert_eq!(capability.mode, TransferMode::CpuCopy);
862        assert!(capability.stages.contains(&TransferStage::RepackStride));
863        assert!(capability.stages.contains(&TransferStage::Scale {
864            filter: ScaleFilter::Nearest
865        }));
866        assert!(capability.stages.contains(&TransferStage::FlipVertical));
867    }
868
869    #[test]
870    fn cpu_transfer_converts_color_range() {
871        let source = rgba_frame(1, 1, 4, vec![0, 255, 128, 255]);
872        let mut output = vec![0; 4];
873        let destination = CpuFrameDestination {
874            dimensions: dimensions(1, 1),
875            pixel_format: PixelFormat::Rgba8,
876            color_range: ColorRange::Limited,
877            orientation: Orientation::TopLeft,
878            planes: vec![CpuPlaneDestination {
879                data: &mut output,
880                stride: 4,
881            }],
882        };
883        execute_transfer(
884            None,
885            FrameSource::Cpu(CpuFrameSource {
886                frame: &source,
887                orientation: Orientation::TopLeft,
888            }),
889            FrameDestination::Cpu(destination),
890            TransferPolicy::any(),
891        )
892        .unwrap();
893        assert_eq!(output, [16, 235, 126, 255]);
894    }
895
896    #[test]
897    fn native_gl_upload_and_webgl_readback_use_the_adapter() {
898        let frame = rgba_frame(2, 2, 8, vec![0; 16]);
899        let mut native = FakeAdapter::new(GraphicsApi::NativeOpenGl, TransferMode::GpuCopy);
900        execute_transfer(
901            Some(&mut native),
902            FrameSource::Cpu(CpuFrameSource {
903                frame: &frame,
904                orientation: Orientation::TopLeft,
905            }),
906            FrameDestination::Graphics(resource(
907                GraphicsApi::NativeOpenGl,
908                ResourceOwnership::Caller,
909            )),
910            TransferPolicy::require(TransferMode::GpuCopy),
911        )
912        .unwrap();
913        assert_eq!(native.calls, ["upload"]);
914
915        let mut output = vec![0; 16];
916        let destination = CpuFrameDestination {
917            dimensions: dimensions(2, 2),
918            pixel_format: PixelFormat::Rgba8,
919            color_range: ColorRange::Full,
920            orientation: Orientation::TopLeft,
921            planes: vec![CpuPlaneDestination {
922                data: &mut output,
923                stride: 8,
924            }],
925        };
926        let mut web = FakeAdapter::new(GraphicsApi::WebGl, TransferMode::CpuCopy);
927        execute_transfer(
928            Some(&mut web),
929            FrameSource::Graphics(resource(GraphicsApi::WebGl, ResourceOwnership::Caller)),
930            FrameDestination::Cpu(destination),
931            TransferPolicy::require(TransferMode::CpuCopy),
932        )
933        .unwrap();
934        assert_eq!(output, vec![23; 16]);
935        assert_eq!(web.calls, ["readback"]);
936    }
937
938    #[test]
939    fn strict_policy_rejects_fallback_without_calling_backend() {
940        let frame = rgba_frame(2, 2, 8, vec![0; 16]);
941        let mut adapter = FakeAdapter::new(GraphicsApi::NativeOpenGl, TransferMode::CpuCopy);
942        let error = execute_transfer(
943            Some(&mut adapter),
944            FrameSource::Cpu(CpuFrameSource {
945                frame: &frame,
946                orientation: Orientation::TopLeft,
947            }),
948            FrameDestination::Graphics(resource(
949                GraphicsApi::NativeOpenGl,
950                ResourceOwnership::Caller,
951            )),
952            TransferPolicy::require(TransferMode::Shared),
953        )
954        .unwrap_err();
955        assert_eq!(error.kind(), ErrorKind::Unsupported);
956        assert!(adapter.calls.is_empty());
957    }
958
959    #[test]
960    fn shared_graphics_transfer_is_reported_when_no_conversion_is_needed() {
961        let mut adapter = FakeAdapter::new(GraphicsApi::NativeOpenGl, TransferMode::Shared);
962        let graphics = resource(GraphicsApi::NativeOpenGl, ResourceOwnership::Caller);
963        let capability = execute_transfer(
964            Some(&mut adapter),
965            FrameSource::Graphics(graphics),
966            FrameDestination::Graphics(graphics),
967            TransferPolicy::require(TransferMode::Shared),
968        )
969        .unwrap();
970        assert_eq!(capability.mode, TransferMode::Shared);
971        assert!(capability.stages.is_empty());
972        assert_eq!(adapter.calls, ["copy"]);
973    }
974
975    #[test]
976    fn context_loss_identity_owner_and_current_state_are_validated() {
977        let destination = FrameDestination::Graphics(resource(
978            GraphicsApi::NativeOpenGl,
979            ResourceOwnership::Caller,
980        ));
981        let frame = rgba_frame(2, 2, 8, vec![0; 16]);
982        let source = FrameSource::Cpu(CpuFrameSource {
983            frame: &frame,
984            orientation: Orientation::TopLeft,
985        });
986        let mut adapter = FakeAdapter::new(GraphicsApi::NativeOpenGl, TransferMode::GpuCopy);
987        adapter.lost = true;
988        assert_eq!(
989            inspect_transfer(Some(&adapter), source, &destination, TransferPolicy::any()).mode,
990            TransferMode::Unsupported
991        );
992        adapter.lost = false;
993        adapter.current = false;
994        assert_eq!(
995            inspect_transfer(Some(&adapter), source, &destination, TransferPolicy::any()).mode,
996            TransferMode::Unsupported
997        );
998        adapter.current = true;
999        adapter.context = ContextIdentity(99);
1000        assert_eq!(
1001            inspect_transfer(Some(&adapter), source, &destination, TransferPolicy::any()).mode,
1002            TransferMode::Unsupported
1003        );
1004        adapter.context = ContextIdentity(7);
1005        adapter.owner = ExecutionOwner(99);
1006        assert_eq!(
1007            inspect_transfer(Some(&adapter), source, &destination, TransferPolicy::any()).mode,
1008            TransferMode::Unsupported
1009        );
1010    }
1011
1012    #[test]
1013    fn cleanup_deletes_only_library_owned_resources() {
1014        let mut adapter = FakeAdapter::new(GraphicsApi::WebGl, TransferMode::GpuCopy);
1015        resource(GraphicsApi::WebGl, ResourceOwnership::Caller)
1016            .release(&mut adapter)
1017            .unwrap();
1018        assert!(adapter.calls.is_empty());
1019        resource(GraphicsApi::WebGl, ResourceOwnership::Library)
1020            .release(&mut adapter)
1021            .unwrap();
1022        assert_eq!(adapter.calls, ["delete"]);
1023    }
1024}