1use super::RenderNodeCpu;
8
9#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
11fn lerp_u8(a: f32, b: f32, t: f32) -> u8 {
12 (a + (b - a) * t + 0.5).clamp(0.0, 255.0) as u8
13}
14
15const DIP_PHASE: f32 = 0.2;
23
24fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
26 let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
27 t * t * (3.0 - 2.0 * t)
28}
29
30pub struct WipeTransitionNode {
45 pub progress: f32,
47 pub softness: f32,
49 pub angle: f32,
52 pub to_rgba: Vec<u8>,
54 pub to_width: u32,
56 pub to_height: u32,
58}
59
60impl WipeTransitionNode {
61 #[must_use]
63 pub fn new(
64 progress: f32,
65 softness: f32,
66 angle: f32,
67 to_rgba: Vec<u8>,
68 to_width: u32,
69 to_height: u32,
70 ) -> Self {
71 Self {
72 progress,
73 softness,
74 angle,
75 to_rgba,
76 to_width,
77 to_height,
78 }
79 }
80
81 fn mask_at(&self, x: u32, y: u32, w: u32, h: u32) -> f32 {
98 let (ax, ay) = (self.angle.cos(), self.angle.sin());
99 if self.softness <= 0.0 {
100 const AXIS: f32 = 0.999;
101 #[allow(clippy::cast_precision_loss)]
102 let (wf, hf) = (w as f32, h as f32);
103 #[allow(clippy::cast_possible_truncation)]
105 let edge = |extent: f32, at: f32| (extent * at) as i64;
106 if ax > AXIS {
107 return f32::from(i64::from(x) > edge(wf, 1.0 - self.progress));
108 }
109 if ax < -AXIS {
110 return f32::from(i64::from(x) <= edge(wf, self.progress));
111 }
112 if ay > AXIS {
113 return f32::from(i64::from(y) > edge(hf, 1.0 - self.progress));
114 }
115 if ay < -AXIS {
116 return f32::from(i64::from(y) <= edge(hf, self.progress));
117 }
118 }
119 #[allow(clippy::cast_precision_loss)]
120 let (uv_x, uv_y) = ((x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32);
121 let reach = f32::midpoint(ax.abs(), ay.abs());
122 let hw = self.softness.max(1e-3);
124 let center = (0.5 + reach + hw) + ((0.5 - reach - hw) - (0.5 + reach + hw)) * self.progress;
127 let proj = (uv_x - 0.5) * ax + (uv_y - 0.5) * ay + 0.5;
128 smoothstep(center - hw, center + hw, proj)
129 }
130}
131
132impl RenderNodeCpu for WipeTransitionNode {
133 #[allow(clippy::cast_precision_loss)]
134 fn process_cpu(&self, rgba: &mut [u8], w: u32, h: u32) {
135 if self.to_rgba.len() != rgba.len() {
136 log::warn!(
137 "WipeTransitionNode::process_cpu skipped: size mismatch a={} b={}",
138 rgba.len(),
139 self.to_rgba.len()
140 );
141 return;
142 }
143 for y in 0..h {
144 for x in 0..w {
145 let idx = ((y * w + x) * 4) as usize;
146 let mask = self.mask_at(x, y, w, h);
147 for c in 0..4 {
148 let a = f32::from(rgba[idx + c]);
149 let b = f32::from(self.to_rgba[idx + c]);
150 rgba[idx + c] = lerp_u8(a, b, mask);
151 }
152 }
153 }
154 }
155}
156
157pub struct FadeTransitionNode {
180 pub progress: f32,
182 pub to_rgba: Vec<u8>,
184 pub to_width: u32,
186 pub to_height: u32,
188}
189
190impl FadeTransitionNode {
191 #[must_use]
193 pub fn new(progress: f32, to_rgba: Vec<u8>, to_width: u32, to_height: u32) -> Self {
194 Self {
195 progress,
196 to_rgba,
197 to_width,
198 to_height,
199 }
200 }
201}
202
203impl RenderNodeCpu for FadeTransitionNode {
204 fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
205 if self.to_rgba.len() != rgba.len() {
206 log::warn!(
207 "FadeTransitionNode::process_cpu skipped: size mismatch a={} b={}",
208 rgba.len(),
209 self.to_rgba.len()
210 );
211 return;
212 }
213 for (a, b) in rgba.iter_mut().zip(self.to_rgba.iter()) {
214 *a = lerp_u8(f32::from(*a), f32::from(*b), self.progress);
215 }
216 }
217}
218
219pub struct DissolveTransitionNode {
234 pub mask: Vec<u8>,
243 pub to_rgba: Vec<u8>,
245 pub to_width: u32,
247 pub to_height: u32,
249}
250
251impl DissolveTransitionNode {
252 #[must_use]
255 pub fn new(mask: Vec<u8>, to_rgba: Vec<u8>, to_width: u32, to_height: u32) -> Self {
256 Self {
257 mask,
258 to_rgba,
259 to_width,
260 to_height,
261 }
262 }
263}
264
265impl RenderNodeCpu for DissolveTransitionNode {
266 fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
267 if self.to_rgba.len() != rgba.len() || self.mask.len() != rgba.len() {
268 log::warn!(
269 "DissolveTransitionNode::process_cpu skipped: size mismatch a={} b={} mask={}",
270 rgba.len(),
271 self.to_rgba.len(),
272 self.mask.len()
273 );
274 return;
275 }
276 for ((px, b), m) in rgba
277 .as_chunks_mut::<4>()
278 .0
279 .iter_mut()
280 .zip(self.to_rgba.as_chunks::<4>().0)
281 .zip(self.mask.as_chunks::<4>().0)
282 {
283 if m[0] >= 128 {
284 *px = *b;
285 }
286 }
287 }
288}
289
290pub struct DipToColorNode {
295 pub progress: f32,
298 pub color: [f32; 3],
305 pub to_rgba: Vec<u8>,
307 pub to_width: u32,
309 pub to_height: u32,
311}
312
313impl DipToColorNode {
314 #[must_use]
316 pub fn new(
317 progress: f32,
318 color: [f32; 3],
319 to_rgba: Vec<u8>,
320 to_width: u32,
321 to_height: u32,
322 ) -> Self {
323 Self {
324 progress,
325 color,
326 to_rgba,
327 to_width,
328 to_height,
329 }
330 }
331}
332
333impl RenderNodeCpu for DipToColorNode {
334 fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
335 if self.to_rgba.len() != rgba.len() {
336 log::warn!(
337 "DipToColorNode::process_cpu skipped: size mismatch a={} b={}",
338 rgba.len(),
339 self.to_rgba.len()
340 );
341 return;
342 }
343 let bg = [
344 self.color[0] * 255.0,
345 self.color[1] * 255.0,
346 self.color[2] * 255.0,
347 255.0,
348 ];
349 let g = 1.0 - self.progress;
352 let s1 = smoothstep(1.0 - DIP_PHASE, 1.0, g);
353 let s2 = smoothstep(DIP_PHASE, 1.0, g);
354 for (px, b) in rgba
355 .as_chunks_mut::<4>()
356 .0
357 .iter_mut()
358 .zip(self.to_rgba.as_chunks::<4>().0)
359 {
360 for c in 0..4 {
361 let leaving = f32::from(px[c]) * s1 + bg[c] * (1.0 - s1);
362 let arriving = bg[c] * s2 + f32::from(b[c]) * (1.0 - s2);
363 px[c] = lerp_u8(arriving, leaving, g);
366 }
367 }
368 }
369}
370
371#[cfg(feature = "wgpu")]
376#[derive(PartialEq, Eq, Hash, Clone, Copy)]
377pub(crate) struct TransitionPipelineKey {
378 pub(crate) label: &'static str,
379 pub(crate) uniform_size: u64,
380 pub(crate) mask: bool,
381}
382
383#[cfg(feature = "wgpu")]
384pub(crate) struct TransitionPipeline {
385 render_pipeline: wgpu::RenderPipeline,
386 bind_group_layout: wgpu::BindGroupLayout,
387 sampler: wgpu::Sampler,
388 uniform_buf: wgpu::Buffer,
389}
390
391#[cfg(feature = "wgpu")]
392fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
393 wgpu::BindGroupLayoutEntry {
394 binding,
395 visibility: wgpu::ShaderStages::FRAGMENT,
396 ty: wgpu::BindingType::Texture {
397 sample_type: wgpu::TextureSampleType::Float { filterable: true },
398 view_dimension: wgpu::TextureViewDimension::D2,
399 multisampled: false,
400 },
401 count: None,
402 }
403}
404
405#[cfg(feature = "wgpu")]
412fn cached_pipeline(
413 ctx: &crate::context::RenderContext,
414 label: &'static str,
415 shader_src: &str,
416 uniform_size: u64,
417 mask: bool,
418) -> std::sync::Arc<TransitionPipeline> {
419 let mut cache = match ctx.transition_pipelines.lock() {
420 Ok(guard) => guard,
421 Err(poisoned) => poisoned.into_inner(),
425 };
426 let key = TransitionPipelineKey {
433 label,
434 uniform_size,
435 mask,
436 };
437 std::sync::Arc::clone(cache.entry(key).or_insert_with(|| {
438 std::sync::Arc::new(build_pipeline(
439 &ctx.device,
440 shader_src,
441 label,
442 uniform_size,
443 mask,
444 ))
445 }))
446}
447
448#[cfg(feature = "wgpu")]
456fn build_pipeline(
457 device: &wgpu::Device,
458 shader_src: &str,
459 label: &str,
460 uniform_size: u64,
461 mask: bool,
462) -> TransitionPipeline {
463 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
464 label: Some(label),
465 source: wgpu::ShaderSource::Wgsl(shader_src.into()),
466 });
467 let mut entries = vec![
468 tex_entry(0),
469 tex_entry(1),
470 wgpu::BindGroupLayoutEntry {
471 binding: 2,
472 visibility: wgpu::ShaderStages::FRAGMENT,
473 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
474 count: None,
475 },
476 wgpu::BindGroupLayoutEntry {
477 binding: 3,
478 visibility: wgpu::ShaderStages::FRAGMENT,
479 ty: wgpu::BindingType::Buffer {
480 ty: wgpu::BufferBindingType::Uniform,
481 has_dynamic_offset: false,
482 min_binding_size: None,
483 },
484 count: None,
485 },
486 ];
487 if mask {
488 entries.push(tex_entry(4));
489 }
490 let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
491 label: Some(label),
492 entries: &entries,
493 });
494 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
495 label: Some(label),
496 bind_group_layouts: &[Some(&bgl)],
497 immediate_size: 0,
498 });
499 let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
500 label: Some(label),
501 layout: Some(&pipeline_layout),
502 vertex: wgpu::VertexState {
503 module: &shader,
504 entry_point: Some("vs_main"),
505 buffers: &[],
506 compilation_options: wgpu::PipelineCompilationOptions::default(),
507 },
508 fragment: Some(wgpu::FragmentState {
509 module: &shader,
510 entry_point: Some("fs_main"),
511 targets: &[Some(wgpu::ColorTargetState {
512 format: wgpu::TextureFormat::Rgba8Unorm,
513 blend: None,
514 write_mask: wgpu::ColorWrites::ALL,
515 })],
516 compilation_options: wgpu::PipelineCompilationOptions::default(),
517 }),
518 primitive: wgpu::PrimitiveState::default(),
519 depth_stencil: None,
520 multisample: wgpu::MultisampleState::default(),
521 multiview_mask: None,
522 cache: None,
523 });
524 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
525 label: Some(label),
526 address_mode_u: wgpu::AddressMode::ClampToEdge,
527 address_mode_v: wgpu::AddressMode::ClampToEdge,
528 mag_filter: wgpu::FilterMode::Linear,
529 min_filter: wgpu::FilterMode::Linear,
530 ..Default::default()
531 });
532 let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
533 label: Some(label),
534 size: uniform_size,
535 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
536 mapped_at_creation: false,
537 });
538 TransitionPipeline {
539 render_pipeline,
540 bind_group_layout: bgl,
541 sampler,
542 uniform_buf,
543 }
544}
545
546#[cfg(feature = "wgpu")]
548fn upload_frame(
549 ctx: &crate::context::RenderContext,
550 rgba: &[u8],
551 width: u32,
552 height: u32,
553) -> wgpu::Texture {
554 let tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
555 label: Some("Transition to_tex"),
556 size: wgpu::Extent3d {
557 width,
558 height,
559 depth_or_array_layers: 1,
560 },
561 mip_level_count: 1,
562 sample_count: 1,
563 dimension: wgpu::TextureDimension::D2,
564 format: wgpu::TextureFormat::Rgba8Unorm,
565 usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
566 view_formats: &[],
567 });
568 ctx.queue.write_texture(
569 wgpu::TexelCopyTextureInfo {
570 texture: &tex,
571 mip_level: 0,
572 origin: wgpu::Origin3d::ZERO,
573 aspect: wgpu::TextureAspect::All,
574 },
575 rgba,
576 wgpu::TexelCopyBufferLayout {
577 offset: 0,
578 bytes_per_row: Some(width * 4),
579 rows_per_image: None,
580 },
581 wgpu::Extent3d {
582 width,
583 height,
584 depth_or_array_layers: 1,
585 },
586 );
587 tex
588}
589
590#[cfg(feature = "wgpu")]
592fn run_pass(
593 ctx: &crate::context::RenderContext,
594 pd: &TransitionPipeline,
595 tex_a: &wgpu::Texture,
596 tex_b: &wgpu::Texture,
597 mask: Option<&wgpu::Texture>,
598 output: &wgpu::Texture,
599 label: &str,
600) {
601 let a_view = tex_a.create_view(&wgpu::TextureViewDescriptor::default());
602 let b_view = tex_b.create_view(&wgpu::TextureViewDescriptor::default());
603 let mask_view = mask.map(|m| m.create_view(&wgpu::TextureViewDescriptor::default()));
604 let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
605 let mut bind_entries = vec![
606 wgpu::BindGroupEntry {
607 binding: 0,
608 resource: wgpu::BindingResource::TextureView(&a_view),
609 },
610 wgpu::BindGroupEntry {
611 binding: 1,
612 resource: wgpu::BindingResource::TextureView(&b_view),
613 },
614 wgpu::BindGroupEntry {
615 binding: 2,
616 resource: wgpu::BindingResource::Sampler(&pd.sampler),
617 },
618 wgpu::BindGroupEntry {
619 binding: 3,
620 resource: pd.uniform_buf.as_entire_binding(),
621 },
622 ];
623 if let Some(view) = mask_view.as_ref() {
624 bind_entries.push(wgpu::BindGroupEntry {
625 binding: 4,
626 resource: wgpu::BindingResource::TextureView(view),
627 });
628 }
629 let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
630 label: Some(label),
631 layout: &pd.bind_group_layout,
632 entries: &bind_entries,
633 });
634 let mut encoder = ctx
635 .device
636 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(label) });
637 {
638 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
639 label: Some(label),
640 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
641 view: &out_view,
642 resolve_target: None,
643 depth_slice: None,
644 ops: wgpu::Operations {
645 load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
646 store: wgpu::StoreOp::Store,
647 },
648 })],
649 depth_stencil_attachment: None,
650 timestamp_writes: None,
651 occlusion_query_set: None,
652 multiview_mask: None,
653 });
654 pass.set_pipeline(&pd.render_pipeline);
655 pass.set_bind_group(0, &bind_group, &[]);
656 pass.draw(0..6, 0..1);
657 }
658 ctx.queue.submit(std::iter::once(encoder.finish()));
659}
660
661#[cfg(feature = "wgpu")]
662fn pack_f32(values: &[f32]) -> Vec<u8> {
663 values.iter().flat_map(|f| f.to_le_bytes()).collect()
664}
665
666#[cfg(feature = "wgpu")]
667impl super::RenderNode for WipeTransitionNode {
668 fn input_count(&self) -> usize {
669 2
670 }
671
672 fn process(
673 &self,
674 inputs: &[&wgpu::Texture],
675 outputs: &[&wgpu::Texture],
676 ctx: &crate::context::RenderContext,
677 ) {
678 let Some(tex_a) = inputs.first() else {
679 log::warn!("WipeTransitionNode::process called with no inputs");
680 return;
681 };
682 let Some(output) = outputs.first() else {
683 log::warn!("WipeTransitionNode::process called with no outputs");
684 return;
685 };
686 let pd = cached_pipeline(ctx, "Wipe", include_str!("../shaders/wipe.wgsl"), 16, false);
687 ctx.queue.write_buffer(
688 &pd.uniform_buf,
689 0,
690 &pack_f32(&[self.progress, self.softness, self.angle, 0.0]),
691 );
692 let to_tex = upload_frame(ctx, &self.to_rgba, self.to_width, self.to_height);
693 run_pass(ctx, &pd, tex_a, &to_tex, None, output, "Wipe pass");
694 }
695}
696
697#[cfg(feature = "wgpu")]
698impl super::RenderNode for FadeTransitionNode {
699 fn input_count(&self) -> usize {
700 2
701 }
702
703 fn process(
704 &self,
705 inputs: &[&wgpu::Texture],
706 outputs: &[&wgpu::Texture],
707 ctx: &crate::context::RenderContext,
708 ) {
709 let Some(tex_a) = inputs.first() else {
710 log::warn!("FadeTransitionNode::process called with no inputs");
711 return;
712 };
713 let Some(output) = outputs.first() else {
714 log::warn!("FadeTransitionNode::process called with no outputs");
715 return;
716 };
717 let pd = cached_pipeline(
720 ctx,
721 "Fade",
722 include_str!("../shaders/crossfade.wgsl"),
723 16,
724 false,
725 );
726 ctx.queue.write_buffer(
727 &pd.uniform_buf,
728 0,
729 &pack_f32(&[self.progress, 0.0, 0.0, 0.0]),
730 );
731 let to_tex = upload_frame(ctx, &self.to_rgba, self.to_width, self.to_height);
732 run_pass(ctx, &pd, tex_a, &to_tex, None, output, "Fade pass");
733 }
734}
735
736#[cfg(feature = "wgpu")]
737impl super::RenderNode for DissolveTransitionNode {
738 fn input_count(&self) -> usize {
739 2
740 }
741
742 fn process(
743 &self,
744 inputs: &[&wgpu::Texture],
745 outputs: &[&wgpu::Texture],
746 ctx: &crate::context::RenderContext,
747 ) {
748 let Some(tex_a) = inputs.first() else {
749 log::warn!("DissolveTransitionNode::process called with no inputs");
750 return;
751 };
752 let Some(output) = outputs.first() else {
753 log::warn!("DissolveTransitionNode::process called with no outputs");
754 return;
755 };
756 let pd = cached_pipeline(
759 ctx,
760 "Dissolve",
761 include_str!("../shaders/dissolve.wgsl"),
762 16,
763 true,
764 );
765 let to_tex = upload_frame(ctx, &self.to_rgba, self.to_width, self.to_height);
766 let mask_tex = upload_frame(ctx, &self.mask, self.to_width, self.to_height);
767 run_pass(
768 ctx,
769 &pd,
770 tex_a,
771 &to_tex,
772 Some(&mask_tex),
773 output,
774 "Dissolve pass",
775 );
776 }
777}
778
779#[cfg(feature = "wgpu")]
780impl super::RenderNode for DipToColorNode {
781 fn input_count(&self) -> usize {
782 2
783 }
784
785 fn process(
786 &self,
787 inputs: &[&wgpu::Texture],
788 outputs: &[&wgpu::Texture],
789 ctx: &crate::context::RenderContext,
790 ) {
791 let Some(tex_a) = inputs.first() else {
792 log::warn!("DipToColorNode::process called with no inputs");
793 return;
794 };
795 let Some(output) = outputs.first() else {
796 log::warn!("DipToColorNode::process called with no outputs");
797 return;
798 };
799 let pd = cached_pipeline(ctx, "Dip", include_str!("../shaders/dip.wgsl"), 32, false);
800 ctx.queue.write_buffer(
801 &pd.uniform_buf,
802 0,
803 &pack_f32(&[
804 self.progress,
805 0.0,
806 0.0,
807 0.0,
808 self.color[0],
809 self.color[1],
810 self.color[2],
811 1.0,
812 ]),
813 );
814 let to_tex = upload_frame(ctx, &self.to_rgba, self.to_width, self.to_height);
815 run_pass(ctx, &pd, tex_a, &to_tex, None, output, "Dip pass");
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 fn wipe_progress_zero_should_be_clip_a() {
825 let b = vec![200u8, 200, 200, 255];
826 let node = WipeTransitionNode::new(0.0, 0.0, 0.0, b, 1, 1);
827 let a = vec![10u8, 20, 30, 255];
828 let mut rgba = a.clone();
829 node.process_cpu(&mut rgba, 1, 1);
830 assert_eq!(rgba, a, "progress=0 must output clip A");
831 }
832
833 #[test]
834 fn wipe_at_progress_one_should_keep_ffmpegs_final_column() {
835 let a = vec![
840 10u8, 20, 30, 255, 10, 20, 30, 255, 10, 20, 30, 255, 10, 20, 30, 255,
841 ];
842 let b = vec![
843 200u8, 210, 220, 255, 200, 210, 220, 255, 200, 210, 220, 255, 200, 210, 220, 255,
844 ];
845 let node = WipeTransitionNode::new(1.0, 0.0, 0.0, b, 4, 1);
846 let mut rgba = a.clone();
847 node.process_cpu(&mut rgba, 4, 1);
848 assert_eq!(
849 &rgba[0..4],
850 &a[0..4],
851 "column 0 stays on clip A at progress 1"
852 );
853 for x in 1..4 {
854 assert_eq!(
855 &rgba[x * 4..x * 4 + 3],
856 &[200, 210, 220],
857 "column {x} must be clip B at progress 1"
858 );
859 }
860 }
861
862 #[test]
863 fn wipe_hard_edge_should_land_on_ffmpegs_integer_column() {
864 let a: Vec<u8> = (0..8).flat_map(|_| [10u8, 20, 30, 255]).collect();
870 let b: Vec<u8> = (0..8).flat_map(|_| [200u8, 210, 220, 255]).collect();
871 let node = WipeTransitionNode::new(0.5, 0.0, 0.0, b, 8, 1);
872 let mut rgba = a.clone();
873 node.process_cpu(&mut rgba, 8, 1);
874 for x in 0..8 {
875 let want: [u8; 3] = if x > 4 { [200, 210, 220] } else { [10, 20, 30] };
876 assert_eq!(
877 &rgba[x * 4..x * 4 + 3],
878 &want,
879 "column {x} at progress 0.5 (FFmpeg edge z=4)"
880 );
881 }
882 }
883
884 #[test]
885 fn wipe_size_mismatch_should_leave_rgba_unchanged() {
886 let b = vec![200u8; 8]; let node = WipeTransitionNode::new(0.5, 0.0, 0.0, b, 2, 1);
888 let original = vec![10u8, 20, 30, 255]; let mut rgba = original.clone();
890 node.process_cpu(&mut rgba, 1, 1);
891 assert_eq!(rgba, original, "size mismatch must be a no-op");
892 }
893
894 const FADE_A: [u8; 4] = [10, 200, 30, 255];
898 const FADE_B: [u8; 4] = [210, 40, 130, 55];
899
900 #[test]
901 fn fade_transition_progress_zero_should_be_clip_a() {
902 let node = FadeTransitionNode::new(0.0, FADE_B.to_vec(), 1, 1);
903 let mut rgba = FADE_A.to_vec();
904 node.process_cpu(&mut rgba, 1, 1);
905 assert_eq!(rgba, FADE_A, "progress=0 must output clip A");
906 }
907
908 #[test]
909 fn fade_transition_progress_one_should_be_clip_b() {
910 let node = FadeTransitionNode::new(1.0, FADE_B.to_vec(), 1, 1);
911 let mut rgba = FADE_A.to_vec();
912 node.process_cpu(&mut rgba, 1, 1);
913 assert_eq!(rgba, FADE_B, "progress=1 must output clip B");
914 }
915
916 #[test]
917 fn fade_transition_half_should_average_the_pair() {
918 let node = FadeTransitionNode::new(0.5, FADE_B.to_vec(), 1, 1);
921 let mut rgba = FADE_A.to_vec();
922 node.process_cpu(&mut rgba, 1, 1);
923 for (c, got) in rgba.iter().enumerate() {
924 let want = f32::midpoint(f32::from(FADE_A[c]), f32::from(FADE_B[c]));
925 assert!(
926 (f32::from(*got) - want).abs() <= 1.0,
927 "channel {c}: got {got} want {want}"
928 );
929 }
930 }
931
932 #[test]
933 fn fade_transition_size_mismatch_should_leave_rgba_unchanged() {
934 let node = FadeTransitionNode::new(0.5, vec![200u8; 8], 2, 1); let original = FADE_A.to_vec(); let mut rgba = original.clone();
937 node.process_cpu(&mut rgba, 1, 1);
938 assert_eq!(rgba, original, "size mismatch must be a no-op");
939 }
940
941 #[test]
942 fn dissolve_with_an_empty_mask_should_be_clip_a() {
943 let node = DissolveTransitionNode::new(vec![0u8; 4], vec![210u8, 40, 130, 55], 1, 1);
944 let a = vec![10u8, 200, 30, 255];
945 let mut rgba = a.clone();
946 node.process_cpu(&mut rgba, 1, 1);
947 assert_eq!(rgba, a, "an unset mask must leave clip A");
948 }
949
950 #[test]
951 fn dissolve_with_a_full_mask_should_be_clip_b() {
952 let b = vec![210u8, 40, 130, 55];
953 let node = DissolveTransitionNode::new(vec![255u8; 4], b.clone(), 1, 1);
954 let mut rgba = vec![10u8, 200, 30, 255];
955 node.process_cpu(&mut rgba, 1, 1);
956 assert_eq!(rgba, b, "a set mask must reveal clip B");
957 }
958
959 #[test]
960 fn dissolve_should_follow_the_mask_pixel_for_pixel() {
961 let (w, h) = (8u32, 4u32);
967 let n = (w * h) as usize;
968 let a: Vec<u8> = [0u8, 0, 0, 255].repeat(n);
969 let b: Vec<u8> = [255u8, 255, 255, 255].repeat(n);
970 let mut mask = vec![0u8; n * 4];
973 for i in 0..n {
974 if i % 3 == 0 {
975 mask[i * 4..i * 4 + 4].fill(255);
976 }
977 }
978 let node = DissolveTransitionNode::new(mask, b, w, h);
979 let mut rgba = a.clone();
980 node.process_cpu(&mut rgba, w, h);
981 for (i, px) in rgba.as_chunks::<4>().0.iter().enumerate() {
982 let want = if i % 3 == 0 { 255 } else { 0 };
983 assert_eq!(px[0], want, "pixel {i} must follow the mask");
984 }
985 }
986
987 #[test]
988 fn dissolve_size_mismatch_should_leave_rgba_unchanged() {
989 let node = DissolveTransitionNode::new(vec![255u8; 8], vec![200u8; 8], 2, 1);
990 let original = vec![10u8, 200, 30, 255];
991 let mut rgba = original.clone();
992 node.process_cpu(&mut rgba, 1, 1);
993 assert_eq!(rgba, original, "size mismatch must be a no-op");
994 }
995
996 #[test]
997 fn dissolve_mask_size_mismatch_should_leave_rgba_unchanged() {
998 let node = DissolveTransitionNode::new(vec![255u8; 8], vec![200u8; 4], 1, 1);
1001 let original = vec![10u8, 200, 30, 255];
1002 let mut rgba = original.clone();
1003 node.process_cpu(&mut rgba, 1, 1);
1004 assert_eq!(rgba, original, "a mask size mismatch must be a no-op");
1005 }
1006
1007 #[test]
1008 fn dip_progress_zero_should_be_clip_a() {
1009 let b = vec![200u8, 200, 200, 255];
1010 let node = DipToColorNode::new(0.0, [0.0, 0.0, 0.0], b, 1, 1);
1011 let a = vec![10u8, 20, 30, 255];
1012 let mut rgba = a.clone();
1013 node.process_cpu(&mut rgba, 1, 1);
1014 assert_eq!(rgba, a, "progress=0 must output clip A");
1015 }
1016
1017 #[test]
1018 fn dip_at_half_should_follow_ffmpegs_phased_curve() {
1019 let b = vec![200u8, 200, 200, 255];
1027 let node = DipToColorNode::new(0.5, [0.0, 0.0, 0.0], b, 1, 1);
1028 let mut rgba = vec![120u8, 130, 140, 255];
1029 node.process_cpu(&mut rgba, 1, 1);
1030 for (i, got) in rgba[0..3].iter().enumerate() {
1031 assert!(
1032 (i32::from(*got) - 68).abs() <= 1,
1033 "progress=0.5 must follow FFmpeg's phased curve (~68) at {i}, got {got}"
1034 );
1035 }
1036 }
1037
1038 #[test]
1039 fn dip_should_be_darkest_before_the_midpoint() {
1040 let b = vec![200u8, 200, 200, 255];
1044 let darkest = (1..=9)
1045 .map(|i| {
1046 #[allow(clippy::cast_precision_loss)]
1047 let p = i as f32 / 10.0;
1048 let node = DipToColorNode::new(p, [0.0, 0.0, 0.0], b.clone(), 1, 1);
1049 let mut rgba = vec![120u8, 130, 140, 255];
1050 node.process_cpu(&mut rgba, 1, 1);
1051 (rgba[0], i)
1052 })
1053 .min()
1054 .map(|(_, i)| i)
1055 .expect("the sweep is non-empty");
1056 assert!(
1057 darkest <= 3,
1058 "the dip must bottom out in its first phase (<= 0.3), got progress 0.{darkest}"
1059 );
1060 }
1061
1062 #[test]
1063 fn dip_progress_one_should_be_clip_b() {
1064 let b = vec![200u8, 210, 220, 255];
1065 let node = DipToColorNode::new(1.0, [0.0, 0.0, 0.0], b.clone(), 1, 1);
1066 let mut rgba = vec![10u8, 20, 30, 255];
1067 node.process_cpu(&mut rgba, 1, 1);
1068 for (got, want) in rgba.iter().zip(b.iter()) {
1069 assert!(
1070 (i32::from(*got) - i32::from(*want)).abs() <= 1,
1071 "progress=1 must output clip B"
1072 );
1073 }
1074 }
1075
1076 #[test]
1077 fn dip_phase_two_size_mismatch_should_leave_rgba_unchanged() {
1078 let b = vec![200u8; 8]; let node = DipToColorNode::new(0.75, [0.0, 0.0, 0.0], b, 2, 1);
1080 let original = vec![10u8, 20, 30, 255]; let mut rgba = original.clone();
1082 node.process_cpu(&mut rgba, 1, 1);
1083 assert_eq!(rgba, original, "phase-2 size mismatch must be a no-op");
1084 }
1085}
1086
1087#[cfg(all(test, feature = "wgpu"))]
1088mod gpu_tests {
1089 use super::*;
1090 use crate::context::RenderContext;
1091 use crate::graph::RenderGraph;
1092 use std::sync::Arc;
1093
1094 fn ctx() -> Option<Arc<RenderContext>> {
1095 match futures::executor::block_on(RenderContext::init()) {
1096 Ok(ctx) => Some(Arc::new(ctx)),
1097 Err(_) => None,
1098 }
1099 }
1100
1101 #[test]
1102 fn transition_pipeline_should_be_compiled_once_across_frames() {
1103 let Some(ctx) = ctx() else {
1104 return;
1105 };
1106 let (w, h) = (8u32, 8u32);
1111 let n = (w * h) as usize;
1112 let a: Vec<u8> = [10u8, 20, 30, 255].repeat(n);
1113 let b: Vec<u8> = [200u8, 210, 220, 255].repeat(n);
1114 let before = ctx.transition_pipeline_count();
1115 for i in 0..5 {
1116 #[allow(clippy::cast_precision_loss)]
1117 let progress = i as f32 / 5.0;
1118 let out = RenderGraph::new(Arc::clone(&ctx))
1119 .push(FadeTransitionNode::new(progress, b.clone(), w, h))
1120 .process_gpu(&a, w, h)
1121 .expect("gpu fade");
1122 assert_eq!(out.len(), a.len());
1123 }
1124 assert_eq!(
1125 ctx.transition_pipeline_count() - before,
1126 1,
1127 "five frames of one kind must compile one pipeline, not five"
1128 );
1129 }
1130
1131 #[test]
1132 fn transition_pipelines_should_be_cached_per_kind() {
1133 let Some(ctx) = ctx() else {
1134 return;
1135 };
1136 let (w, h) = (8u32, 8u32);
1139 let n = (w * h) as usize;
1140 let a: Vec<u8> = [10u8, 20, 30, 255].repeat(n);
1141 let b: Vec<u8> = [200u8, 210, 220, 255].repeat(n);
1142 let before = ctx.transition_pipeline_count();
1143 let _ = RenderGraph::new(Arc::clone(&ctx))
1144 .push(FadeTransitionNode::new(0.5, b.clone(), w, h))
1145 .process_gpu(&a, w, h)
1146 .expect("gpu fade");
1147 let _ = RenderGraph::new(Arc::clone(&ctx))
1148 .push(WipeTransitionNode::new(0.5, 0.0, 0.0, b, w, h))
1149 .process_gpu(&a, w, h)
1150 .expect("gpu wipe");
1151 assert_eq!(
1152 ctx.transition_pipeline_count() - before,
1153 2,
1154 "two kinds must hold two entries"
1155 );
1156 }
1157
1158 #[test]
1159 fn dissolve_gpu_should_follow_the_mask() {
1160 let Some(ctx) = ctx() else {
1161 return;
1162 };
1163 let (w, h) = (8u32, 4u32);
1169 let n = (w * h) as usize;
1170 let a: Vec<u8> = [0u8, 0, 0, 255].repeat(n);
1171 let b: Vec<u8> = [255u8, 255, 255, 255].repeat(n);
1172 let mut mask = vec![0u8; n * 4];
1173 for i in 0..n {
1174 if i % 3 == 0 {
1175 mask[i * 4..i * 4 + 4].fill(255);
1176 }
1177 }
1178 let out = RenderGraph::new(Arc::clone(&ctx))
1179 .push(DissolveTransitionNode::new(mask, b, w, h))
1180 .process_gpu(&a, w, h)
1181 .expect("gpu dissolve");
1182 for (i, px) in out.as_chunks::<4>().0.iter().enumerate() {
1183 let want: u8 = if i % 3 == 0 { 255 } else { 0 };
1184 assert!(
1185 (i32::from(px[0]) - i32::from(want)).abs() <= 2,
1186 "GPU pixel {i} must follow the mask: got {} want {want}",
1187 px[0]
1188 );
1189 }
1190 }
1191
1192 #[test]
1193 fn wipe_gpu_should_land_on_ffmpegs_integer_column() {
1194 let Some(ctx) = ctx() else {
1195 return;
1196 };
1197 let a: Vec<u8> = (0..8).flat_map(|_| [10u8, 20, 30, 255]).collect();
1200 let b: Vec<u8> = (0..8).flat_map(|_| [200u8, 210, 220, 255]).collect();
1201 let out = RenderGraph::new(Arc::clone(&ctx))
1202 .push(WipeTransitionNode::new(0.5, 0.0, 0.0, b, 8, 1))
1203 .process_gpu(&a, 8, 1)
1204 .expect("gpu wipe");
1205 for x in 0..8 {
1206 let want: [u8; 3] = if x > 4 { [200, 210, 220] } else { [10, 20, 30] };
1207 for i in 0..3 {
1208 assert!(
1209 (i32::from(out[x * 4 + i]) - i32::from(want[i])).abs() <= 2,
1210 "GPU column {x} channel {i} at progress 0.5 (FFmpeg edge z=4)"
1211 );
1212 }
1213 }
1214 }
1215
1216 #[test]
1217 fn dip_gpu_at_half_should_match_the_cpu_curve() {
1218 let Some(ctx) = ctx() else {
1219 return;
1220 };
1221 let a = vec![120u8, 130, 140, 255];
1222 let b = vec![200u8, 200, 200, 255];
1223 let out = RenderGraph::new(Arc::clone(&ctx))
1224 .push(DipToColorNode::new(0.5, [0.0, 0.0, 0.0], b, 1, 1))
1225 .process_gpu(&a, 1, 1)
1226 .expect("gpu dip");
1227 for i in 0..3 {
1229 assert!(
1230 (i32::from(out[i]) - 68).abs() <= 2,
1231 "GPU dip at progress 0.5 must follow FFmpeg's curve (~68) at {i}, got {}",
1232 out[i]
1233 );
1234 }
1235 }
1236}