Skip to main content

long_screen/
output.rs

1//! What a run leaves: its rows, its canvases as tiles, PNG output of any canvas, the diagnostics, and the
2//! fingerprint of every row (the format `scripts/fingerprint-scenarios.ts` writes, for parity with the web page).
3
4use crate::context::Ctx;
5use crate::engine::catalog::{display_name, Locale};
6use crate::kernels::geometry::Rect;
7use crate::store::Store;
8use crate::tiles::decode_png;
9use crate::value::{fingerprint, read_rect, Js, Obj};
10use crate::Diagnostic;
11use std::collections::BTreeMap;
12use std::io::{self, Write};
13
14/// One canvas of the result.
15#[derive(Clone, Debug, PartialEq)]
16pub struct CanvasInfo {
17    pub id: String,
18    /// The region (layer) it belongs to.
19    pub layer: String,
20    /// The stored name: the zh vocabulary the page persists (`内容画布 1`, `固定界面`, …) whatever the run's
21    /// language. [`RunResult::display_name`] gives it in the run's language.
22    pub name: String,
23    /// `moving` (reconstructed content), `fixed` (screen-fixed interface) or `presentation` (a framed view).
24    pub kind: String,
25    /// World-coordinate bounds of what was painted.
26    pub bounds: Rect,
27    pub tile_count: u64,
28    pub observed_pixels: f64,
29    /// Fragment number within its layer (0 is the main canvas).
30    pub fragment: u32,
31    /// Set when revisit evidence merged this fragment into another canvas (it then has no tiles).
32    pub attached_to: Option<String>,
33    /// For a framed presentation: the canvas it frames.
34    pub presents: Option<String>,
35}
36
37impl CanvasInfo {
38    fn from_row(o: &Obj) -> CanvasInfo {
39        CanvasInfo {
40            id: o.str("id").unwrap_or_default().to_string(),
41            layer: o.str("layer").unwrap_or_default().to_string(),
42            name: o.str("name").unwrap_or_default().to_string(),
43            kind: o.str("kind").unwrap_or_default().to_string(),
44            bounds: o.get("bounds").and_then(read_rect).unwrap_or(Rect {
45                x: 0.0,
46                y: 0.0,
47                width: 0.0,
48                height: 0.0,
49            }),
50            tile_count: o.num("tileCount").max(0.0) as u64,
51            observed_pixels: o.num("observedPixels"),
52            fragment: o.num("fragment").max(0.0) as u32,
53            attached_to: o.str("attachedTo").map(String::from),
54            presents: o
55                .get("presentation")
56                .and_then(Js::as_obj)
57                .and_then(|p| p.str("sourceCanvas"))
58                .map(String::from),
59        }
60    }
61}
62
63/// A finished (or partial) run.
64pub struct RunResult {
65    store: Store,
66    run_id: String,
67    project: Obj,
68    diagnostics: Vec<Diagnostic>,
69    tile_size: usize,
70    seconds: f64,
71    locale: Locale,
72}
73
74impl std::fmt::Debug for RunResult {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("RunResult")
77            .field("status", &self.status())
78            .field("frames", &self.frames())
79            .field("rows", &self.store.len())
80            .finish()
81    }
82}
83
84impl RunResult {
85    pub(crate) fn new(ctx: Ctx, seconds: f64) -> RunResult {
86        let project = ctx.project();
87        RunResult {
88            run_id: ctx.run_id.clone(),
89            tile_size: ctx.tiles.size,
90            diagnostics: ctx.journal.shown,
91            store: ctx.store,
92            project,
93            seconds,
94            locale: ctx.locale,
95        }
96    }
97
98    /// `complete`, `partial` or `error`.
99    pub fn status(&self) -> &str {
100        self.project.str("status").unwrap_or("error")
101    }
102
103    /// The failure that ended the run early, if one did.
104    pub fn error(&self) -> Option<&str> {
105        self.project.str("error")
106    }
107
108    /// Frames analysed.
109    pub fn frames(&self) -> u32 {
110        self.project.num("frames") as u32
111    }
112
113    pub fn rendered_frames(&self) -> u32 {
114        let n = self.project.num("renderedFrames");
115        if n.is_finite() {
116            n as u32
117        } else {
118            0
119        }
120    }
121
122    /// Wall-clock seconds the run took.
123    pub fn seconds(&self) -> f64 {
124        self.seconds
125    }
126
127    /// The project row (as the page stores it).
128    pub fn project(&self) -> &Obj {
129        &self.project
130    }
131
132    /// Every diagnostic, in emission order, in the run's display language.
133    pub fn diagnostics(&self) -> &[Diagnostic] {
134        &self.diagnostics
135    }
136
137    /// `canvas`'s name in the run's language (`Content canvas 1` in English), as the page shows it.
138    pub fn display_name(&self, canvas: &CanvasInfo) -> String {
139        display_name(self.locale, &canvas.name)
140    }
141
142    /// Every stored row.
143    pub fn store(&self) -> &Store {
144        &self.store
145    }
146
147    fn row(&self, key: &str) -> Option<Js> {
148        self.store.get(&format!("run/{}/{key}", self.run_id))
149    }
150
151    /// The run-relative stored row `key` (e.g. `performance`, `canvas/<id>`).
152    pub fn get(&self, key: &str) -> Option<Js> {
153        self.row(key)
154    }
155
156    /// Every canvas, in key order.
157    pub fn canvases(&self) -> Vec<CanvasInfo> {
158        let base = format!("run/{}/canvas/", self.run_id);
159        self.store
160            .keys(&base)
161            .iter()
162            .filter_map(|k| self.store.get(k))
163            .filter_map(|v| v.as_obj().map(CanvasInfo::from_row))
164            .collect()
165    }
166
167    /// Every canvas worth a PNG, in the order the page lists them (its canvas menu, and `prints` in the npm package):
168    /// framed presentations first when the run framed them ([`crate::Framing::Context`]), then reconstructed
169    /// content, then screen-fixed interface, each by observed pixels, most first. A canvas without tiles (a fragment
170    /// that later evidence merged into another canvas holds none) is left out.
171    pub fn prints(&self) -> Vec<CanvasInfo> {
172        let framed = self
173            .project
174            .get("settings")
175            .and_then(Js::as_obj)
176            .and_then(|s| s.str("framing"))
177            == Some("context");
178        printable(self.canvases(), framed)
179    }
180
181    /// The print: the canvas the page shows first, the first of [`RunResult::prints`].
182    pub fn print(&self) -> Option<CanvasInfo> {
183        self.prints().into_iter().next()
184    }
185
186    /// The reconstructed content other than the print's own, in the page's order: separate fragments the run could
187    /// not place.
188    pub fn fragments(&self) -> Vec<CanvasInfo> {
189        fragments_of(&self.prints())
190    }
191
192    /// The level-0 tile `(x, y)` of `canvas` as `tile_size × tile_size` RGBA, if it was painted.
193    pub fn tile(&self, canvas: &str, x: i32, y: i32) -> Option<Vec<u8>> {
194        let row = self.row(&format!("tile/{canvas}/0/{x}_{y}"))?;
195        let blob = row.as_obj()?.get("blob")?.bytes()?.clone();
196        decode_png(&blob).ok().map(|(_, _, rgba)| rgba)
197    }
198
199    pub fn tile_size(&self) -> usize {
200        self.tile_size
201    }
202
203    /// The pixel extent of `canvas`'s bounds: its top-left world pixel and its size.
204    fn extent(&self, canvas: &str) -> io::Result<(i64, i64, u32, u32)> {
205        let info = self
206            .canvases()
207            .into_iter()
208            .find(|c| c.id == canvas)
209            .ok_or_else(|| {
210                io::Error::new(io::ErrorKind::NotFound, format!("no canvas {canvas}"))
211            })?;
212        let b = info.bounds;
213        let (x0, y0) = (b.x.floor() as i64, b.y.floor() as i64);
214        let (w, h) = (
215            b.width.ceil().max(0.0) as u32,
216            b.height.ceil().max(0.0) as u32,
217        );
218        if w == 0 || h == 0 {
219            return Err(io::Error::new(
220                io::ErrorKind::InvalidData,
221                "the canvas is empty",
222            ));
223        }
224        Ok((x0, y0, w, h))
225    }
226
227    /// The size in pixels of the image [`RunResult::write_png`] and [`RunResult::read_rows`] give for `canvas`.
228    pub fn canvas_size(&self, canvas: &str) -> io::Result<(u32, u32)> {
229        self.extent(canvas).map(|(_, _, w, h)| (w, h))
230    }
231
232    /// Hands `row` each row of `canvas`'s bounds as RGBA (unobserved pixels transparent), top to bottom, decoding
233    /// one band of tiles at a time, so memory stays at one tile row however long the print is.
234    pub fn read_rows(
235        &self,
236        canvas: &str,
237        mut row: impl FnMut(&[u8]) -> io::Result<()>,
238    ) -> io::Result<(u32, u32)> {
239        let (x0, y0, w, h) = self.extent(canvas)?;
240        let size = self.tile_size as i64;
241        let mut line = vec![0u8; w as usize * 4];
242        let mut y = 0i64;
243        while y < h as i64 {
244            let wy = y0 + y;
245            let ty = wy.div_euclid(size);
246            let band_end = ((ty + 1) * size - y0).min(h as i64);
247            let tx0 = x0.div_euclid(size);
248            let tx1 = (x0 + w as i64 - 1).div_euclid(size);
249            let tiles: Vec<Option<Vec<u8>>> = (tx0..=tx1)
250                .map(|tx| self.tile(canvas, tx as i32, ty as i32))
251                .collect();
252            while y < band_end {
253                let ly = (y0 + y - ty * size) as usize;
254                line.fill(0);
255                for (i, tile) in tiles.iter().enumerate() {
256                    let Some(tile) = tile else { continue };
257                    let tx = tx0 + i as i64;
258                    let from = (tx * size).max(x0);
259                    let to = ((tx + 1) * size).min(x0 + w as i64);
260                    let src = ((ly * self.tile_size) + (from - tx * size) as usize) * 4;
261                    let dst = (from - x0) as usize * 4;
262                    let n = (to - from) as usize * 4;
263                    line[dst..dst + n].copy_from_slice(&tile[src..src + n]);
264                }
265                row(&line)?;
266                y += 1;
267            }
268        }
269        Ok((w, h))
270    }
271
272    /// Writes `canvas`'s bounds as one RGBA PNG (unobserved pixels transparent), one band of tiles at a time, so
273    /// memory stays at one tile row however long the print is.
274    pub fn write_png<W: Write>(&self, canvas: &str, out: W) -> io::Result<(u32, u32)> {
275        let (w, h) = self.canvas_size(canvas)?;
276        let mut encoder = png::Encoder::new(out, w, h);
277        encoder.set_color(png::ColorType::Rgba);
278        encoder.set_depth(png::BitDepth::Eight);
279        encoder.set_compression(png::Compression::Fast);
280        let mut writer = encoder.write_header().map_err(io::Error::other)?;
281        let mut stream = writer.stream_writer().map_err(io::Error::other)?;
282        self.read_rows(canvas, |line| stream.write_all(line))?;
283        stream.finish().map_err(io::Error::other)?;
284        Ok((w, h))
285    }
286
287    /// Every row's fingerprint digest, keyed as `scripts/fingerprint-scenarios.ts` keys them (`<run>` for the run
288    /// id), plus the project row's digest.
289    pub fn fingerprint(&self) -> (BTreeMap<String, String>, String) {
290        let mut rows = BTreeMap::new();
291        for (key, _) in self.store.iter() {
292            let value = self.store.get(key).unwrap();
293            let k = key.replace(&self.run_id, "<run>");
294            rows.insert(k, fingerprint(&value, &self.run_id));
295        }
296        let project = fingerprint(&Js::Obj(self.project.clone()), &self.run_id);
297        (rows, project)
298    }
299}
300
301/// The page's order (`printable` in src/package/index.ts; the canvas menu in src/ui/canvases.ts ranks the same way):
302/// canvases with tiles, not merged into another and with finite bounds, ranked presentation (only when `framed`),
303/// then moving, then fixed, and within a rank by observed pixels, most first. The sort is stable, so ties keep key
304/// order, as the page's `Array.prototype.sort` keeps the order its rows arrive in.
305fn printable(canvases: Vec<CanvasInfo>, framed: bool) -> Vec<CanvasInfo> {
306    let rank = |c: &CanvasInfo| match c.kind.as_str() {
307        "presentation" if framed => -1,
308        "fixed" => 1,
309        _ => 0,
310    };
311    let mut out: Vec<CanvasInfo> = canvases
312        .into_iter()
313        .filter(|c| {
314            c.tile_count > 0
315                && c.attached_to.is_none()
316                && c.bounds.width.is_finite()
317                && c.bounds.height.is_finite()
318        })
319        .collect();
320    // `b.observedPixels - a.observedPixels`: a NaN difference compares equal, as it does in the page's sort.
321    out.sort_by(|a, b| {
322        rank(a).cmp(&rank(b)).then_with(|| {
323            (b.observed_pixels - a.observed_pixels)
324                .partial_cmp(&0.0)
325                .unwrap_or(std::cmp::Ordering::Equal)
326        })
327    });
328    out
329}
330
331/// The moving canvases in `prints` other than the one the print shows.
332fn fragments_of(prints: &[CanvasInfo]) -> Vec<CanvasInfo> {
333    let main = prints
334        .first()
335        .map(|p| p.presents.clone().unwrap_or_else(|| p.id.clone()));
336    prints
337        .iter()
338        .filter(|c| c.kind == "moving" && Some(&c.id) != main.as_ref())
339        .cloned()
340        .collect()
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    fn canvas(id: &str, kind: &str, observed: f64) -> CanvasInfo {
348        CanvasInfo {
349            id: id.into(),
350            layer: "body".into(),
351            name: id.into(),
352            kind: kind.into(),
353            bounds: Rect {
354                x: 0.0,
355                y: 0.0,
356                width: 100.0,
357                height: 100.0,
358            },
359            tile_count: 1,
360            observed_pixels: observed,
361            fragment: 0,
362            attached_to: None,
363            presents: None,
364        }
365    }
366
367    fn framed(id: &str, of: &str, observed: f64) -> CanvasInfo {
368        CanvasInfo {
369            presents: Some(of.into()),
370            ..canvas(id, "presentation", observed)
371        }
372    }
373
374    fn ids(canvases: &[CanvasInfo]) -> Vec<&str> {
375        canvases.iter().map(|c| c.id.as_str()).collect()
376    }
377
378    #[test]
379    fn a_framed_main_canvas_outranks_a_bigger_fragment_that_was_not_framed() {
380        // The main canvas was framed; the fragment, with more observed pixels, was not (framing found no reference
381        // for it, or skipped it as too sparse). The page shows the framed main canvas.
382        let rows = vec![
383            canvas("main", "moving", 100.0),
384            framed("main-framed", "main", 150.0),
385            CanvasInfo {
386                fragment: 1,
387                ..canvas("piece", "moving", 200.0)
388            },
389        ];
390        let prints = printable(rows, true);
391        assert_eq!(ids(&prints), ["main-framed", "piece", "main"]);
392        assert_eq!(ids(&fragments_of(&prints)), ["piece"]);
393    }
394
395    #[test]
396    fn presentations_rank_by_their_own_observed_pixels() {
397        // The framed views need not follow their sources' order: each frame adds its own chrome.
398        let rows = vec![
399            canvas("a", "moving", 100.0),
400            canvas("b", "moving", 200.0),
401            framed("a-framed", "a", 300.0),
402            framed("b-framed", "b", 250.0),
403        ];
404        let prints = printable(rows, true);
405        assert_eq!(ids(&prints), ["a-framed", "b-framed", "b", "a"]);
406        assert_eq!(ids(&fragments_of(&prints)), ["b"]);
407    }
408
409    #[test]
410    fn fragments_follow_observed_pixels_not_key_order() {
411        let rows = vec![
412            canvas("a", "moving", 300.0),
413            canvas("b", "moving", 10.0),
414            canvas("c", "moving", 20.0),
415        ];
416        let prints = printable(rows, false);
417        assert_eq!(ids(&prints), ["a", "c", "b"]);
418        assert_eq!(ids(&fragments_of(&prints)), ["c", "b"]);
419    }
420
421    #[test]
422    fn fixed_parts_come_last_and_empty_merged_or_unbounded_canvases_are_left_out() {
423        let rows = vec![
424            canvas("chrome", "fixed", 1e6),
425            canvas("page", "moving", 100.0),
426            CanvasInfo {
427                tile_count: 0,
428                attached_to: Some("page".into()),
429                ..canvas("merged", "moving", 500.0)
430            },
431            CanvasInfo {
432                tile_count: 0,
433                ..canvas("empty", "moving", 0.0)
434            },
435            CanvasInfo {
436                bounds: Rect {
437                    x: 0.0,
438                    y: 0.0,
439                    width: f64::NAN,
440                    height: 100.0,
441                },
442                ..canvas("unbounded", "moving", 900.0)
443            },
444        ];
445        assert_eq!(ids(&printable(rows, true)), ["page", "chrome"]);
446    }
447
448    #[test]
449    fn without_framing_a_presentation_ranks_with_the_content() {
450        // The npm package ranks a presentation first only under `framing: 'context'`.
451        let rows = vec![
452            canvas("page", "moving", 100.0),
453            framed("page-framed", "page", 50.0),
454        ];
455        assert_eq!(ids(&printable(rows, false)), ["page", "page-framed"]);
456    }
457
458    #[test]
459    fn ties_keep_key_order() {
460        let rows = vec![canvas("a", "moving", 100.0), canvas("b", "moving", 100.0)];
461        assert_eq!(ids(&printable(rows, false)), ["a", "b"]);
462    }
463
464    #[test]
465    fn only_fixed_interface_still_gives_a_print() {
466        // The page shows a fixed canvas when nothing moved; so does the print.
467        let prints = printable(vec![canvas("chrome", "fixed", 10.0)], true);
468        assert_eq!(ids(&prints), ["chrome"]);
469        assert!(fragments_of(&prints).is_empty());
470    }
471}