Skip to main content

alpha2_model/
fingerprint.rs

1//! What a source's media was when the project recorded it: evidence a host
2//! compares with the bytes it finds, never a locator.
3
4use alpha2_media::ColorMetadata;
5use alpha2_time::FrameRate;
6use serde::{Deserialize, Serialize};
7
8/// Evidence of a source's media, each part recorded only when it was known.
9///
10/// A matching content hash proves the same bytes. Without one, the other
11/// parts can only show a difference. `modified_ms` narrows a search for
12/// candidates and never decides whether a candidate is the same media.
13#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15#[serde(deny_unknown_fields)]
16pub struct MediaFingerprint {
17    /// SHA-256 of the whole file, as 64 lowercase hexadecimal digits.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub content_sha256: Option<String>,
20    /// The file's length in bytes.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub byte_length: Option<u64>,
23    /// The file's modification time, in milliseconds since the Unix epoch.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub modified_ms: Option<i64>,
26    /// The streams the file carries.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub streams: Option<StreamTopology>,
29    /// Where the media ends on its own timeline, in ticks: its first
30    /// presentation plus its length, the end of any source range that reads
31    /// it. A transport stream that starts at 1.4 s and lasts 2 s ends at
32    /// 3.4 s. Non-negative.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub duration_ticks: Option<i64>,
35    /// The color the decoded pictures carry.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub color: Option<ColorMetadata>,
38}
39
40impl MediaFingerprint {
41    /// Whether every recorded part is well formed.
42    #[must_use]
43    pub fn is_valid(&self) -> bool {
44        self.malformed_part().is_none()
45    }
46
47    /// The first recorded part that is not well formed, by its field name:
48    /// a hash that is not 64 lowercase hexadecimal digits, a negative
49    /// duration, a stream without size, channels or rate, or a color whose
50    /// dynamic range contradicts its transfer.
51    #[must_use]
52    pub fn malformed_part(&self) -> Option<&'static str> {
53        if self
54            .content_sha256
55            .as_deref()
56            .is_some_and(|hash| !is_sha256_hex(hash))
57        {
58            Some("content_sha256")
59        } else if self.duration_ticks.is_some_and(|ticks| ticks < 0) {
60            Some("duration_ticks")
61        } else if self
62            .streams
63            .as_ref()
64            .is_some_and(|streams| !streams.is_valid())
65        {
66            Some("streams")
67        } else if self.color.is_some_and(|color| !color.is_consistent()) {
68            Some("color")
69        } else {
70            None
71        }
72    }
73}
74
75/// The streams a media file carries.
76#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78#[serde(deny_unknown_fields)]
79pub struct StreamTopology {
80    /// The primary video stream, if there is one.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub video: Option<VideoStreamShape>,
83    /// The primary audio stream, if there is one.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub audio: Option<AudioStreamShape>,
86}
87
88impl StreamTopology {
89    fn is_valid(&self) -> bool {
90        self.video
91            .as_ref()
92            .is_none_or(|video| video.width > 0 && video.height > 0)
93            && self
94                .audio
95                .as_ref()
96                .is_none_or(|audio| audio.channels > 0 && audio.sample_rate > 0)
97    }
98}
99
100/// A video stream's displayed size and frame rate.
101#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
103#[serde(deny_unknown_fields)]
104#[non_exhaustive]
105pub struct VideoStreamShape {
106    /// Displayed width in square pixels: the stored width stretched by a
107    /// pixel aspect ratio wider than tall, then turned by the stream's
108    /// orientation.
109    pub width: u32,
110    /// Displayed height in square pixels: the stored height stretched by a
111    /// pixel aspect ratio taller than wide, then turned.
112    pub height: u32,
113    /// The rate its first frames are presented at, as every host measures
114    /// it from packet times and rounds it (`alpha2_assets::FrameRateEvidence`),
115    /// when it can be measured. Not the rate a stream declares: a declared
116    /// rate is a guess for variable-rate media, and browsers report none.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub frame_rate: Option<FrameRate>,
119}
120
121impl VideoStreamShape {
122    /// A stream of `width` x `height` pictures at `frame_rate`.
123    #[must_use]
124    pub const fn new(width: u32, height: u32, frame_rate: Option<FrameRate>) -> Self {
125        Self {
126            width,
127            height,
128            frame_rate,
129        }
130    }
131}
132
133/// An audio stream's channel count and sample rate.
134#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
135#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
136#[serde(deny_unknown_fields)]
137#[non_exhaustive]
138pub struct AudioStreamShape {
139    /// Channel count.
140    pub channels: u32,
141    /// Samples per second per channel.
142    pub sample_rate: u32,
143}
144
145impl AudioStreamShape {
146    /// A stream of `channels` channels at `sample_rate`.
147    #[must_use]
148    pub const fn new(channels: u32, sample_rate: u32) -> Self {
149        Self {
150            channels,
151            sample_rate,
152        }
153    }
154}
155
156fn is_sha256_hex(hash: &str) -> bool {
157    hash.len() == 64
158        && hash
159            .bytes()
160            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
161}