Skip to main content

pfx_post/
tape.rs

1use crate::chain::{Chain, Pass};
2use crate::hash::{grain_hash, mixed};
3use crate::image::Image;
4
5pub const WGSL: &str = include_str!("tape.wgsl");
6
7pub const SPLIT: [f32; 2] = [0.0034, 0.0039];
8pub const MIX: [[f32; 3]; 3] = [[0.41, 0.59, 0.0], [0.92, 0.06, 0.02], [0.45, 0.08, 0.47]];
9pub const ECHOES: [(f32, f32); 2] = [(0.007, 0.25), (0.016, 0.10)];
10pub const SOFT: f32 = 0.5;
11pub const BLEED: f32 = 0.75 / 1920.0;
12pub const LIFT: f32 = 0.07;
13pub const DESATURATE: f32 = 0.22;
14pub const GRAIN: f32 = 0.008;
15pub const SCANLINES: f32 = 0.04;
16pub const SHOVE: f32 = 0.0025;
17pub const JITTER: f32 = 0.0003;
18pub const BANDS: [Band; 3] = [
19    Band {
20        turn: 0.618_034,
21        start: 0.0,
22        speed: 0.21,
23        width: 0.05,
24        gain: 0.9,
25    },
26    Band {
27        turn: 0.414_213_56,
28        start: 0.37,
29        speed: 0.34,
30        width: 0.022,
31        gain: 1.0,
32    },
33    Band {
34        turn: 0.732_050_8,
35        start: 0.71,
36        speed: 0.13,
37        width: 0.09,
38        gain: 0.6,
39    },
40];
41
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub struct Band {
44    pub turn: f32,
45    pub start: f32,
46    pub speed: f32,
47    pub width: f32,
48    pub gain: f32,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum Direction {
53    Forward,
54    Rewind,
55}
56
57impl Direction {
58    pub fn name(self) -> &'static str {
59        match self {
60            Self::Forward => "forward",
61            Self::Rewind => "rewind",
62        }
63    }
64
65    pub fn parse(name: &str) -> Option<Self> {
66        [Self::Forward, Self::Rewind]
67            .into_iter()
68            .find(|direction| direction.name() == name)
69    }
70
71    pub fn lead(self) -> f32 {
72        match self {
73            Self::Forward => -1.0,
74            Self::Rewind => 1.0,
75        }
76    }
77}
78
79#[derive(Clone, Copy, Debug, PartialEq)]
80pub struct Tape {
81    pub strength: f32,
82    pub direction: Direction,
83    pub split: Option<f32>,
84    pub echo: Option<f32>,
85    pub wash: Option<f32>,
86    pub smear: Option<f32>,
87    pub grain: Option<f32>,
88    pub bands: Option<f32>,
89}
90
91#[derive(Clone, Copy, Debug, PartialEq)]
92pub struct Amounts {
93    pub split: f32,
94    pub echo: f32,
95    pub wash: f32,
96    pub smear: f32,
97    pub grain: f32,
98    pub bands: f32,
99}
100
101impl Default for Tape {
102    fn default() -> Self {
103        Self::OFF
104    }
105}
106
107impl Tape {
108    pub const OFF: Self = Self::new(0.0, Direction::Forward);
109
110    pub const fn new(strength: f32, direction: Direction) -> Self {
111        Self {
112            strength,
113            direction,
114            split: None,
115            echo: None,
116            wash: None,
117            smear: None,
118            grain: None,
119            bands: None,
120        }
121    }
122
123    pub const fn forward(strength: f32) -> Self {
124        Self::new(strength, Direction::Forward)
125    }
126
127    pub const fn rewind(strength: f32) -> Self {
128        Self::new(strength, Direction::Rewind)
129    }
130
131    pub fn strength(&self) -> f32 {
132        if self.strength.is_finite() {
133            self.strength.clamp(0.0, 1.0)
134        } else {
135            0.0
136        }
137    }
138
139    pub fn is_off(&self) -> bool {
140        self.strength() <= 0.0
141    }
142
143    pub fn amounts(&self) -> Amounts {
144        let strength = self.strength();
145        let amount = |value: Option<f32>, default: f32| {
146            let value = value.unwrap_or(default);
147            if value.is_finite() {
148                strength * value.max(0.0)
149            } else {
150                0.0
151            }
152        };
153        Amounts {
154            split: amount(self.split, 1.0),
155            echo: amount(self.echo, 0.0).min(2.0),
156            wash: amount(self.wash, 1.0).min(1.0),
157            smear: amount(self.smear, 0.0).min(1.0),
158            grain: amount(self.grain, 1.0),
159            bands: amount(self.bands, 1.0),
160        }
161    }
162
163    pub fn params(&self) -> [f32; 16] {
164        let a = self.amounts();
165        [
166            SPLIT[0] * a.split,
167            SPLIT[1] * a.split,
168            0.0,
169            self.direction.lead(),
170            ECHOES[0].1 * a.echo,
171            ECHOES[1].1 * a.echo,
172            SOFT * a.smear,
173            BLEED * a.smear,
174            LIFT * a.wash,
175            1.0 - DESATURATE * a.smear,
176            GRAIN * a.grain,
177            SCANLINES * a.grain,
178            a.bands,
179            SHOVE,
180            0.0,
181            self.strength(),
182        ]
183    }
184
185    pub fn split_pixels(&self, width: u32) -> [f32; 2] {
186        let p = self.params();
187        [p[0] * width as f32, p[1] * width as f32]
188    }
189
190    pub fn echo_pixels(&self, width: u32) -> [f32; 2] {
191        ECHOES.map(|(offset, _)| offset * width as f32)
192    }
193
194    pub fn band_centres(&self, time: f32, seed: u32) -> [f32; 3] {
195        let roll = -self.direction.lead();
196        BANDS.map(|band| centre(roll, time, &band, seed))
197    }
198
199    pub fn envelope(&self, y: f32, time: f32, seed: u32) -> f32 {
200        envelope(-self.direction.lead(), y, time, seed)
201    }
202}
203
204fn centre(roll: f32, time: f32, band: &Band, seed: u32) -> f32 {
205    let spun = (seed & 0xffff) as f32 * band.turn + band.start;
206    let travel = spun - spun.floor() + roll * band.speed * time;
207    (travel - travel.floor()) * (1.0 + 2.0 * band.width) - band.width
208}
209
210fn envelope(roll: f32, y: f32, time: f32, seed: u32) -> f32 {
211    BANDS
212        .iter()
213        .map(|band| {
214            let c = centre(roll, time, band, seed);
215            let d = (1.0 - (y - c).abs() / band.width).max(0.0);
216            d * d * band.gain
217        })
218        .fold(0.0, f32::max)
219}
220
221fn hash(x: u32, y: u32, seed: u32) -> u32 {
222    let mut h =
223        x.wrapping_mul(0x8da6_b343) ^ y.wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
224    h ^= h >> 15;
225    h = h.wrapping_mul(0x2c1b_3c6d);
226    h ^= h >> 12;
227    h
228}
229
230impl Chain {
231    pub fn tape(&self) -> Option<Tape> {
232        self.passes.iter().find_map(|pass| match pass {
233            Pass::Tape(tape) => Some(*tape),
234            _ => None,
235        })
236    }
237
238    pub fn set_tape(&mut self, tape: Tape) {
239        if let Some(Pass::Tape(slot)) = self
240            .passes
241            .iter_mut()
242            .find(|pass| matches!(pass, Pass::Tape(_)))
243        {
244            *slot = tape;
245            return;
246        }
247        let at = self
248            .passes
249            .iter()
250            .rposition(|pass| !matches!(pass, Pass::Encode))
251            .map_or(0, |index| index + 1);
252        self.passes.insert(at, Pass::Tape(tape));
253    }
254}
255
256const TO_I: [f32; 3] = [0.596, -0.274, -0.322];
257const TO_Q: [f32; 3] = [0.211, -0.523, 0.312];
258
259fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
260    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
261}
262
263fn root(c: [f32; 3]) -> [f32; 3] {
264    c.map(|v| v.max(0.0).sqrt())
265}
266
267pub fn apply(image: &Image, tape: &Tape, frame: u32, seed: u32) -> Image {
268    if tape.is_off() || image.width == 0 || image.height == 0 {
269        return image.clone();
270    }
271    let p = tape.params();
272    let fw = image.width as f32;
273    let lead = p[3];
274    let sr = lead * p[0] * fw;
275    let sb = lead * p[1] * fw;
276    let mix = mixed(seed, frame);
277    let t = frame as f32 / 60.0;
278    let roll = -lead;
279    let bands = p[12];
280    let d = p[7] * fw;
281    let (lift, saturation, grain, scan_depth) = (p[8], p[9], p[10], p[11]);
282    let period = (image.height as f32 / 540.0).round_ties_even().max(2.0);
283    let speck_scale = (image.width / 960).max(1);
284    let mut out = image.clone();
285    for row in 0..image.height {
286        let v = (row as f32 + 0.5) / image.height as f32;
287        let env = envelope(roll, v, t, seed);
288        let rh = hash(row, 17, mix);
289        let shove =
290            lead * bands * fw * (p[13] * env + JITTER * env * ((rh >> 16) as f32 / 32768.0 - 1.0));
291        let scan =
292            1.0 - scan_depth * (0.5 + 0.5 * (std::f32::consts::TAU * row as f32 / period).cos());
293        let at = |x: f32| image.sample(x - 0.5, row as f32);
294        for col in 0..image.width {
295            let x = col as f32 + 0.5 + shove + p[6];
296            let here = at(x);
297            let (left, right) = if d > 0.0 {
298                let pair = |p: f32| {
299                    let a = at(p - d);
300                    let b = at(p + d);
301                    std::array::from_fn::<f32, 4, _>(|c| 0.5 * (a[c] + b[c]))
302                };
303                (pair(x + sr), pair(x - sb))
304            } else {
305                (at(x + sr), at(x - sb))
306            };
307            let mut split: [f32; 3] = std::array::from_fn(|c| {
308                MIX[c][0] * here[c] + MIX[c][1] * left[c] + MIX[c][2] * right[c]
309            });
310            if p[4] > 0.0 || p[5] > 0.0 {
311                let e1 = at(x + lead * ECHOES[0].0 * fw);
312                let e2 = at(x + lead * ECHOES[1].0 * fw);
313                let keep = 1.0 - p[4] - p[5];
314                split = std::array::from_fn(|c| split[c] * keep + p[4] * e1[c] + p[5] * e2[c]);
315            }
316            let split = root(split);
317            let mut luma = lift + (1.0 - lift) * dot(split, [0.299, 0.587, 0.114]);
318            let mut chroma = [dot(split, TO_I) * saturation, dot(split, TO_Q) * saturation];
319            if env > 0.0 {
320                let n = grain_hash(col / speck_scale, row, mix ^ 0x51);
321                let lo = 1.0 - 0.12 * env;
322                let u = ((n - lo) / (1.0 - lo)).clamp(0.0, 1.0);
323                luma += bands * env * 0.1 * u * u * (3.0 - 2.0 * u);
324            }
325            let h = hash(col, row, mix ^ 7);
326            let n = (h & 0x3ff) as f32 / 1024.0 + ((h >> 10) & 0x3ff) as f32 / 1024.0 - 1.0;
327            luma = (luma + n * grain) * scan;
328            chroma[0] += (((h >> 20) & 0x3f) as f32 / 64.0 - 0.5) * grain * 0.6;
329            chroma[1] += ((h >> 26) as f32 / 64.0 - 0.5) * grain * 0.6;
330            let g = [
331                luma + 0.956_170_7 * chroma[0] + 0.621_432_6 * chroma[1],
332                luma - 0.272_688_6 * chroma[0] - 0.646_813_2 * chroma[1],
333                luma - 1.103_744_1 * chroma[0] + 1.700_623_1 * chroma[1],
334            ]
335            .map(|v| v.clamp(0.0, 1.0));
336            let i = image.index(col, row);
337            out.pixels[i] = [g[0] * g[0], g[1] * g[1], g[2] * g[2], here[3]];
338        }
339    }
340    out
341}
342
343pub const KEYS: &[&str] = &[
344    "bands",
345    "direction",
346    "echo",
347    "grain",
348    "smear",
349    "split",
350    "strength",
351    "wash",
352];
353
354pub fn read(value: &toml::Value, current: Option<Tape>) -> Result<Tape, String> {
355    let mut tape = current.unwrap_or(Tape::forward(1.0));
356    let number = |value: &toml::Value, key: &str| {
357        value
358            .as_float()
359            .or_else(|| value.as_integer().map(|n| n as f64))
360            .map(|n| n as f32)
361            .filter(|n| n.is_finite() && *n >= 0.0)
362            .ok_or_else(|| format!("{key} is a number of at least 0"))
363    };
364    let direction = |value: &toml::Value| {
365        value
366            .as_str()
367            .and_then(Direction::parse)
368            .ok_or_else(|| "tape.direction is \"forward\" or \"rewind\"".to_string())
369    };
370    match value {
371        toml::Value::String(_) => tape.direction = direction(value)?,
372        toml::Value::Table(table) => {
373            if let Some(bad) = table.keys().find(|key| !KEYS.contains(&key.as_str())) {
374                return Err(format!(
375                    "unknown tape key {bad}; allowed: {}",
376                    KEYS.join(", ")
377                ));
378            }
379            for (key, value) in table {
380                let name = format!("tape.{key}");
381                match key.as_str() {
382                    "strength" => tape.strength = number(value, &name)?.min(1.0),
383                    "direction" => tape.direction = direction(value)?,
384                    "split" => tape.split = Some(number(value, &name)?),
385                    "echo" => tape.echo = Some(number(value, &name)?),
386                    "wash" => tape.wash = Some(number(value, &name)?),
387                    "smear" => tape.smear = Some(number(value, &name)?),
388                    "grain" => tape.grain = Some(number(value, &name)?),
389                    _ => tape.bands = Some(number(value, &name)?),
390                }
391            }
392        }
393        _ => tape.strength = number(value, "tape")?.min(1.0),
394    }
395    Ok(tape)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::gpu::GpuChain;
402    use crate::gpu::tests::half;
403    use crate::preset::Style;
404    use pfx_gpu::{Gpu, OffscreenTarget};
405
406    const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
407
408    fn float(value: u16) -> f32 {
409        let sign = if value & 0x8000 != 0 { -1.0 } else { 1.0 };
410        let exponent = ((value >> 10) & 31) as i32;
411        let mantissa = (value & 1023) as f32;
412        if exponent == 0 {
413            return sign * mantissa * 2.0f32.powi(-24);
414        }
415        sign * (1.0 + mantissa / 1024.0) * 2.0f32.powi(exponent - 15)
416    }
417
418    fn scene(width: u32, height: u32) -> Image {
419        Image::from_fn(width, height, |x, y| {
420            let u = x as f32 / width as f32;
421            let v = y as f32 / height as f32;
422            let mut c = [0.004, 0.004, 0.004, 1.0];
423            if (0.1..0.12).contains(&u) && (0.1..0.8).contains(&v) {
424                c = [1.0, 1.0, 1.0, 1.0];
425            }
426            if (0.3..0.5).contains(&u) && (0.55..0.85).contains(&v) {
427                c = [0.02, 0.3, 0.55, 1.0];
428            }
429            if (0.6..0.9).contains(&u) && (0.2..0.24).contains(&v) {
430                c = [0.9, 0.12, 0.05, 1.0];
431            }
432            if (0.15..0.95).contains(&v) && x % 23 == 4 {
433                c = [0.05, 0.35, 0.4, 1.0];
434            }
435            c.map(|value| float(half(value)))
436        })
437    }
438
439    const LINE: u32 = 5000;
440
441    fn line() -> Image {
442        Image::from_fn(2 * LINE, 2, |x, _| {
443            if x == LINE {
444                [1.0, 1.0, 1.0, 1.0]
445            } else {
446                [0.0, 0.0, 0.0, 1.0]
447            }
448        })
449    }
450
451    fn check_split(image: &Image, out: &[[f32; 4]], direction: Direction, tolerance: f32) {
452        let tape = only_split(direction);
453        let [sr, sb] = tape.split_pixels(image.width);
454        assert_eq!((sr, sb), (34.0, 39.0));
455        let lead = direction.lead() as i32;
456        let at = |x: i32, c: usize| out[(image.width as i32 + x) as usize][c];
457        let line = LINE as i32;
458        let red = line - lead * 34;
459        let blue = line + lead * 39;
460        for (c, weights) in MIX.iter().enumerate() {
461            for (x, want) in [(line, weights[0]), (red, weights[1]), (blue, weights[2])] {
462                let got = at(x, c);
463                assert!(
464                    (got - want).abs() < tolerance,
465                    "{direction:?} channel {c} at {x}: {got} vs {want}"
466                );
467            }
468            for x in [line - 1, line + 1, red - 1, red + 1, blue - 1, blue + 1] {
469                assert!(
470                    at(x, c) < tolerance,
471                    "{direction:?} channel {c} spreads to {x}"
472                );
473            }
474        }
475    }
476
477    fn only_split(direction: Direction) -> Tape {
478        Tape {
479            echo: Some(0.0),
480            wash: Some(0.0),
481            grain: Some(0.0),
482            bands: Some(0.0),
483            ..Tape::new(1.0, direction)
484        }
485    }
486
487    fn output_target(gpu: &Gpu, width: u32, height: u32) -> OffscreenTarget {
488        let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
489            label: Some("tape test output"),
490            size: wgpu::Extent3d {
491                width,
492                height,
493                depth_or_array_layers: 1,
494            },
495            mip_level_count: 1,
496            sample_count: 1,
497            dimension: wgpu::TextureDimension::D2,
498            format: FORMAT,
499            usage: wgpu::TextureUsages::STORAGE_BINDING
500                | wgpu::TextureUsages::TEXTURE_BINDING
501                | wgpu::TextureUsages::COPY_SRC,
502            view_formats: &[],
503        });
504        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
505        OffscreenTarget {
506            texture,
507            view,
508            format: FORMAT,
509            width,
510            height,
511        }
512    }
513
514    struct Bench {
515        gpu: Gpu,
516        input: OffscreenTarget,
517        output: OffscreenTarget,
518    }
519
520    impl Bench {
521        fn new(image: &Image) -> Self {
522            let gpu = pollster::block_on(Gpu::headless()).unwrap();
523            let input = gpu.offscreen(image.width, image.height, FORMAT).unwrap();
524            let output = output_target(&gpu, image.width, image.height);
525            let pixels: Vec<u16> = image
526                .pixels
527                .iter()
528                .flat_map(|p| p.iter().map(|v| half(*v)))
529                .collect();
530            gpu.upload_rgba16(&input, &pixels).unwrap();
531            Self { gpu, input, output }
532        }
533
534        fn chain(&self, chain: Chain) -> GpuChain {
535            GpuChain::new(chain, &self.gpu.device, self.input.width, self.input.height)
536        }
537
538        fn run(&self, post: &GpuChain, frame: u32, seed: u32) -> Vec<u16> {
539            let mut encoder =
540                self.gpu
541                    .device
542                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
543                        label: Some("tape test"),
544                    });
545            post.run(
546                &mut encoder,
547                &self.input.view,
548                None,
549                None,
550                &self.output.view,
551                frame,
552                seed,
553            );
554            self.gpu.queue.submit(Some(encoder.finish()));
555            self.gpu.readback_rgba16(&self.output).unwrap()
556        }
557    }
558
559    fn rows(pixels: &[[f32; 4]], clean: &Image) -> Vec<f32> {
560        (0..clean.height)
561            .map(|y| {
562                (0..clean.width)
563                    .map(|x| {
564                        let i = clean.index(x, y);
565                        (pixels[i][1] - clean.pixels[i][1]).abs()
566                    })
567                    .sum::<f32>()
568                    / clean.width as f32
569            })
570            .collect()
571    }
572
573    fn shift(a: &[f32], b: &[f32]) -> i32 {
574        let n = a.len() as i32;
575        let mut best = (f32::MIN, 0);
576        for d in -n / 4..=n / 4 {
577            let mut score = 0.0;
578            for y in 0..n {
579                let z = y - d;
580                if (0..n).contains(&z) {
581                    score += b[y as usize] * a[z as usize];
582                }
583            }
584            if score > best.0 {
585                best = (score, d);
586            }
587        }
588        best.1
589    }
590
591    fn pixels(bytes: &[u16]) -> Vec<[f32; 4]> {
592        bytes
593            .chunks_exact(4)
594            .map(|c| [float(c[0]), float(c[1]), float(c[2]), float(c[3])])
595            .collect()
596    }
597
598    #[test]
599    fn wgsl_validates() {
600        let module = naga::front::wgsl::parse_str(WGSL).unwrap();
601        naga::valid::Validator::new(
602            naga::valid::ValidationFlags::all(),
603            naga::valid::Capabilities::empty(),
604        )
605        .validate(&module)
606        .unwrap();
607    }
608
609    #[test]
610    fn strength_zero_is_the_chain_without_it() {
611        let image = scene(96, 48);
612        let mut plain = Style::Crt.chain();
613        plain.passes.push(Pass::Encode);
614        plain.frame = 9;
615        plain.seed = 4;
616        let mut taped = plain.clone();
617        taped.set_tape(Tape::rewind(0.0));
618        assert!(matches!(
619            taped.passes[taped.passes.len() - 2..],
620            [Pass::Tape(_), Pass::Encode]
621        ));
622        assert_eq!(
623            plain.apply(&image, None, None),
624            taped.apply(&image, None, None)
625        );
626        assert_eq!(apply(&image, &Tape::OFF, 3, 5), image);
627        assert_eq!(Tape::forward(f32::NAN).params()[15], 0.0);
628    }
629
630    #[test]
631    fn same_time_and_seed_give_the_same_image() {
632        let image = scene(96, 48);
633        let tape = Tape::rewind(1.0);
634        assert_eq!(apply(&image, &tape, 12, 7), apply(&image, &tape, 12, 7));
635        assert_ne!(apply(&image, &tape, 12, 7), apply(&image, &tape, 13, 7));
636        assert_ne!(apply(&image, &tape, 12, 7), apply(&image, &tape, 12, 8));
637    }
638
639    #[test]
640    fn the_split_moves_red_and_blue_the_stated_pixels() {
641        for direction in [Direction::Forward, Direction::Rewind] {
642            check_split(
643                &line(),
644                &apply(&line(), &only_split(direction), 0, 0).pixels,
645                direction,
646                1e-4,
647            );
648        }
649    }
650
651    #[test]
652    fn offsets_scale_with_width() {
653        let tape = Tape::rewind(1.0);
654        let hd = tape.split_pixels(1920);
655        let uhd = tape.split_pixels(3840);
656        assert!((uhd[0] - 2.0 * hd[0]).abs() < 1e-4 && (uhd[1] - 2.0 * hd[1]).abs() < 1e-4);
657        assert!((hd[0] - 6.528).abs() < 1e-3 && (hd[1] - 7.488).abs() < 1e-3);
658        let [a, b] = tape.echo_pixels(3840);
659        assert!((a - 2.0 * tape.echo_pixels(1920)[0]).abs() < 1e-3 && b > a);
660        assert!((Tape::rewind(0.5).split_pixels(1920)[0] - hd[0] * 0.5).abs() < 1e-4);
661        let custom = Tape {
662            split: Some(2.0),
663            ..Tape::rewind(0.5)
664        };
665        assert!((custom.split_pixels(1920)[1] - hd[1]).abs() < 1e-4);
666        let a = Tape::rewind(1.0).amounts();
667        assert_eq!((a.echo, a.smear, a.wash), (0.0, 0.0, 1.0));
668    }
669
670    #[test]
671    fn bands_roll_down_forward_and_up_in_rewind() {
672        for (tape, sign) in [(Tape::forward(1.0), 1.0), (Tape::rewind(1.0), -1.0)] {
673            let a = tape.band_centres(0.5, 3);
674            let b = tape.band_centres(0.6, 3);
675            for (k, band) in BANDS.iter().enumerate() {
676                let moved = b[k] - a[k];
677                let expected = sign * band.speed * 0.1 * (1.0 + 2.0 * band.width);
678                if moved.abs() < 0.5 {
679                    assert!((moved - expected).abs() < 1e-4, "{tape:?} band {k}");
680                }
681            }
682        }
683    }
684
685    #[test]
686    fn a_chain_reads_tape_from_toml() {
687        let chain = crate::parse::parse_str(
688            "style = \"noir\"\nencode = true\ntape = { strength = 0.8, direction = \"rewind\" }\n",
689        )
690        .unwrap();
691        let tape = chain.tape().unwrap();
692        assert_eq!(tape, Tape::rewind(0.8));
693        assert!(matches!(
694            chain.passes[chain.passes.len() - 2..],
695            [Pass::Tape(_), Pass::Encode]
696        ));
697        let plain = crate::parse::parse_str("tape = 0.5\n").unwrap();
698        assert_eq!(plain.tape(), Some(Tape::forward(0.5)));
699        let named = crate::parse::parse_str("tape = \"rewind\"\n").unwrap();
700        assert_eq!(named.tape(), Some(Tape::rewind(1.0)));
701        let tuned = crate::parse::parse_str(
702            "tape = { strength = 1, split = 0.5, echo = 1, wash = 2, smear = 0.5, grain = 0.25, bands = 1.5 }\n",
703        )
704        .unwrap()
705        .tape()
706        .unwrap();
707        assert_eq!(
708            tuned.amounts(),
709            Amounts {
710                split: 0.5,
711                echo: 1.0,
712                wash: 1.0,
713                smear: 0.5,
714                grain: 0.25,
715                bands: 1.5
716            }
717        );
718        assert!(crate::parse::parse_str("tape = { speed = 1 }\n").is_err());
719        assert!(crate::parse::parse_str("tape = \"sideways\"\n").is_err());
720        assert!(crate::parse::parse_str("tape = -1\n").is_err());
721    }
722
723    #[test]
724    fn set_tape_replaces_the_slot() {
725        let mut chain = Chain::new();
726        chain.set_tape(Tape::forward(0.3));
727        chain.passes.push(Pass::Encode);
728        chain.set_tape(Tape::rewind(0.9));
729        assert_eq!(chain.passes.len(), 2);
730        assert_eq!(chain.tape(), Some(Tape::rewind(0.9)));
731    }
732
733    #[test]
734    #[ignore = "needs a GPU; run under pgpu"]
735    fn gpu_strength_zero_is_byte_identical() {
736        let image = scene(200, 72);
737        let bench = Bench::new(&image);
738        for style in [Style::Noir, Style::Film, Style::Neon, Style::Crt] {
739            let mut plain = style.chain();
740            plain.passes.push(Pass::Encode);
741            let want = bench.run(&bench.chain(plain.clone()), 7, 23);
742            let mut slot = plain.clone();
743            slot.set_tape(Tape::OFF);
744            assert_eq!(bench.run(&bench.chain(slot), 7, 23), want, "{style:?}");
745            let mut ramp = bench.chain(plain.clone());
746            ramp.set_tape(Tape::OFF);
747            assert_eq!(bench.run(&ramp, 7, 23), want, "{style:?} unset");
748            ramp.set_tape(Tape::rewind(1.0));
749            assert_ne!(bench.run(&ramp, 7, 23), want, "{style:?} on");
750            ramp.set_tape(Tape::rewind(0.0));
751            assert_eq!(bench.run(&ramp, 7, 23), want, "{style:?} ramped out");
752        }
753        let mut alone = Chain::new();
754        alone.set_tape(Tape::OFF);
755        let got = bench.run(&bench.chain(alone), 7, 23);
756        let want: Vec<u16> = image
757            .pixels
758            .iter()
759            .flat_map(|p| p.iter().map(|v| half(*v)))
760            .collect();
761        assert_eq!(got, want);
762    }
763
764    #[test]
765    #[ignore = "needs a GPU; run under pgpu"]
766    fn gpu_same_time_and_seed_give_the_same_bytes() {
767        let image = scene(1100, 64);
768        let bench = Bench::new(&image);
769        let mut chain = Chain::new();
770        chain.set_tape(Tape::rewind(1.0));
771        let post = bench.chain(chain);
772        let a = bench.run(&post, 30, 11);
773        let b = bench.run(&post, 30, 11);
774        assert_eq!(a, b);
775        assert_ne!(a, bench.run(&post, 31, 11));
776        assert_ne!(a, bench.run(&post, 30, 12));
777    }
778
779    #[test]
780    #[ignore = "needs a GPU; run under pgpu"]
781    fn gpu_split_moves_red_and_blue_the_stated_pixels() {
782        let image = line();
783        let bench = Bench::new(&image);
784        for direction in [Direction::Forward, Direction::Rewind] {
785            let mut chain = Chain::new();
786            chain.set_tape(only_split(direction));
787            let out = pixels(&bench.run(&bench.chain(chain), 0, 0));
788            check_split(&image, &out, direction, 2e-3);
789        }
790    }
791
792    #[test]
793    #[ignore = "needs a GPU; run under pgpu"]
794    fn gpu_bands_move_opposite_ways() {
795        let image = Image::from_fn(1280, 360, |x, _| {
796            let v = if x % 16 < 8 { 0.75 } else { 0.0625 };
797            [v, v, v, 1.0]
798        });
799        let bench = Bench::new(&image);
800        let mut shifts = Vec::new();
801        for direction in [Direction::Forward, Direction::Rewind] {
802            let tape = Tape {
803                split: Some(0.0),
804                echo: Some(0.0),
805                wash: Some(0.0),
806                grain: Some(0.0),
807                ..Tape::new(1.0, direction)
808            };
809            let mut chain = Chain::new();
810            chain.set_tape(tape);
811            let post = bench.chain(chain);
812            let a = rows(&pixels(&bench.run(&post, 60, 5)), &image);
813            let b = rows(&pixels(&bench.run(&post, 66, 5)), &image);
814            assert!(a.iter().any(|v| *v > 0.01), "{direction:?} shows no band");
815            let moved = shift(&a, &b);
816            let centres = tape.band_centres(1.0, 5);
817            let later = tape.band_centres(1.1, 5);
818            println!("{direction:?}: rows moved {moved}, centres {centres:?} -> {later:?}");
819            shifts.push(moved);
820        }
821        assert!(shifts[0] > 0, "forward rolls down: {shifts:?}");
822        assert!(shifts[1] < 0, "rewind rolls up: {shifts:?}");
823    }
824
825    #[test]
826    #[ignore = "needs a GPU; run under pgpu"]
827    fn gpu_matches_cpu() {
828        let image = scene(1300, 90);
829        let bench = Bench::new(&image);
830        for (tape, frame) in [
831            (Tape::rewind(1.0), 40),
832            (Tape::forward(0.6), 3),
833            (
834                Tape {
835                    echo: Some(1.0),
836                    smear: Some(1.0),
837                    ..Tape::forward(1.0)
838                },
839                9,
840            ),
841            (
842                Tape {
843                    split: Some(3.0),
844                    wash: Some(0.3),
845                    bands: Some(2.0),
846                    ..Tape::rewind(0.9)
847                },
848                77,
849            ),
850        ] {
851            let mut chain = Chain::new();
852            chain.set_tape(tape);
853            chain.frame = frame;
854            chain.seed = 13;
855            let want = chain.apply(&image, None, None);
856            let got = pixels(&bench.run(&bench.chain(chain), frame, 13));
857            let mut worst = 0.0f32;
858            for (g, w) in got.iter().zip(&want.pixels) {
859                for c in 0..4 {
860                    worst = worst.max((g[c] - w[c]).abs());
861                }
862            }
863            assert!(worst <= 0.006, "{tape:?}: {worst}");
864        }
865    }
866
867    #[test]
868    #[ignore = "needs a GPU; run under pgpu"]
869    fn gpu_folds_the_encode_into_the_tape() {
870        let image = scene(640, 48);
871        let bench = Bench::new(&image);
872        for style in [Style::Film, Style::Crt, Style::Noir] {
873            let mut chain = style.chain();
874            chain.passes.insert(0, Pass::Exposure(1.0));
875            chain.passes.push(Pass::Encode);
876            chain.set_tape(Tape::rewind(0.8));
877            chain.frame = 21;
878            chain.seed = 3;
879            let want = chain.apply(&image, None, None);
880            let got = pixels(&bench.run(&bench.chain(chain), 21, 3));
881            let mut worst = 0.0f32;
882            for (g, w) in got.iter().zip(&want.pixels) {
883                for c in 0..3 {
884                    worst = worst.max((g[c] - w[c]).abs());
885                }
886            }
887            assert!(worst <= 0.03, "{style:?}: {worst}");
888        }
889    }
890
891    #[test]
892    #[ignore = "needs a GPU; run under pgpu"]
893    fn benchmark_4k() {
894        let gpu = pollster::block_on(Gpu::headless()).unwrap();
895        let input = gpu.offscreen(3840, 2160, FORMAT).unwrap();
896        let output = output_target(&gpu, 3840, 2160);
897        let mut timer = pfx_gpu::GpuProfiler::new(&gpu.device, &gpu.queue);
898        let mut turns = pfx_gpu::pace::Turns::default();
899        let mut chain = Chain::new();
900        chain.set_tape(Tape::rewind(1.0));
901        let post = turns.cpu(|| GpuChain::new(chain, &gpu.device, 3840, 2160));
902        let mut samples = Vec::new();
903        for attempt in 0..24u32 {
904            let mut encoder = gpu
905                .device
906                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
907                    label: Some("tape benchmark"),
908                });
909            post.run_timed(
910                &mut encoder,
911                &input.view,
912                None,
913                None,
914                &output.view,
915                attempt,
916                23,
917                Some(&mut timer),
918            );
919            let slot = timer.finish(&mut encoder);
920            gpu.queue.submit(Some(encoder.finish()));
921            if let Some(slot) = slot {
922                timer.submitted(slot);
923            }
924            gpu.device
925                .poll(wgpu::PollType::wait_indefinitely())
926                .unwrap();
927            let total: f64 = timer
928                .collect(&gpu.device)
929                .iter()
930                .flatten()
931                .map(|pass| pass.milliseconds)
932                .sum();
933            turns.add(total);
934            if attempt > 3 {
935                samples.push(total);
936            }
937        }
938        samples.sort_by(f64::total_cmp);
939        let median = samples[samples.len() / 2];
940        println!(
941            "tape at 3840x2160: median {median:.3} ms, max {:.3} ms",
942            samples[samples.len() - 1]
943        );
944        for tape in [None, Some(Tape::rewind(1.0))] {
945            let mut finish = Style::Noir.chain();
946            finish.passes.insert(0, Pass::Exposure(1.0));
947            finish.passes.push(Pass::Encode);
948            if let Some(tape) = tape {
949                finish.set_tape(tape);
950            }
951            let post = turns.cpu(|| GpuChain::new(finish, &gpu.device, 3840, 2160));
952            let mut totals = Vec::new();
953            for attempt in 0..24u32 {
954                let mut encoder =
955                    gpu.device
956                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
957                            label: Some("tape benchmark finish"),
958                        });
959                post.run_timed(
960                    &mut encoder,
961                    &input.view,
962                    None,
963                    None,
964                    &output.view,
965                    attempt,
966                    23,
967                    Some(&mut timer),
968                );
969                let slot = timer.finish(&mut encoder);
970                gpu.queue.submit(Some(encoder.finish()));
971                if let Some(slot) = slot {
972                    timer.submitted(slot);
973                }
974                gpu.device
975                    .poll(wgpu::PollType::wait_indefinitely())
976                    .unwrap();
977                let passes: Vec<_> = timer.collect(&gpu.device).into_iter().flatten().collect();
978                let total: f64 = passes.iter().map(|pass| pass.milliseconds).sum();
979                turns.add(total);
980                if attempt == 23 {
981                    for pass in &passes {
982                        println!("  {} {:.3} ms", pass.label, pass.milliseconds);
983                    }
984                }
985                if attempt > 3 {
986                    totals.push(total);
987                }
988            }
989            totals.sort_by(f64::total_cmp);
990            println!(
991                "noir finish with tape {:?}: median {:.3} ms",
992                tape.map(|t| t.strength),
993                totals[totals.len() / 2]
994            );
995        }
996        assert!(median < 0.5, "{median:.3} ms");
997    }
998}