Skip to main content

alpha2_model/
fingerprint.rs

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