Skip to main content

postproject_core/
content.rs

1//! Structure and membership values for compound representations.
2
3use std::collections::BTreeSet;
4
5use crate::{Error, ErrorKind, RationalRate, ResourceId, Result};
6
7/// Maximum encoded length of an extensible resource-role identifier.
8pub const MAX_RESOURCE_ROLE_BYTES: usize = 128;
9/// Maximum combined UTF-8 length of an image-sequence prefix and suffix.
10pub const MAX_SEQUENCE_PATTERN_BYTES: usize = 1_024;
11/// Maximum supported zero-padding width for an image-sequence frame number.
12pub const MAX_FRAME_PADDING: u8 = 32;
13/// Maximum number of sparse frame exceptions stored in one sequence descriptor.
14pub const MAX_SEQUENCE_EXCEPTIONS: usize = 100_000;
15/// Maximum number of materialized resource members in one content structure.
16pub const MAX_CONTENT_MEMBERS: usize = 100_000;
17
18/// An inclusive, regularly stepped frame domain.
19#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
20pub struct FrameRange {
21    start: i64,
22    end: i64,
23    step: u32,
24}
25
26impl FrameRange {
27    /// Creates an inclusive range whose end is aligned to its step.
28    ///
29    /// # Errors
30    ///
31    /// Returns [`ErrorKind::InvalidArgument`] for a reversed range, zero step,
32    /// or an end frame not reachable from the start by whole steps.
33    pub fn new(start: i64, end: i64, step: u32) -> Result<Self> {
34        if step == 0 || end < start {
35            return Err(Error::new(
36                ErrorKind::InvalidArgument,
37                "frame range must be ascending with a non-zero step",
38            ));
39        }
40        let distance = i128::from(end) - i128::from(start);
41        if distance % i128::from(step) != 0 {
42            return Err(Error::new(
43                ErrorKind::InvalidArgument,
44                "frame range end must be aligned to its step",
45            ));
46        }
47        Ok(Self { start, end, step })
48    }
49
50    /// Returns the first frame number.
51    #[must_use]
52    pub const fn start(self) -> i64 {
53        self.start
54    }
55
56    /// Returns the last frame number.
57    #[must_use]
58    pub const fn end(self) -> i64 {
59        self.end
60    }
61
62    /// Returns the positive frame-number increment.
63    #[must_use]
64    pub const fn step(self) -> u32 {
65        self.step
66    }
67
68    /// Returns the exact number of frames in the regular domain.
69    #[must_use]
70    pub fn frame_count(self) -> u128 {
71        let distance = i128::from(self.end) - i128::from(self.start);
72        distance.unsigned_abs() / u128::from(self.step) + 1
73    }
74
75    /// Returns whether the frame belongs to the stepped domain.
76    #[must_use]
77    pub fn contains(self, frame: i64) -> bool {
78        if frame < self.start || frame > self.end {
79            return false;
80        }
81        let distance = i128::from(frame) - i128::from(self.start);
82        distance % i128::from(self.step) == 0
83    }
84}
85
86/// The filename components surrounding an image-sequence frame number.
87#[derive(Clone, Debug, Eq, Hash, PartialEq)]
88pub struct ImageSequencePattern {
89    prefix: String,
90    suffix: String,
91    padding: u8,
92}
93
94impl ImageSequencePattern {
95    /// Creates a bounded filename pattern without directory separators.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`ErrorKind::InvalidArgument`] when the pattern is empty, too
100    /// long, contains a path separator or NUL, or requests excessive padding.
101    pub fn new(prefix: impl Into<String>, suffix: impl Into<String>, padding: u8) -> Result<Self> {
102        let prefix = prefix.into();
103        let suffix = suffix.into();
104        let invalid_character = |character| matches!(character, '/' | '\\' | '\0');
105        if (prefix.is_empty() && suffix.is_empty())
106            || prefix.len().saturating_add(suffix.len()) > MAX_SEQUENCE_PATTERN_BYTES
107            || prefix.chars().any(invalid_character)
108            || suffix.chars().any(invalid_character)
109            || padding > MAX_FRAME_PADDING
110        {
111            return Err(Error::new(
112                ErrorKind::InvalidArgument,
113                "invalid image-sequence filename pattern",
114            ));
115        }
116        Ok(Self {
117            prefix,
118            suffix,
119            padding,
120        })
121    }
122
123    /// Returns the text before the frame number.
124    #[must_use]
125    pub fn prefix(&self) -> &str {
126        &self.prefix
127    }
128
129    /// Returns the text after the frame number.
130    #[must_use]
131    pub fn suffix(&self) -> &str {
132        &self.suffix
133    }
134
135    /// Returns the minimum frame-number width.
136    #[must_use]
137    pub const fn padding(&self) -> u8 {
138        self.padding
139    }
140
141    /// Formats a filename for `frame` without joining it to a locator.
142    #[must_use]
143    pub fn filename(&self, frame: i64) -> String {
144        let frame = format!("{frame:0width$}", width = usize::from(self.padding));
145        format!("{}{frame}{}", self.prefix, self.suffix)
146    }
147}
148
149/// A compact description of one regular or sparse image sequence.
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct ImageSequenceDescriptor {
152    resource_id: ResourceId,
153    pattern: ImageSequencePattern,
154    frames: FrameRange,
155    rate: RationalRate,
156    known_missing_frames: Vec<i64>,
157}
158
159impl ImageSequenceDescriptor {
160    /// Creates a sequence descriptor and canonicalizes its sparse exceptions.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`ErrorKind::InvalidArgument`] when there are too many missing
165    /// frames or an exception does not belong to the regular frame domain.
166    pub fn new(
167        resource_id: ResourceId,
168        pattern: ImageSequencePattern,
169        frames: FrameRange,
170        rate: RationalRate,
171        mut known_missing_frames: Vec<i64>,
172    ) -> Result<Self> {
173        if known_missing_frames.len() > MAX_SEQUENCE_EXCEPTIONS {
174            return Err(Error::new(
175                ErrorKind::InvalidArgument,
176                format!("image sequence has more than {MAX_SEQUENCE_EXCEPTIONS} sparse exceptions"),
177            ));
178        }
179        if known_missing_frames
180            .iter()
181            .any(|frame| !frames.contains(*frame))
182        {
183            return Err(Error::new(
184                ErrorKind::InvalidArgument,
185                "missing image-sequence frame is outside the regular frame domain",
186            ));
187        }
188        known_missing_frames.sort_unstable();
189        known_missing_frames.dedup();
190        Ok(Self {
191            resource_id,
192            pattern,
193            frames,
194            rate,
195            known_missing_frames,
196        })
197    }
198
199    /// Returns the compact patterned resource identity.
200    #[must_use]
201    pub const fn resource_id(&self) -> ResourceId {
202        self.resource_id
203    }
204
205    /// Returns the sequence filename pattern.
206    #[must_use]
207    pub const fn pattern(&self) -> &ImageSequencePattern {
208        &self.pattern
209    }
210
211    /// Returns the regular frame domain before sparse exceptions.
212    #[must_use]
213    pub const fn frames(&self) -> FrameRange {
214        self.frames
215    }
216
217    /// Returns the exact playback or capture rate.
218    #[must_use]
219    pub const fn rate(&self) -> RationalRate {
220        self.rate
221    }
222
223    /// Returns sorted, unique frames known to be absent.
224    #[must_use]
225    pub fn known_missing_frames(&self) -> &[i64] {
226        &self.known_missing_frames
227    }
228
229    /// Returns whether a frame is recorded as missing.
230    #[must_use]
231    pub fn is_known_missing(&self, frame: i64) -> bool {
232        self.known_missing_frames.binary_search(&frame).is_ok()
233    }
234}
235
236/// A namespaced, open-world role for a resource within a representation.
237#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
238pub struct ResourceRole(String);
239
240impl ResourceRole {
241    /// Creates a role such as `org.postproject:essence` or `vendor:playlist`.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`ErrorKind::InvalidArgument`] when the role is empty, too long,
246    /// is not namespaced, or contains unsupported bytes.
247    pub fn new(value: impl Into<String>) -> Result<Self> {
248        let value = value.into();
249        let valid_bytes = value
250            .bytes()
251            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':'));
252        let valid_namespace = value
253            .split_once(':')
254            .is_some_and(|(namespace, local)| !namespace.is_empty() && !local.is_empty());
255        if value.len() > MAX_RESOURCE_ROLE_BYTES || !valid_bytes || !valid_namespace {
256            return Err(Error::new(
257                ErrorKind::InvalidArgument,
258                format!(
259                    "resource role must be a namespaced identifier of at most {MAX_RESOURCE_ROLE_BYTES} ASCII bytes"
260                ),
261            ));
262        }
263        Ok(Self(value))
264    }
265
266    /// Returns the exact role identifier.
267    #[must_use]
268    pub fn as_str(&self) -> &str {
269        &self.0
270    }
271}
272
273/// One resource's role and requiredness within a content structure.
274#[derive(Clone, Debug, Eq, PartialEq)]
275pub struct ResourceMember {
276    resource_id: ResourceId,
277    role: ResourceRole,
278    required: bool,
279}
280
281impl ResourceMember {
282    /// Creates a resource membership.
283    #[must_use]
284    pub const fn new(resource_id: ResourceId, role: ResourceRole, required: bool) -> Self {
285        Self {
286            resource_id,
287            role,
288            required,
289        }
290    }
291
292    /// Returns the participating resource.
293    #[must_use]
294    pub const fn resource_id(&self) -> ResourceId {
295        self.resource_id
296    }
297
298    /// Returns the member's extensible semantic role.
299    #[must_use]
300    pub const fn role(&self) -> &ResourceRole {
301        &self.role
302    }
303
304    /// Returns whether availability of this member is required for completeness.
305    #[must_use]
306    pub const fn is_required(&self) -> bool {
307        self.required
308    }
309}
310
311/// The structural shape used to realize a representation.
312#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
313#[non_exhaustive]
314pub enum ContentStructureKind {
315    /// One concrete resource.
316    SingleResource,
317    /// One compact patterned image-sequence resource.
318    ImageSequence,
319    /// Several required resources consumed in stable order.
320    OrderedParts,
321    /// A role-bearing collection of required and optional resources.
322    Package,
323}
324
325#[derive(Clone, Debug, Eq, PartialEq)]
326enum ContentStructureData {
327    SingleResource(ResourceId),
328    ImageSequence(ImageSequenceDescriptor),
329    OrderedParts(Vec<ResourceMember>),
330    Package(Vec<ResourceMember>),
331}
332
333/// A validated description of how resources realize one representation.
334#[derive(Clone, Debug, Eq, PartialEq)]
335pub struct ContentStructure(ContentStructureData);
336
337impl ContentStructure {
338    /// Creates a representation backed by one required resource.
339    #[must_use]
340    pub const fn single_resource(resource_id: ResourceId) -> Self {
341        Self(ContentStructureData::SingleResource(resource_id))
342    }
343
344    /// Creates a representation backed by one compact image sequence.
345    #[must_use]
346    pub const fn image_sequence(descriptor: ImageSequenceDescriptor) -> Self {
347        Self(ContentStructureData::ImageSequence(descriptor))
348    }
349
350    /// Creates an ordered span whose members are all required.
351    ///
352    /// # Errors
353    ///
354    /// Returns [`ErrorKind::InvalidArgument`] when the membership is empty,
355    /// unbounded, duplicated, or contains an optional member.
356    pub fn ordered_parts(members: Vec<ResourceMember>) -> Result<Self> {
357        validate_members(&members)?;
358        if members.iter().any(|member| !member.is_required()) {
359            return Err(Error::new(
360                ErrorKind::InvalidArgument,
361                "ordered content parts must all be required",
362            ));
363        }
364        Ok(Self(ContentStructureData::OrderedParts(members)))
365    }
366
367    /// Creates a package with at least one required member.
368    ///
369    /// # Errors
370    ///
371    /// Returns [`ErrorKind::InvalidArgument`] when the membership is empty,
372    /// unbounded, duplicated, or has no required member.
373    pub fn package(members: Vec<ResourceMember>) -> Result<Self> {
374        validate_members(&members)?;
375        if !members.iter().any(ResourceMember::is_required) {
376            return Err(Error::new(
377                ErrorKind::InvalidArgument,
378                "a content package must have at least one required member",
379            ));
380        }
381        Ok(Self(ContentStructureData::Package(members)))
382    }
383
384    /// Returns the structure discriminator.
385    #[must_use]
386    pub const fn kind(&self) -> ContentStructureKind {
387        match self.0 {
388            ContentStructureData::SingleResource(_) => ContentStructureKind::SingleResource,
389            ContentStructureData::ImageSequence(_) => ContentStructureKind::ImageSequence,
390            ContentStructureData::OrderedParts(_) => ContentStructureKind::OrderedParts,
391            ContentStructureData::Package(_) => ContentStructureKind::Package,
392        }
393    }
394
395    /// Returns the resource when this is a single-resource structure.
396    #[must_use]
397    pub const fn single_resource_id(&self) -> Option<ResourceId> {
398        match self.0 {
399            ContentStructureData::SingleResource(resource_id) => Some(resource_id),
400            _ => None,
401        }
402    }
403
404    /// Returns the descriptor when this is an image sequence.
405    #[must_use]
406    pub const fn image_sequence_descriptor(&self) -> Option<&ImageSequenceDescriptor> {
407        match &self.0 {
408            ContentStructureData::ImageSequence(descriptor) => Some(descriptor),
409            _ => None,
410        }
411    }
412
413    /// Returns members for an ordered or package structure.
414    #[must_use]
415    pub fn members(&self) -> Option<&[ResourceMember]> {
416        match &self.0 {
417            ContentStructureData::OrderedParts(members)
418            | ContentStructureData::Package(members) => Some(members),
419            _ => None,
420        }
421    }
422
423    /// Returns referenced resources in structural order.
424    #[must_use]
425    pub fn resource_ids(&self) -> Vec<ResourceId> {
426        match &self.0 {
427            ContentStructureData::SingleResource(resource_id) => vec![*resource_id],
428            ContentStructureData::ImageSequence(descriptor) => vec![descriptor.resource_id()],
429            ContentStructureData::OrderedParts(members)
430            | ContentStructureData::Package(members) => {
431                members.iter().map(ResourceMember::resource_id).collect()
432            }
433        }
434    }
435}
436
437fn validate_members(members: &[ResourceMember]) -> Result<()> {
438    if members.is_empty() || members.len() > MAX_CONTENT_MEMBERS {
439        return Err(Error::new(
440            ErrorKind::InvalidArgument,
441            format!("content structure must have 1-{MAX_CONTENT_MEMBERS} members"),
442        ));
443    }
444    let unique: BTreeSet<_> = members.iter().map(ResourceMember::resource_id).collect();
445    if unique.len() != members.len() {
446        return Err(Error::new(
447            ErrorKind::InvalidArgument,
448            "content structure contains a duplicate resource",
449        ));
450    }
451    Ok(())
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn frame_ranges_are_inclusive_and_stepped() {
460        let frames = FrameRange::new(-2, 4, 2).expect("valid range");
461
462        assert_eq!(frames.frame_count(), 4);
463        assert!(frames.contains(-2));
464        assert!(frames.contains(4));
465        assert!(!frames.contains(1));
466        assert!(FrameRange::new(1, 0, 1).is_err());
467        assert!(FrameRange::new(0, 5, 2).is_err());
468    }
469
470    #[test]
471    fn sequence_patterns_format_frames_without_paths() {
472        let pattern = ImageSequencePattern::new("shot.", ".exr", 4).expect("valid pattern");
473
474        assert_eq!(pattern.filename(12), "shot.0012.exr");
475        assert_eq!(pattern.filename(-2), "shot.-002.exr");
476        assert!(ImageSequencePattern::new("directory/shot.", ".exr", 4).is_err());
477        assert!(ImageSequencePattern::new("", "", 0).is_err());
478    }
479
480    #[test]
481    fn sequence_descriptor_is_compact_and_canonical() {
482        let frames = FrameRange::new(1_001, 1_010, 1).expect("valid range");
483        let pattern = ImageSequencePattern::new("render.", ".exr", 4).expect("valid pattern");
484        let rate = RationalRate::new(24_000, 1_001).expect("valid rate");
485        let descriptor = ImageSequenceDescriptor::new(
486            ResourceId::new(),
487            pattern,
488            frames,
489            rate,
490            vec![1_007, 1_003, 1_007],
491        )
492        .expect("valid sequence");
493
494        assert_eq!(descriptor.known_missing_frames(), [1_003, 1_007]);
495        assert!(descriptor.is_known_missing(1_003));
496        assert!(!descriptor.is_known_missing(1_004));
497        assert!(
498            ImageSequenceDescriptor::new(
499                ResourceId::new(),
500                descriptor.pattern().clone(),
501                frames,
502                rate,
503                vec![999],
504            )
505            .is_err()
506        );
507    }
508
509    #[test]
510    fn resource_roles_are_namespaced_and_open_world() {
511        let standard = ResourceRole::new("org.postproject:essence").expect("valid role");
512        let vendor = ResourceRole::new("example.camera:playlist-v2").expect("valid role");
513
514        assert_eq!(standard.as_str(), "org.postproject:essence");
515        assert_eq!(vendor.as_str(), "example.camera:playlist-v2");
516        assert!(ResourceRole::new("essence").is_err());
517        assert!(ResourceRole::new("vendor:").is_err());
518        assert!(ResourceRole::new("vendor:side car").is_err());
519    }
520
521    #[test]
522    fn membership_preserves_role_and_requiredness() {
523        let resource_id = ResourceId::new();
524        let role = ResourceRole::new("org.postproject:thumbnail").expect("valid role");
525        let member = ResourceMember::new(resource_id, role, false);
526
527        assert_eq!(member.resource_id(), resource_id);
528        assert_eq!(member.role().as_str(), "org.postproject:thumbnail");
529        assert!(!member.is_required());
530    }
531
532    #[test]
533    fn compound_structures_enforce_membership_invariants() {
534        let essence = ResourceRole::new("org.postproject:essence").expect("valid role");
535        let thumbnail = ResourceRole::new("org.postproject:thumbnail").expect("valid role");
536        let required = ResourceMember::new(ResourceId::new(), essence, true);
537        let optional = ResourceMember::new(ResourceId::new(), thumbnail, false);
538
539        let ordered =
540            ContentStructure::ordered_parts(vec![required.clone()]).expect("valid ordered parts");
541        let package = ContentStructure::package(vec![required.clone(), optional.clone()])
542            .expect("valid package");
543
544        assert_eq!(ordered.kind(), ContentStructureKind::OrderedParts);
545        assert_eq!(package.members().expect("package members").len(), 2);
546        assert_eq!(
547            package.resource_ids(),
548            vec![required.resource_id(), optional.resource_id()]
549        );
550        assert!(ContentStructure::ordered_parts(vec![optional.clone()]).is_err());
551        assert!(ContentStructure::package(vec![optional]).is_err());
552        assert!(ContentStructure::package(vec![required.clone(), required]).is_err());
553    }
554}