Skip to main content

denoize/
project.rs

1//! Portable, source-bound partial-file timelines.
2//!
3//! A project never interprets encoded packet, container edit-list, or granule
4//! coordinates as user time. Every selection reuses [`PresentationRegion`]
5//! and therefore addresses decoded presentation frames bound to the exact
6//! source bytes. Assembly is deliberately linear: unsupported graph shapes,
7//! overlaps, future fields, and changed sources fail before publication.
8
9use crate::batch_resume::{self, Digest, FileFingerprint};
10use crate::decode::{AudioStreamReader, DecodeLimits};
11use crate::{
12    AtomicOutput, AudioInputSession, AudioStreamWriter, ChannelLayout, CommitMode, EncodeOptions,
13    ExecutionPlan, OutputFormat, PresentationRegion, RuntimeModelPackage, SignedExecutionReceipt,
14    StreamEncodeSpec,
15};
16use hound::{SampleFormat, WavSpec};
17use serde::{Deserialize, Serialize};
18use sha2::{Digest as _, Sha256};
19use std::collections::{BTreeMap, BTreeSet, VecDeque};
20use std::io::{Read as _, Write as _};
21use std::path::{Path, PathBuf};
22
23#[path = "project_bundle.rs"]
24mod bundle;
25pub use bundle::{
26    build_project_bundle, import_project_bundle, inspect_project_bundle, ProjectBundleBinding,
27    ProjectBundleBindingKind, ProjectBundleBuildOptions, ProjectBundleFileInfo,
28    ProjectBundleImportReport, ProjectBundleInfo, PROJECT_BUNDLE_IMPORT_SCHEMA,
29    PROJECT_BUNDLE_SCHEMA,
30};
31#[path = "project_execution.rs"]
32mod execution_contract;
33pub use execution_contract::{
34    write_project_execution_plan, write_signed_project_execution_receipt, ProjectExecutionPlan,
35    ProjectExecutionReceiptPayload, ProjectReceiptVerificationReport,
36    SignedProjectExecutionReceipt, PROJECT_EXECUTION_PLAN_SCHEMA, PROJECT_EXECUTION_RECEIPT_SCHEMA,
37    PROJECT_RECEIPT_VERIFICATION_SCHEMA,
38};
39#[path = "project_automation.rs"]
40mod automation_contract;
41pub use automation_contract::{
42    run_project_batch, ProjectBatchItemReport, ProjectBatchReport, ProjectBatchRequest,
43    PROJECT_BATCH_SCHEMA, PROJECT_WATCH_CYCLE_SCHEMA,
44};
45
46/// Stable identifier for a portable Stage 23 project document.
47pub const PROJECT_MANIFEST_SCHEMA: &str = "denoize-project-v1";
48/// Current portable project schema version.
49pub const PROJECT_MANIFEST_SCHEMA_VERSION: u32 = 1;
50/// Stable identifier for read-only project verification evidence.
51pub const PROJECT_VALIDATION_SCHEMA: &str = "denoize-project-verification-v1";
52/// Stable identifier for a completed deterministic assembly report.
53pub const PROJECT_RENDER_SCHEMA: &str = "denoize-project-render-v1";
54
55const PROJECT_DIGEST_DOMAIN: &[u8] = b"denoize-project-manifest-digest-v1";
56const TIMELINE_DIGEST_DOMAIN: &[u8] = b"denoize-project-timeline-digest-v1";
57const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
58const MAX_PROJECT_JSON_BYTES: u64 = 16 * 1024 * 1024;
59const MAX_PROJECT_SOURCES: usize = 4_096;
60const MAX_PROJECT_TIMELINES: usize = 1_024;
61const MAX_PROJECT_SELECTIONS: usize = 200_000;
62const MAX_PROJECT_REFERENCES: usize = 16_384;
63const MAX_IDENTIFIER_BYTES: usize = 128;
64const MAX_LOCATOR_BYTES: usize = 4_096;
65const MAX_TEXT_BYTES: usize = 1_024;
66const PROJECT_STREAM_BLOCK_FRAMES: usize = 8_192;
67const MAX_CROSSFADE_FRAMES: u64 = 1_048_576;
68const MAX_CROSSFADE_BYTES: u64 = 64 * 1024 * 1024;
69const MAX_PROJECT_TIMESCALE: u32 = crate::config::MAX_SAMPLE_RATE;
70const MAX_PROJECT_CHANNELS: u16 = crate::config::MAX_STREAM_CHANNELS as u16;
71
72/// One regular project-owned document bound by a portable locator and digest.
73#[non_exhaustive]
74#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
75#[serde(deny_unknown_fields)]
76pub struct ProjectArtifactReference {
77    pub id: String,
78    pub locator: String,
79    pub fingerprint: FileFingerprint,
80}
81
82/// One exact source and its decoded presentation geometry.
83#[non_exhaustive]
84#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
85#[serde(deny_unknown_fields)]
86pub struct ProjectSource {
87    pub id: String,
88    pub locator: String,
89    pub fingerprint: FileFingerprint,
90    pub timescale: u32,
91    pub channels: u16,
92    pub presentation_frames: u64,
93    pub license: Option<ProjectArtifactReference>,
94}
95
96/// One source-bound edit in a deterministic linear timeline.
97///
98/// `channel_map` has one zero-based source-channel index for each timeline
99/// output channel. `crossfade_from_previous_ticks` overlaps only the adjacent
100/// source regions; padding on that boundary is therefore rejected.
101#[non_exhaustive]
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
103#[serde(deny_unknown_fields)]
104pub struct ProjectSelection {
105    pub id: String,
106    pub source_id: String,
107    pub region: PresentationRegion,
108    pub channel_map: Vec<u16>,
109    pub padding_before_ticks: u64,
110    pub padding_after_ticks: u64,
111    pub crossfade_from_previous_ticks: u64,
112}
113
114/// A bounded edit graph whose only supported shape is an ordered linear path.
115#[non_exhaustive]
116#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
117#[serde(deny_unknown_fields)]
118pub struct ProjectTimeline {
119    pub id: String,
120    pub timescale: u32,
121    pub channels: u16,
122    pub selections: Vec<ProjectSelection>,
123}
124
125/// A signed custom-model package and the public key required to authenticate it.
126#[non_exhaustive]
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
128#[serde(deny_unknown_fields)]
129pub struct ProjectModelReference {
130    pub id: String,
131    pub package: ProjectArtifactReference,
132    pub public_key: ProjectArtifactReference,
133    pub package_id: String,
134    pub package_revision: String,
135    pub signing_key_id: String,
136    pub license_spdx: String,
137}
138
139/// Versioned portable project state. Source and model payloads are references,
140/// never embedded in this JSON document.
141#[non_exhaustive]
142#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
143#[serde(deny_unknown_fields)]
144pub struct ProjectManifest {
145    pub schema: String,
146    pub schema_version: u32,
147    pub project_id: String,
148    pub denoize_version: String,
149    pub sources: Vec<ProjectSource>,
150    pub timelines: Vec<ProjectTimeline>,
151    pub settings: Vec<ProjectArtifactReference>,
152    pub presets: Vec<ProjectArtifactReference>,
153    pub models: Vec<ProjectModelReference>,
154    pub plans: Vec<ProjectArtifactReference>,
155    pub receipts: Vec<ProjectArtifactReference>,
156}
157
158/// Exact presentation metadata discovered without whole-file PCM retention.
159#[non_exhaustive]
160#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
161pub struct ProjectSourceInspection {
162    pub fingerprint: FileFingerprint,
163    pub timescale: u32,
164    pub channels: u16,
165    pub presentation_frames: u64,
166    pub format: String,
167    pub codec: String,
168}
169
170/// Machine-readable evidence produced by read-only project validation.
171#[non_exhaustive]
172#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
173#[serde(deny_unknown_fields)]
174pub struct ProjectValidationReport {
175    pub schema: String,
176    pub schema_version: u32,
177    pub project_id: String,
178    pub manifest_digest: Digest,
179    pub sources_verified: u64,
180    pub settings_verified: u64,
181    pub presets_verified: u64,
182    pub models_verified: u64,
183    pub plans_verified: u64,
184    pub receipts_verified: u64,
185    pub timelines_verified: u64,
186}
187
188/// Completed output identity for one project timeline assembly.
189#[non_exhaustive]
190#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
191#[serde(deny_unknown_fields)]
192pub struct ProjectRenderReport {
193    pub schema: String,
194    pub schema_version: u32,
195    pub project_id: String,
196    pub manifest_digest: Digest,
197    pub timeline_id: String,
198    pub timeline_digest: Digest,
199    pub output: FileFingerprint,
200    pub timescale: u32,
201    pub channels: u16,
202    pub presentation_frames: u64,
203    pub retained_pcm_upper_bound_bytes: u64,
204}
205
206impl ProjectArtifactReference {
207    pub fn new(
208        id: impl Into<String>,
209        locator: impl Into<String>,
210        fingerprint: FileFingerprint,
211    ) -> Result<Self, String> {
212        let reference = Self {
213            id: id.into(),
214            locator: locator.into(),
215            fingerprint,
216        };
217        validate_artifact_reference("project artifact", &reference)?;
218        Ok(reference)
219    }
220}
221
222impl ProjectSource {
223    pub fn new(
224        id: impl Into<String>,
225        locator: impl Into<String>,
226        inspection: ProjectSourceInspection,
227        license: Option<ProjectArtifactReference>,
228    ) -> Result<Self, String> {
229        let source = Self {
230            id: id.into(),
231            locator: locator.into(),
232            fingerprint: inspection.fingerprint,
233            timescale: inspection.timescale,
234            channels: inspection.channels,
235            presentation_frames: inspection.presentation_frames,
236            license,
237        };
238        validate_identifier("project source ID", &source.id)?;
239        validate_locator(&source.locator)?;
240        validate_fingerprint("project source", source.fingerprint)?;
241        if source.timescale == 0
242            || source.timescale > MAX_PROJECT_TIMESCALE
243            || source.channels == 0
244            || source.channels > MAX_PROJECT_CHANNELS
245            || source.presentation_frames == 0
246        {
247            return Err("project source presentation geometry is unsupported".into());
248        }
249        Ok(source)
250    }
251}
252
253impl ProjectSelection {
254    #[allow(clippy::too_many_arguments)]
255    pub fn new(
256        id: impl Into<String>,
257        source_id: impl Into<String>,
258        region: PresentationRegion,
259        channel_map: Vec<u16>,
260        padding_before_ticks: u64,
261        padding_after_ticks: u64,
262        crossfade_from_previous_ticks: u64,
263    ) -> Result<Self, String> {
264        let selection = Self {
265            id: id.into(),
266            source_id: source_id.into(),
267            region,
268            channel_map,
269            padding_before_ticks,
270            padding_after_ticks,
271            crossfade_from_previous_ticks,
272        };
273        validate_identifier("project selection ID", &selection.id)?;
274        validate_identifier("project selection source ID", &selection.source_id)?;
275        selection.region.validate()?;
276        if selection.channel_map.is_empty() {
277            return Err("project selection channel map must not be empty".into());
278        }
279        Ok(selection)
280    }
281}
282
283impl ProjectModelReference {
284    pub fn open(
285        id: impl Into<String>,
286        package: ProjectArtifactReference,
287        public_key: ProjectArtifactReference,
288        root: impl AsRef<Path>,
289    ) -> Result<Self, String> {
290        let root = canonical_project_root(root.as_ref())?;
291        let package_path = verify_artifact_reference(&root, &package, "project model package")?;
292        let public_key_path =
293            verify_artifact_reference(&root, &public_key, "project model public key")?;
294        let runtime = RuntimeModelPackage::open(package_path, public_key_path)?;
295        let info = runtime.info();
296        let reference = Self {
297            id: id.into(),
298            package,
299            public_key,
300            package_id: info.package_id,
301            package_revision: info.package_revision,
302            signing_key_id: info.signing_key_id,
303            license_spdx: info.license_spdx,
304        };
305        validate_identifier("project model ID", &reference.id)?;
306        Ok(reference)
307    }
308}
309
310impl ProjectTimeline {
311    pub fn new(
312        id: impl Into<String>,
313        timescale: u32,
314        channels: u16,
315        selections: Vec<ProjectSelection>,
316    ) -> Result<Self, String> {
317        let timeline = Self {
318            id: id.into(),
319            timescale,
320            channels,
321            selections,
322        };
323        validate_identifier("project timeline ID", &timeline.id)?;
324        if timeline.timescale == 0
325            || timeline.timescale > MAX_PROJECT_TIMESCALE
326            || timeline.channels == 0
327            || timeline.channels > MAX_PROJECT_CHANNELS
328            || timeline.selections.is_empty()
329        {
330            return Err("project timeline geometry or selections are unsupported".into());
331        }
332        timeline.presentation_frames()?;
333        Ok(timeline)
334    }
335
336    /// Return the exact assembled presentation length after adjacent fades.
337    pub fn presentation_frames(&self) -> Result<u64, String> {
338        let mut frames = 0_u64;
339        for selection in &self.selections {
340            frames = frames
341                .checked_add(selection.padding_before_ticks)
342                .and_then(|value| value.checked_add(selection.region.duration_ticks))
343                .and_then(|value| value.checked_add(selection.padding_after_ticks))
344                .and_then(|value| value.checked_sub(selection.crossfade_from_previous_ticks))
345                .ok_or_else(|| "project timeline presentation length overflows".to_string())?;
346        }
347        if frames == 0 || frames > MAX_JSON_SAFE_INTEGER {
348            return Err("project timeline presentation length is outside JSON-safe bounds".into());
349        }
350        Ok(frames)
351    }
352
353    /// Stable digest shared by rendering, plans, receipts, batch, and watch.
354    pub fn digest(&self) -> Result<Digest, String> {
355        let encoded = serde_json::to_vec(self)
356            .map_err(|error| format!("serialize project timeline for digest: {error}"))?;
357        Ok(domain_digest(TIMELINE_DIGEST_DOMAIN, &encoded))
358    }
359}
360
361impl ProjectManifest {
362    #[allow(clippy::too_many_arguments)]
363    pub fn new(
364        project_id: impl Into<String>,
365        mut sources: Vec<ProjectSource>,
366        mut timelines: Vec<ProjectTimeline>,
367        mut settings: Vec<ProjectArtifactReference>,
368        mut presets: Vec<ProjectArtifactReference>,
369        mut models: Vec<ProjectModelReference>,
370        mut plans: Vec<ProjectArtifactReference>,
371        mut receipts: Vec<ProjectArtifactReference>,
372    ) -> Result<Self, String> {
373        sources.sort_by(|left, right| left.id.cmp(&right.id));
374        timelines.sort_by(|left, right| left.id.cmp(&right.id));
375        settings.sort_by(|left, right| left.id.cmp(&right.id));
376        presets.sort_by(|left, right| left.id.cmp(&right.id));
377        models.sort_by(|left, right| left.id.cmp(&right.id));
378        plans.sort_by(|left, right| left.id.cmp(&right.id));
379        receipts.sort_by(|left, right| left.id.cmp(&right.id));
380        let manifest = Self {
381            schema: PROJECT_MANIFEST_SCHEMA.into(),
382            schema_version: PROJECT_MANIFEST_SCHEMA_VERSION,
383            project_id: project_id.into(),
384            denoize_version: env!("CARGO_PKG_VERSION").into(),
385            sources,
386            timelines,
387            settings,
388            presets,
389            models,
390            plans,
391            receipts,
392        };
393        manifest.validate()?;
394        Ok(manifest)
395    }
396
397    /// Parse one bounded regular project document and reject future fields.
398    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, String> {
399        let bytes =
400            read_bounded_regular(path.as_ref(), "project manifest", MAX_PROJECT_JSON_BYTES)?;
401        let manifest: Self = serde_json::from_slice(&bytes).map_err(|error| {
402            format!(
403                "parse project manifest {}: {error}",
404                path.as_ref().display()
405            )
406        })?;
407        manifest.validate()?;
408        Ok(manifest)
409    }
410
411    pub fn to_json(&self) -> Result<String, String> {
412        self.validate()?;
413        serde_json::to_string(self).map_err(|error| format!("serialize project manifest: {error}"))
414    }
415
416    pub fn to_pretty_json(&self) -> Result<String, String> {
417        self.validate()?;
418        let encoded = serde_json::to_string_pretty(self)
419            .map_err(|error| format!("serialize project manifest: {error}"))?;
420        if encoded.len() as u64 >= MAX_PROJECT_JSON_BYTES {
421            return Err("serialized project manifest exceeds its 16 MiB limit".into());
422        }
423        Ok(encoded)
424    }
425
426    pub fn digest(&self) -> Result<Digest, String> {
427        self.validate()?;
428        let encoded = serde_json::to_vec(self)
429            .map_err(|error| format!("serialize project manifest for digest: {error}"))?;
430        Ok(domain_digest(PROJECT_DIGEST_DOMAIN, &encoded))
431    }
432
433    pub fn timeline(&self, id: &str) -> Result<&ProjectTimeline, String> {
434        self.timelines
435            .binary_search_by(|timeline| timeline.id.as_str().cmp(id))
436            .map(|index| &self.timelines[index])
437            .map_err(|_| format!("project has no timeline named {id}"))
438    }
439
440    /// Validate the closed schema, all finite bounds, and the supported linear
441    /// edit-graph shape without opening any referenced path.
442    pub fn validate(&self) -> Result<(), String> {
443        if self.schema != PROJECT_MANIFEST_SCHEMA
444            || self.schema_version != PROJECT_MANIFEST_SCHEMA_VERSION
445        {
446            return Err(format!(
447                "unsupported project manifest schema: {} v{}",
448                self.schema, self.schema_version
449            ));
450        }
451        validate_identifier("project ID", &self.project_id)?;
452        validate_text("project denoize version", &self.denoize_version)?;
453        if self.sources.is_empty() || self.sources.len() > MAX_PROJECT_SOURCES {
454            return Err(format!(
455                "project source count must be in 1..={MAX_PROJECT_SOURCES}"
456            ));
457        }
458        if self.timelines.is_empty() || self.timelines.len() > MAX_PROJECT_TIMELINES {
459            return Err(format!(
460                "project timeline count must be in 1..={MAX_PROJECT_TIMELINES}"
461            ));
462        }
463        validate_sorted_unique(&self.sources, |source| &source.id, "project sources")?;
464        validate_sorted_unique(
465            &self.timelines,
466            |timeline| &timeline.id,
467            "project timelines",
468        )?;
469        validate_sorted_unique(&self.settings, |item| &item.id, "project settings")?;
470        validate_sorted_unique(&self.presets, |item| &item.id, "project presets")?;
471        validate_sorted_unique(&self.models, |item| &item.id, "project models")?;
472        validate_sorted_unique(&self.plans, |item| &item.id, "project plans")?;
473        validate_sorted_unique(&self.receipts, |item| &item.id, "project receipts")?;
474        for (label, count) in [
475            ("settings", self.settings.len()),
476            ("presets", self.presets.len()),
477            ("models", self.models.len()),
478            ("plans", self.plans.len()),
479            ("receipts", self.receipts.len()),
480        ] {
481            if count > MAX_PROJECT_REFERENCES {
482                return Err(format!(
483                    "project {label} exceed the {MAX_PROJECT_REFERENCES}-item limit"
484                ));
485            }
486        }
487
488        let mut locators = BTreeMap::new();
489        let mut sources = BTreeMap::new();
490        for source in &self.sources {
491            validate_identifier("project source ID", &source.id)?;
492            validate_locator(&source.locator)?;
493            validate_fingerprint("project source", source.fingerprint)?;
494            if source.timescale == 0
495                || source.timescale > MAX_PROJECT_TIMESCALE
496                || source.channels == 0
497                || source.channels > MAX_PROJECT_CHANNELS
498                || source.presentation_frames == 0
499            {
500                return Err("project source presentation geometry is unsupported".into());
501            }
502            if source.presentation_frames > MAX_JSON_SAFE_INTEGER {
503                return Err(
504                    "project source frame count exceeds the JSON safe-integer limit".into(),
505                );
506            }
507            record_locator(
508                &mut locators,
509                &source.locator,
510                source.fingerprint,
511                "project source",
512            )?;
513            if let Some(license) = &source.license {
514                validate_artifact_reference("project source license", license)?;
515                record_locator(
516                    &mut locators,
517                    &license.locator,
518                    license.fingerprint,
519                    "project source license",
520                )?;
521            }
522            sources.insert(source.id.as_str(), source);
523        }
524        for (kind, references) in [
525            ("setting", self.settings.as_slice()),
526            ("preset", self.presets.as_slice()),
527            ("plan", self.plans.as_slice()),
528            ("receipt", self.receipts.as_slice()),
529        ] {
530            for reference in references {
531                validate_artifact_reference(&format!("project {kind}"), reference)?;
532                record_locator(
533                    &mut locators,
534                    &reference.locator,
535                    reference.fingerprint,
536                    &format!("project {kind}"),
537                )?;
538            }
539        }
540        for model in &self.models {
541            validate_identifier("project model ID", &model.id)?;
542            validate_artifact_reference("project model package", &model.package)?;
543            validate_artifact_reference("project model public key", &model.public_key)?;
544            validate_text("project model package ID", &model.package_id)?;
545            validate_text("project model package revision", &model.package_revision)?;
546            validate_text("project model signing key ID", &model.signing_key_id)?;
547            validate_text("project model license", &model.license_spdx)?;
548            record_locator(
549                &mut locators,
550                &model.package.locator,
551                model.package.fingerprint,
552                "project model package",
553            )?;
554            record_locator(
555                &mut locators,
556                &model.public_key.locator,
557                model.public_key.fingerprint,
558                "project model public key",
559            )?;
560        }
561
562        let mut selection_count = 0usize;
563        for timeline in &self.timelines {
564            validate_identifier("project timeline ID", &timeline.id)?;
565            if timeline.timescale == 0
566                || timeline.timescale > MAX_PROJECT_TIMESCALE
567                || timeline.channels == 0
568                || timeline.channels > MAX_PROJECT_CHANNELS
569            {
570                return Err("project timeline geometry is unsupported".into());
571            }
572            if timeline.selections.is_empty() {
573                return Err(format!(
574                    "project timeline {} has no selections",
575                    timeline.id
576                ));
577            }
578            selection_count = selection_count
579                .checked_add(timeline.selections.len())
580                .ok_or_else(|| "project selection count overflows".to_string())?;
581            if selection_count > MAX_PROJECT_SELECTIONS {
582                return Err(format!(
583                    "project exceeds the {MAX_PROJECT_SELECTIONS}-selection limit"
584                ));
585            }
586            let mut selection_ids = BTreeSet::new();
587            for (index, selection) in timeline.selections.iter().enumerate() {
588                validate_identifier("project selection ID", &selection.id)?;
589                if !selection_ids.insert(selection.id.as_str()) {
590                    return Err(format!(
591                        "project timeline {} contains duplicate selection ID {}",
592                        timeline.id, selection.id
593                    ));
594                }
595                let source = sources.get(selection.source_id.as_str()).ok_or_else(|| {
596                    format!(
597                        "project selection {} references unknown source {}",
598                        selection.id, selection.source_id
599                    )
600                })?;
601                selection.region.validate_source(
602                    source.fingerprint,
603                    source.timescale,
604                    source.presentation_frames,
605                )?;
606                if timeline.timescale != source.timescale {
607                    return Err(format!(
608                        "project timeline {} timescale does not match source {}",
609                        timeline.id, source.id
610                    ));
611                }
612                if selection.channel_map.len() != usize::from(timeline.channels) {
613                    return Err(format!(
614                        "project selection {} channel map must contain {} entries",
615                        selection.id, timeline.channels
616                    ));
617                }
618                if selection
619                    .channel_map
620                    .iter()
621                    .any(|channel| *channel >= source.channels)
622                {
623                    return Err(format!(
624                        "project selection {} channel map references a missing source channel",
625                        selection.id
626                    ));
627                }
628                for value in [
629                    selection.padding_before_ticks,
630                    selection.padding_after_ticks,
631                ] {
632                    if value > MAX_JSON_SAFE_INTEGER {
633                        return Err("project selection padding exceeds JSON-safe bounds".into());
634                    }
635                }
636                let crossfade = selection.crossfade_from_previous_ticks;
637                if index == 0 && crossfade != 0 {
638                    return Err(
639                        "the first project selection cannot crossfade from a predecessor".into(),
640                    );
641                }
642                if crossfade > MAX_CROSSFADE_FRAMES || crossfade > selection.region.duration_ticks {
643                    return Err(format!(
644                        "project selection {} crossfade exceeds its bound",
645                        selection.id
646                    ));
647                }
648                if crossfade > 0 {
649                    let previous = &timeline.selections[index - 1];
650                    if crossfade > previous.region.duration_ticks {
651                        return Err(format!(
652                            "project selection {} crossfade exceeds its predecessor",
653                            selection.id
654                        ));
655                    }
656                    if selection.padding_before_ticks != 0 || previous.padding_after_ticks != 0 {
657                        return Err(format!(
658                            "project selection {} cannot overlap across a padded boundary",
659                            selection.id
660                        ));
661                    }
662                    let bytes = crossfade
663                        .checked_mul(u64::from(timeline.channels))
664                        .and_then(|samples| samples.checked_mul(16))
665                        .ok_or_else(|| {
666                            "project crossfade retained-byte count overflows".to_string()
667                        })?;
668                    if bytes > MAX_CROSSFADE_BYTES {
669                        return Err(format!(
670                            "project selection {} crossfade exceeds the {}-byte retained PCM limit",
671                            selection.id, MAX_CROSSFADE_BYTES
672                        ));
673                    }
674                }
675            }
676            timeline.presentation_frames()?;
677        }
678        let encoded = serde_json::to_vec(self)
679            .map_err(|error| format!("serialize project manifest for validation: {error}"))?;
680        if encoded.len() as u64 >= MAX_PROJECT_JSON_BYTES {
681            return Err("project manifest exceeds its 16 MiB limit".into());
682        }
683        Ok(())
684    }
685}
686
687/// Inspect and fully decode a source in bounded blocks to establish its exact
688/// presentation geometry and content fingerprint.
689pub fn inspect_project_source(
690    path: impl AsRef<Path>,
691    limits: DecodeLimits,
692) -> Result<ProjectSourceInspection, String> {
693    let session = AudioInputSession::open(path.as_ref())?;
694    let mut reader = AudioStreamReader::from_session(session, limits)?;
695    let info = reader.info();
696    let initial = reader.fingerprint_input()?;
697    let mut frames = 0_u64;
698    while let Some(block) = reader.next_block(PROJECT_STREAM_BLOCK_FRAMES)? {
699        let block_frames = block.first().map_or(0, Vec::len);
700        if block_frames == 0 || block.iter().any(|channel| channel.len() != block_frames) {
701            return Err("project source decoder returned invalid planar geometry".into());
702        }
703        frames = frames
704            .checked_add(block_frames as u64)
705            .ok_or_else(|| "project source presentation length overflows".to_string())?;
706        if frames > MAX_JSON_SAFE_INTEGER {
707            return Err("project source presentation length exceeds JSON-safe bounds".into());
708        }
709    }
710    if frames == 0 {
711        return Err("project source has no presentation frames".into());
712    }
713    if info.total_frames.is_some_and(|declared| declared != frames) {
714        return Err(format!(
715            "project source decoded {frames} frames, but its container declared {}",
716            info.total_frames.unwrap_or(0)
717        ));
718    }
719    let final_fingerprint = reader.fingerprint_input()?;
720    if final_fingerprint != initial {
721        return Err("project source changed during presentation inspection".into());
722    }
723    Ok(ProjectSourceInspection {
724        fingerprint: initial,
725        timescale: info.sample_rate(),
726        channels: u16::try_from(info.channels())
727            .map_err(|_| "project source channel count does not fit u16".to_string())?,
728        presentation_frames: frames,
729        format: format!("{:?}", info.format).to_ascii_lowercase(),
730        codec: format!("{:?}", info.codec).to_ascii_lowercase(),
731    })
732}
733
734/// Create an exact portable reference to an existing regular file below root.
735pub fn project_artifact_reference(
736    id: impl Into<String>,
737    path: impl AsRef<Path>,
738    root: impl AsRef<Path>,
739) -> Result<ProjectArtifactReference, String> {
740    let root = canonical_project_root(root.as_ref())?;
741    let path = canonical_contained_path(&root, path.as_ref(), "project artifact")?;
742    let locator = crate::portable_locator(&path, &root)?;
743    ProjectArtifactReference::new(id, locator, batch_resume::fingerprint_file(&path)?)
744}
745
746/// Atomically write a manifest. Existing output is retained unless replace is
747/// explicitly selected by the caller.
748pub fn write_project_manifest(
749    path: impl AsRef<Path>,
750    manifest: &ProjectManifest,
751    mode: CommitMode,
752    pretty: bool,
753) -> Result<(), String> {
754    let mut bytes = if pretty {
755        manifest.to_pretty_json()?.into_bytes()
756    } else {
757        manifest.to_json()?.into_bytes()
758    };
759    bytes.push(b'\n');
760    let mut output = AtomicOutput::new(path.as_ref())?;
761    output.file_mut().write_all(&bytes).map_err(|error| {
762        format!(
763            "write staged project manifest {}: {error}",
764            path.as_ref().display()
765        )
766    })?;
767    output.commit(mode)
768}
769
770/// Verify every referenced source/document/model without changing the project.
771pub fn validate_project_files(
772    manifest: &ProjectManifest,
773    root: impl AsRef<Path>,
774    decode_limits: DecodeLimits,
775) -> Result<ProjectValidationReport, String> {
776    manifest.validate()?;
777    let root = canonical_project_root(root.as_ref())?;
778    for source in &manifest.sources {
779        let path = resolve_project_locator(&root, &source.locator, "project source")?;
780        if batch_resume::fingerprint_file(&path)? != source.fingerprint {
781            return Err(format!(
782                "project source {} differs from its manifest",
783                source.id
784            ));
785        }
786        let observed = inspect_project_source(&path, decode_limits)?;
787        if observed.fingerprint != source.fingerprint
788            || observed.timescale != source.timescale
789            || observed.channels != source.channels
790            || observed.presentation_frames != source.presentation_frames
791        {
792            return Err(format!(
793                "project source {} differs from its manifest",
794                source.id
795            ));
796        }
797        if let Some(license) = &source.license {
798            verify_artifact_reference(&root, license, "project source license")?;
799        }
800    }
801    for reference in &manifest.settings {
802        let path = verify_artifact_reference(&root, reference, "project setting")?;
803        let bytes = read_bounded_regular(&path, "project setting", MAX_PROJECT_JSON_BYTES)?;
804        let text = std::str::from_utf8(&bytes)
805            .map_err(|_| format!("project setting is not UTF-8: {}", path.display()))?;
806        text.parse::<toml::Value>()
807            .map_err(|error| format!("parse project setting {}: {error}", path.display()))?;
808    }
809    for reference in &manifest.presets {
810        let path = verify_artifact_reference(&root, reference, "project preset")?;
811        crate::read_daw_preset(&path)?;
812    }
813    for reference in &manifest.plans {
814        let path = verify_artifact_reference(&root, reference, "project plan")?;
815        if let Err(execution_error) = ExecutionPlan::from_file(&path) {
816            ProjectExecutionPlan::from_file(&path).map_err(|project_error| {
817                format!(
818                    "project plan {} is neither a finite execution plan ({execution_error}) nor a project timeline plan ({project_error})",
819                    path.display()
820                )
821            })?;
822        }
823    }
824    for reference in &manifest.receipts {
825        let path = verify_artifact_reference(&root, reference, "project receipt")?;
826        if let Err(execution_error) = SignedExecutionReceipt::from_file(&path) {
827            SignedProjectExecutionReceipt::from_file(&path).map_err(|project_error| {
828                format!(
829                    "project receipt {} is neither a finite execution receipt ({execution_error}) nor a project timeline receipt ({project_error})",
830                    path.display()
831                )
832            })?;
833        }
834    }
835    for model in &manifest.models {
836        let package_path =
837            verify_artifact_reference(&root, &model.package, "project model package")?;
838        let public_key_path =
839            verify_artifact_reference(&root, &model.public_key, "project model public key")?;
840        let package = RuntimeModelPackage::open(&package_path, &public_key_path)?;
841        let info = package.info();
842        if info.package_id != model.package_id
843            || info.package_revision != model.package_revision
844            || info.signing_key_id != model.signing_key_id
845            || info.license_spdx != model.license_spdx
846        {
847            return Err(format!(
848                "project model {} contract differs from its manifest",
849                model.id
850            ));
851        }
852        let mut license = package.open_license_reader()?;
853        std::io::copy(&mut license, &mut std::io::sink())
854            .map_err(|error| format!("verify project model {} license: {error}", model.id))?;
855    }
856    Ok(ProjectValidationReport {
857        schema: PROJECT_VALIDATION_SCHEMA.into(),
858        schema_version: PROJECT_MANIFEST_SCHEMA_VERSION,
859        project_id: manifest.project_id.clone(),
860        manifest_digest: manifest.digest()?,
861        sources_verified: manifest.sources.len() as u64,
862        settings_verified: manifest.settings.len() as u64,
863        presets_verified: manifest.presets.len() as u64,
864        models_verified: manifest.models.len() as u64,
865        plans_verified: manifest.plans.len() as u64,
866        receipts_verified: manifest.receipts.len() as u64,
867        timelines_verified: manifest.timelines.len() as u64,
868    })
869}
870
871/// Replace one missing source locator only after the candidate's complete
872/// fingerprint and presentation geometry exactly match the manifest.
873pub fn relocate_project_source(
874    manifest: &ProjectManifest,
875    source_id: &str,
876    candidate: impl AsRef<Path>,
877    root: impl AsRef<Path>,
878    limits: DecodeLimits,
879) -> Result<ProjectManifest, String> {
880    manifest.validate()?;
881    let root = canonical_project_root(root.as_ref())?;
882    let candidate =
883        canonical_contained_path(&root, candidate.as_ref(), "relocated project source")?;
884    let observed = inspect_project_source(&candidate, limits)?;
885    let mut relocated = manifest.clone();
886    let index = relocated
887        .sources
888        .binary_search_by(|source| source.id.as_str().cmp(source_id))
889        .map_err(|_| format!("project has no source named {source_id}"))?;
890    let expected = &relocated.sources[index];
891    if observed.fingerprint != expected.fingerprint
892        || observed.timescale != expected.timescale
893        || observed.channels != expected.channels
894        || observed.presentation_frames != expected.presentation_frames
895    {
896        return Err(format!(
897            "relocated source does not exactly match project source {source_id}"
898        ));
899    }
900    relocated.sources[index].locator = crate::portable_locator(&candidate, &root)?;
901    relocated.validate()?;
902    Ok(relocated)
903}
904
905/// Assemble one timeline to a verified, atomically published float WAV.
906///
907/// The implementation retains at most one decoder block plus the adjacent
908/// crossfade tails. It never stores complete decoded source or timeline PCM.
909pub fn assemble_project_timeline(
910    manifest: &ProjectManifest,
911    timeline_id: &str,
912    root: impl AsRef<Path>,
913    output: impl AsRef<Path>,
914    mode: CommitMode,
915    limits: DecodeLimits,
916) -> Result<ProjectRenderReport, String> {
917    manifest.validate()?;
918    let root = canonical_project_root(root.as_ref())?;
919    reject_project_output_collision(manifest, &root, output.as_ref())?;
920    validate_project_files(manifest, &root, limits)?;
921    let timeline = manifest.timeline(timeline_id)?;
922    let total_frames = timeline.presentation_frames()?;
923    let channel_mask = ChannelLayout::from_channel_count(usize::from(timeline.channels)).mask();
924    let spec = StreamEncodeSpec::new(
925        WavSpec {
926            channels: timeline.channels,
927            sample_rate: timeline.timescale,
928            bits_per_sample: 32,
929            sample_format: SampleFormat::Float,
930        },
931        channel_mask,
932        Some(total_frames),
933    );
934    let source_map = manifest
935        .sources
936        .iter()
937        .map(|source| (source.id.as_str(), source))
938        .collect::<BTreeMap<_, _>>();
939    let mut transaction = AtomicOutput::new(output.as_ref())?;
940    let staged_path = transaction.staged_path().to_path_buf();
941    let mut retained_upper_bound = 0_u64;
942    {
943        let mut writer = AudioStreamWriter::new(
944            transaction.file_mut(),
945            OutputFormat::Wav,
946            spec,
947            EncodeOptions::default(),
948        )?;
949        let mut previous_tail: Option<Vec<Vec<f64>>> = None;
950        for (index, selection) in timeline.selections.iter().enumerate() {
951            let source = source_map
952                .get(selection.source_id.as_str())
953                .ok_or_else(|| format!("project source disappeared: {}", selection.source_id))?;
954            let path = resolve_project_locator(&root, &source.locator, "project source")?;
955            let mut reader = SelectionReader::open(source, selection, &path, limits)?;
956            let crossfade_in = usize::try_from(selection.crossfade_from_previous_ticks)
957                .map_err(|_| "project crossfade does not fit this platform".to_string())?;
958            if crossfade_in == 0 {
959                if previous_tail
960                    .take()
961                    .is_some_and(|tail| !tail.iter().all(Vec::is_empty))
962                {
963                    return Err("project assembly retained an unexpected predecessor tail".into());
964                }
965                write_silence(
966                    &mut writer,
967                    timeline.channels,
968                    selection.padding_before_ticks,
969                )?;
970            } else {
971                let previous = previous_tail.take().ok_or_else(|| {
972                    "project crossfade is missing its predecessor tail".to_string()
973                })?;
974                let current = reader.read_exact(crossfade_in)?;
975                if previous.iter().any(|channel| channel.len() != crossfade_in)
976                    || current.iter().any(|channel| channel.len() != crossfade_in)
977                {
978                    return Err("project crossfade source length changed during assembly".into());
979                }
980                let mixed = mix_crossfade(previous, current)?;
981                writer.write_block(&mixed)?;
982            }
983            let fade_out = timeline
984                .selections
985                .get(index + 1)
986                .map_or(0, |next| next.crossfade_from_previous_ticks);
987            retained_upper_bound = retained_upper_bound.max(
988                fade_out
989                    .checked_mul(u64::from(timeline.channels))
990                    .and_then(|samples| samples.checked_mul(16))
991                    .ok_or_else(|| "project retained-byte count overflows".to_string())?,
992            );
993            previous_tail = Some(stream_selection_body(
994                &mut reader,
995                &mut writer,
996                usize::try_from(fade_out)
997                    .map_err(|_| "project crossfade does not fit this platform".to_string())?,
998            )?);
999            reader.verify_unchanged()?;
1000            if fade_out == 0 {
1001                let tail = previous_tail.take().unwrap_or_default();
1002                if tail.iter().any(|channel| !channel.is_empty()) {
1003                    writer.write_block(&tail)?;
1004                }
1005                write_silence(
1006                    &mut writer,
1007                    timeline.channels,
1008                    selection.padding_after_ticks,
1009                )?;
1010            }
1011        }
1012        if previous_tail.is_some_and(|tail| tail.iter().any(|channel| !channel.is_empty())) {
1013            return Err("project assembly ended with an unconsumed crossfade tail".into());
1014        }
1015        writer.finalize()?;
1016    }
1017    crate::audio::write_wav_channel_mask_to_file(
1018        transaction.file_mut(),
1019        usize::from(timeline.channels),
1020        channel_mask,
1021    )?;
1022    crate::verify_stream_output_file(
1023        transaction.file_mut(),
1024        &staged_path,
1025        OutputFormat::Wav,
1026        spec,
1027        total_frames,
1028        EncodeOptions::default(),
1029        limits,
1030        PROJECT_STREAM_BLOCK_FRAMES,
1031    )?;
1032    let fingerprint = batch_resume::fingerprint_open_file_at(transaction.file_mut(), &staged_path)?;
1033    transaction.commit(mode)?;
1034    Ok(ProjectRenderReport {
1035        schema: PROJECT_RENDER_SCHEMA.into(),
1036        schema_version: PROJECT_MANIFEST_SCHEMA_VERSION,
1037        project_id: manifest.project_id.clone(),
1038        manifest_digest: manifest.digest()?,
1039        timeline_id: timeline.id.clone(),
1040        timeline_digest: timeline.digest()?,
1041        output: fingerprint,
1042        timescale: timeline.timescale,
1043        channels: timeline.channels,
1044        presentation_frames: total_frames,
1045        retained_pcm_upper_bound_bytes: retained_upper_bound
1046            .checked_add(
1047                (PROJECT_STREAM_BLOCK_FRAMES as u64)
1048                    .checked_mul(u64::from(timeline.channels))
1049                    .and_then(|samples| samples.checked_mul(8))
1050                    .ok_or_else(|| "project block retained-byte count overflows".to_string())?,
1051            )
1052            .ok_or_else(|| "project retained-byte bound overflows".to_string())?,
1053    })
1054}
1055
1056struct SelectionReader {
1057    reader: AudioStreamReader,
1058    source_fingerprint: FileFingerprint,
1059    channel_map: Vec<u16>,
1060    pending: Option<Vec<Vec<f64>>>,
1061    pending_offset: usize,
1062    remaining: u64,
1063}
1064
1065impl SelectionReader {
1066    fn open(
1067        source: &ProjectSource,
1068        selection: &ProjectSelection,
1069        path: &Path,
1070        limits: DecodeLimits,
1071    ) -> Result<Self, String> {
1072        let session = AudioInputSession::open(path)?;
1073        let mut reader = AudioStreamReader::from_session(session, limits)?;
1074        let info = reader.info();
1075        let fingerprint = reader.fingerprint_input()?;
1076        if fingerprint != source.fingerprint
1077            || info.sample_rate() != source.timescale
1078            || info.channels() != usize::from(source.channels)
1079        {
1080            return Err(format!(
1081                "project source {} changed before assembly",
1082                source.id
1083            ));
1084        }
1085        let mut pending = None;
1086        let mut pending_offset = 0usize;
1087        let mut skip = selection.region.start_tick;
1088        while skip > 0 {
1089            let block = reader
1090                .next_block(PROJECT_STREAM_BLOCK_FRAMES)?
1091                .ok_or_else(|| {
1092                    format!(
1093                        "project source {} ended before selection {}",
1094                        source.id, selection.id
1095                    )
1096                })?;
1097            let frames = block.first().map_or(0, Vec::len);
1098            if frames == 0 {
1099                return Err("project source decoder produced an empty block".into());
1100            }
1101            if frames as u64 <= skip {
1102                skip -= frames as u64;
1103            } else {
1104                pending_offset = usize::try_from(skip)
1105                    .map_err(|_| "project selection skip does not fit this platform".to_string())?;
1106                pending = Some(block);
1107                skip = 0;
1108            }
1109        }
1110        Ok(Self {
1111            reader,
1112            source_fingerprint: source.fingerprint,
1113            channel_map: selection.channel_map.clone(),
1114            pending,
1115            pending_offset,
1116            remaining: selection.region.duration_ticks,
1117        })
1118    }
1119
1120    fn next_block(&mut self, max_frames: usize) -> Result<Option<Vec<Vec<f64>>>, String> {
1121        if self.remaining == 0 {
1122            return Ok(None);
1123        }
1124        let request = usize::try_from(self.remaining.min(max_frames as u64))
1125            .map_err(|_| "project selection block does not fit this platform".to_string())?;
1126        let mut output = self
1127            .channel_map
1128            .iter()
1129            .map(|_| Vec::with_capacity(request))
1130            .collect::<Vec<_>>();
1131        while output.first().map_or(0, Vec::len) < request {
1132            if self.pending.is_none() {
1133                self.pending = self.reader.next_block(PROJECT_STREAM_BLOCK_FRAMES)?;
1134                self.pending_offset = 0;
1135            }
1136            let block = self.pending.as_ref().ok_or_else(|| {
1137                "project source ended before the selected presentation region".to_string()
1138            })?;
1139            let block_frames = block.first().map_or(0, Vec::len);
1140            if block_frames == 0 || self.pending_offset >= block_frames {
1141                return Err("project source decoder produced an invalid block".into());
1142            }
1143            let take = (request - output[0].len()).min(block_frames - self.pending_offset);
1144            for (destination, source_channel) in output.iter_mut().zip(&self.channel_map) {
1145                let source = &block[usize::from(*source_channel)];
1146                destination
1147                    .extend_from_slice(&source[self.pending_offset..self.pending_offset + take]);
1148            }
1149            self.pending_offset += take;
1150            if self.pending_offset == block_frames {
1151                self.pending = None;
1152                self.pending_offset = 0;
1153            }
1154        }
1155        self.remaining -= request as u64;
1156        Ok(Some(output))
1157    }
1158
1159    fn read_exact(&mut self, frames: usize) -> Result<Vec<Vec<f64>>, String> {
1160        let mut output = self
1161            .channel_map
1162            .iter()
1163            .map(|_| Vec::with_capacity(frames))
1164            .collect::<Vec<_>>();
1165        while output.first().map_or(0, Vec::len) < frames {
1166            let received = output.first().map_or(0, Vec::len);
1167            let block = self
1168                .next_block(frames - received)?
1169                .ok_or_else(|| "project source ended during crossfade".to_string())?;
1170            for (destination, source) in output.iter_mut().zip(block) {
1171                destination.extend(source);
1172            }
1173        }
1174        Ok(output)
1175    }
1176
1177    fn verify_unchanged(&self) -> Result<(), String> {
1178        if self.reader.fingerprint_input()? != self.source_fingerprint {
1179            return Err("project source changed during timeline assembly".into());
1180        }
1181        Ok(())
1182    }
1183}
1184
1185fn stream_selection_body<W: std::io::Write + std::io::Seek>(
1186    reader: &mut SelectionReader,
1187    writer: &mut AudioStreamWriter<'_, W>,
1188    retain_tail: usize,
1189) -> Result<Vec<Vec<f64>>, String> {
1190    if retain_tail == 0 {
1191        while let Some(block) = reader.next_block(PROJECT_STREAM_BLOCK_FRAMES)? {
1192            writer.write_block(&block)?;
1193        }
1194        return Ok(vec![Vec::new(); reader.channel_map.len()]);
1195    }
1196    let mut pending = reader
1197        .channel_map
1198        .iter()
1199        .map(|_| VecDeque::with_capacity(retain_tail + PROJECT_STREAM_BLOCK_FRAMES))
1200        .collect::<Vec<_>>();
1201    while let Some(block) = reader.next_block(PROJECT_STREAM_BLOCK_FRAMES)? {
1202        for (queue, channel) in pending.iter_mut().zip(block) {
1203            queue.extend(channel);
1204        }
1205        let available = pending.first().map_or(0, VecDeque::len);
1206        if available > retain_tail {
1207            let emit = available - retain_tail;
1208            let mut output = pending
1209                .iter()
1210                .map(|_| Vec::with_capacity(emit))
1211                .collect::<Vec<_>>();
1212            for (destination, queue) in output.iter_mut().zip(&mut pending) {
1213                destination.extend(queue.drain(..emit));
1214            }
1215            writer.write_block(&output)?;
1216        }
1217    }
1218    if pending.iter().any(|queue| queue.len() != retain_tail) {
1219        return Err("project source region is shorter than its outgoing crossfade".into());
1220    }
1221    Ok(pending
1222        .into_iter()
1223        .map(|queue| queue.into_iter().collect::<Vec<_>>())
1224        .collect())
1225}
1226
1227fn mix_crossfade(previous: Vec<Vec<f64>>, current: Vec<Vec<f64>>) -> Result<Vec<Vec<f64>>, String> {
1228    if previous.len() != current.len() || previous.is_empty() {
1229        return Err("project crossfade channel geometry differs".into());
1230    }
1231    let frames = previous[0].len();
1232    if frames == 0
1233        || previous.iter().any(|channel| channel.len() != frames)
1234        || current.iter().any(|channel| channel.len() != frames)
1235    {
1236        return Err("project crossfade frame geometry differs".into());
1237    }
1238    let denominator = (frames + 1) as f64;
1239    let mut output = Vec::with_capacity(previous.len());
1240    for (left, right) in previous.into_iter().zip(current) {
1241        let mut channel = Vec::with_capacity(frames);
1242        for (index, (left, right)) in left.into_iter().zip(right).enumerate() {
1243            let weight = (index + 1) as f64 / denominator;
1244            channel.push(crate::sanitize_sample(
1245                left * (1.0 - weight) + right * weight,
1246            ));
1247        }
1248        output.push(channel);
1249    }
1250    Ok(output)
1251}
1252
1253fn write_silence<W: std::io::Write + std::io::Seek>(
1254    writer: &mut AudioStreamWriter<'_, W>,
1255    channels: u16,
1256    mut frames: u64,
1257) -> Result<(), String> {
1258    while frames > 0 {
1259        let count = usize::try_from(frames.min(PROJECT_STREAM_BLOCK_FRAMES as u64))
1260            .map_err(|_| "project padding does not fit this platform".to_string())?;
1261        writer.write_block(&vec![vec![0.0; count]; usize::from(channels)])?;
1262        frames -= count as u64;
1263    }
1264    Ok(())
1265}
1266
1267fn reject_project_output_collision(
1268    manifest: &ProjectManifest,
1269    root: &Path,
1270    output: &Path,
1271) -> Result<(), String> {
1272    let destination = normalized_project_output(output)?;
1273    let existing_target = std::fs::canonicalize(&destination).ok();
1274    let mut locators = Vec::new();
1275    for source in &manifest.sources {
1276        locators.push(source.locator.as_str());
1277        if let Some(license) = &source.license {
1278            locators.push(license.locator.as_str());
1279        }
1280    }
1281    for reference in manifest
1282        .settings
1283        .iter()
1284        .chain(&manifest.presets)
1285        .chain(&manifest.plans)
1286        .chain(&manifest.receipts)
1287    {
1288        locators.push(reference.locator.as_str());
1289    }
1290    for model in &manifest.models {
1291        locators.push(model.package.locator.as_str());
1292        locators.push(model.public_key.locator.as_str());
1293    }
1294    for locator in locators {
1295        let artifact = resolve_project_locator(root, locator, "project artifact")?;
1296        if artifact == destination || existing_target.as_ref() == Some(&artifact) {
1297            return Err(format!(
1298                "project output collides with referenced project artifact {locator}"
1299            ));
1300        }
1301    }
1302    Ok(())
1303}
1304
1305fn normalized_project_output(output: &Path) -> Result<PathBuf, String> {
1306    let requested = if output.is_absolute() {
1307        output.to_path_buf()
1308    } else {
1309        std::env::current_dir()
1310            .map_err(|error| format!("resolve project output current directory: {error}"))?
1311            .join(output)
1312    };
1313    let name = requested
1314        .file_name()
1315        .filter(|name| !name.is_empty())
1316        .ok_or("project output must name a file")?;
1317    let parent = requested
1318        .parent()
1319        .filter(|path| !path.as_os_str().is_empty())
1320        .unwrap_or_else(|| Path::new("."));
1321    let parent = std::fs::canonicalize(parent).map_err(|error| {
1322        format!(
1323            "resolve project output parent {}: {error}",
1324            parent.display()
1325        )
1326    })?;
1327    Ok(parent.join(name))
1328}
1329
1330fn verify_artifact_reference(
1331    root: &Path,
1332    reference: &ProjectArtifactReference,
1333    context: &str,
1334) -> Result<PathBuf, String> {
1335    let path = resolve_project_locator(root, &reference.locator, context)?;
1336    let observed = batch_resume::fingerprint_file(&path)?;
1337    if observed != reference.fingerprint {
1338        return Err(format!(
1339            "{context} {} differs from its project fingerprint",
1340            reference.id
1341        ));
1342    }
1343    Ok(path)
1344}
1345
1346fn validate_artifact_reference(
1347    context: &str,
1348    reference: &ProjectArtifactReference,
1349) -> Result<(), String> {
1350    validate_identifier(&format!("{context} ID"), &reference.id)?;
1351    validate_locator(&reference.locator)?;
1352    validate_fingerprint(context, reference.fingerprint)
1353}
1354
1355fn validate_sorted_unique<T, F>(values: &[T], key: F, context: &str) -> Result<(), String>
1356where
1357    F: for<'a> Fn(&'a T) -> &'a str,
1358{
1359    let mut previous: Option<&str> = None;
1360    for value in values {
1361        let current = key(value);
1362        if previous.is_some_and(|previous| previous >= current) {
1363            return Err(format!("{context} must be unique and sorted by ID"));
1364        }
1365        previous = Some(current);
1366    }
1367    Ok(())
1368}
1369
1370fn record_locator<'a>(
1371    locators: &mut BTreeMap<&'a str, FileFingerprint>,
1372    locator: &'a str,
1373    fingerprint: FileFingerprint,
1374    context: &str,
1375) -> Result<(), String> {
1376    if let Some(existing) = locators.insert(locator, fingerprint) {
1377        if existing != fingerprint {
1378            return Err(format!(
1379                "{context} reuses locator {locator} with a different fingerprint"
1380            ));
1381        }
1382    }
1383    Ok(())
1384}
1385
1386fn validate_identifier(label: &str, value: &str) -> Result<(), String> {
1387    if value.is_empty()
1388        || value.len() > MAX_IDENTIFIER_BYTES
1389        || value == "."
1390        || value == ".."
1391        || value
1392            .bytes()
1393            .any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')))
1394    {
1395        return Err(format!(
1396            "{label} must contain 1..={MAX_IDENTIFIER_BYTES} ASCII letters, digits, '.', '_', or '-'"
1397        ));
1398    }
1399    Ok(())
1400}
1401
1402fn validate_text(label: &str, value: &str) -> Result<(), String> {
1403    if value.is_empty() || value.len() > MAX_TEXT_BYTES || value.chars().any(char::is_control) {
1404        return Err(format!(
1405            "{label} must contain 1..={MAX_TEXT_BYTES} printable bytes"
1406        ));
1407    }
1408    Ok(())
1409}
1410
1411fn validate_locator(locator: &str) -> Result<(), String> {
1412    if locator.is_empty() || locator.len() > MAX_LOCATOR_BYTES {
1413        return Err(format!(
1414            "project locator length must be in 1..={MAX_LOCATOR_BYTES} bytes"
1415        ));
1416    }
1417    if locator.starts_with('/')
1418        || locator.ends_with('/')
1419        || locator.contains('\\')
1420        || locator.contains(':')
1421        || locator.chars().any(char::is_control)
1422    {
1423        return Err("project locator must be a portable relative path".into());
1424    }
1425    if locator
1426        .split('/')
1427        .any(|part| part.is_empty() || part == "." || part == ".." || part.len() > 255)
1428    {
1429        return Err("project locator contains an unsafe path component".into());
1430    }
1431    Ok(())
1432}
1433
1434fn validate_fingerprint(label: &str, fingerprint: FileFingerprint) -> Result<(), String> {
1435    if fingerprint.len == 0 || fingerprint.len > MAX_JSON_SAFE_INTEGER {
1436        return Err(format!("{label} length is outside JSON-safe bounds"));
1437    }
1438    Ok(())
1439}
1440
1441fn canonical_project_root(root: &Path) -> Result<PathBuf, String> {
1442    let root = std::fs::canonicalize(root)
1443        .map_err(|error| format!("resolve project root {}: {error}", root.display()))?;
1444    if !root.is_dir() {
1445        return Err(format!(
1446            "project root is not a directory: {}",
1447            root.display()
1448        ));
1449    }
1450    Ok(root)
1451}
1452
1453fn canonical_contained_path(root: &Path, path: &Path, context: &str) -> Result<PathBuf, String> {
1454    let path = std::fs::canonicalize(path)
1455        .map_err(|error| format!("resolve {context} {}: {error}", path.display()))?;
1456    if !path.starts_with(root) {
1457        return Err(format!(
1458            "{context} is outside project root {}",
1459            root.display()
1460        ));
1461    }
1462    Ok(path)
1463}
1464
1465fn resolve_project_locator(root: &Path, locator: &str, context: &str) -> Result<PathBuf, String> {
1466    validate_locator(locator)?;
1467    let mut path = root.to_path_buf();
1468    for component in locator.split('/') {
1469        path.push(component);
1470    }
1471    canonical_contained_path(root, &path, context)
1472}
1473
1474fn read_bounded_regular(path: &Path, context: &str, max_bytes: u64) -> Result<Vec<u8>, String> {
1475    let (mut file, length) = crate::input::open_regular_file(path, context)?;
1476    if length == 0 || length > max_bytes {
1477        return Err(format!("{context} length must be in 1..={max_bytes} bytes"));
1478    }
1479    let length = usize::try_from(length)
1480        .map_err(|_| format!("{context} length does not fit this platform"))?;
1481    let mut bytes = Vec::new();
1482    bytes
1483        .try_reserve_exact(length)
1484        .map_err(|error| format!("reserve {context}: {error}"))?;
1485    file.read_to_end(&mut bytes)
1486        .map_err(|error| format!("read {context} {}: {error}", path.display()))?;
1487    if bytes.len() != length {
1488        return Err(format!(
1489            "{context} changed while it was read: {}",
1490            path.display()
1491        ));
1492    }
1493    Ok(bytes)
1494}
1495
1496fn domain_digest(domain: &[u8], bytes: &[u8]) -> Digest {
1497    let mut digest = Sha256::new();
1498    digest.update((domain.len() as u64).to_le_bytes());
1499    digest.update(domain);
1500    digest.update((bytes.len() as u64).to_le_bytes());
1501    digest.update(bytes);
1502    Digest::from_bytes(digest.finalize().into())
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507    use super::*;
1508
1509    fn write_wav(path: &Path, samples: &[f32]) {
1510        let mut writer = hound::WavWriter::create(
1511            path,
1512            WavSpec {
1513                channels: 1,
1514                sample_rate: 8_000,
1515                bits_per_sample: 32,
1516                sample_format: SampleFormat::Float,
1517            },
1518        )
1519        .unwrap();
1520        for sample in samples {
1521            writer.write_sample(*sample).unwrap();
1522        }
1523        writer.finalize().unwrap();
1524    }
1525
1526    fn fixture() -> (tempfile::TempDir, ProjectManifest) {
1527        let directory = tempfile::tempdir().unwrap();
1528        let first_path = directory.path().join("first.wav");
1529        let second_path = directory.path().join("second.wav");
1530        write_wav(&first_path, &[0.1, 0.2, 0.3, 0.4, 0.5, 0.6]);
1531        write_wav(&second_path, &[-0.1, -0.2, -0.3, -0.4, -0.5, -0.6]);
1532        let first = inspect_project_source(&first_path, DecodeLimits::default()).unwrap();
1533        let second = inspect_project_source(&second_path, DecodeLimits::default()).unwrap();
1534        let sources = vec![
1535            ProjectSource {
1536                id: "first".into(),
1537                locator: "first.wav".into(),
1538                fingerprint: first.fingerprint,
1539                timescale: first.timescale,
1540                channels: first.channels,
1541                presentation_frames: first.presentation_frames,
1542                license: None,
1543            },
1544            ProjectSource {
1545                id: "second".into(),
1546                locator: "second.wav".into(),
1547                fingerprint: second.fingerprint,
1548                timescale: second.timescale,
1549                channels: second.channels,
1550                presentation_frames: second.presentation_frames,
1551                license: None,
1552            },
1553        ];
1554        let timeline = ProjectTimeline {
1555            id: "main".into(),
1556            timescale: 8_000,
1557            channels: 1,
1558            selections: vec![
1559                ProjectSelection {
1560                    id: "a".into(),
1561                    source_id: "first".into(),
1562                    region: PresentationRegion::new(first.fingerprint, 8_000, 1, 4).unwrap(),
1563                    channel_map: vec![0],
1564                    padding_before_ticks: 2,
1565                    padding_after_ticks: 0,
1566                    crossfade_from_previous_ticks: 0,
1567                },
1568                ProjectSelection {
1569                    id: "b".into(),
1570                    source_id: "second".into(),
1571                    region: PresentationRegion::new(second.fingerprint, 8_000, 0, 4).unwrap(),
1572                    channel_map: vec![0],
1573                    padding_before_ticks: 0,
1574                    padding_after_ticks: 1,
1575                    crossfade_from_previous_ticks: 2,
1576                },
1577            ],
1578        };
1579        let manifest = ProjectManifest::new(
1580            "fixture",
1581            sources,
1582            vec![timeline],
1583            Vec::new(),
1584            Vec::new(),
1585            Vec::new(),
1586            Vec::new(),
1587            Vec::new(),
1588        )
1589        .unwrap();
1590        (directory, manifest)
1591    }
1592
1593    #[test]
1594    fn linear_timeline_assembles_with_bounded_crossfade_and_padding() {
1595        let (directory, manifest) = fixture();
1596        let output = directory.path().join("output.wav");
1597        let report = assemble_project_timeline(
1598            &manifest,
1599            "main",
1600            directory.path(),
1601            &output,
1602            CommitMode::NoClobber,
1603            DecodeLimits::default(),
1604        )
1605        .unwrap();
1606        assert_eq!(report.presentation_frames, 9);
1607        assert!(report.retained_pcm_upper_bound_bytes < MAX_CROSSFADE_BYTES);
1608        let mut reader = hound::WavReader::open(output).unwrap();
1609        let samples = reader
1610            .samples::<f32>()
1611            .map(Result::unwrap)
1612            .collect::<Vec<_>>();
1613        assert_eq!(samples.len(), 9);
1614        assert_eq!(&samples[..2], &[0.0, 0.0]);
1615        assert!((samples[2] - 0.2).abs() < 1e-6);
1616        assert!((samples[3] - 0.3).abs() < 1e-6);
1617        assert!((samples[4] - (0.4 * 2.0 / 3.0 - 0.1 / 3.0)).abs() < 1e-6);
1618        assert!((samples[5] - (0.5 / 3.0 - 0.2 * 2.0 / 3.0)).abs() < 1e-6);
1619        assert!((samples[6] + 0.3).abs() < 1e-6);
1620        assert!((samples[7] + 0.4).abs() < 1e-6);
1621        assert_eq!(samples[8], 0.0);
1622    }
1623
1624    #[test]
1625    fn future_and_unsupported_overlapping_edits_fail_closed() {
1626        let (_directory, manifest) = fixture();
1627        let mut value = serde_json::to_value(&manifest).unwrap();
1628        value["schema_version"] = 2.into();
1629        assert!(serde_json::from_value::<ProjectManifest>(value)
1630            .unwrap()
1631            .validate()
1632            .unwrap_err()
1633            .contains("unsupported"));
1634
1635        let mut unsupported_geometry = manifest.clone();
1636        unsupported_geometry.timelines[0].channels = MAX_PROJECT_CHANNELS + 1;
1637        assert!(unsupported_geometry
1638            .validate()
1639            .unwrap_err()
1640            .contains("geometry is unsupported"));
1641
1642        let mut invalid = manifest;
1643        invalid.timelines[0].selections[1].padding_before_ticks = 1;
1644        assert!(invalid.validate().unwrap_err().contains("padded boundary"));
1645    }
1646
1647    #[test]
1648    fn relocation_requires_exact_bytes_and_preserves_the_original() {
1649        let (directory, manifest) = fixture();
1650        let replacement = directory.path().join("replacement.wav");
1651        write_wav(&replacement, &[0.0; 6]);
1652        let error = relocate_project_source(
1653            &manifest,
1654            "first",
1655            &replacement,
1656            directory.path(),
1657            DecodeLimits::default(),
1658        )
1659        .unwrap_err();
1660        assert!(error.contains("does not exactly match"));
1661        assert_eq!(manifest.sources[0].locator, "first.wav");
1662
1663        let relocated_path = directory.path().join("relocated.wav");
1664        std::fs::copy(directory.path().join("first.wav"), &relocated_path).unwrap();
1665        let relocated = relocate_project_source(
1666            &manifest,
1667            "first",
1668            &relocated_path,
1669            directory.path(),
1670            DecodeLimits::default(),
1671        )
1672        .unwrap();
1673        assert_eq!(relocated.sources[0].locator, "relocated.wav");
1674    }
1675
1676    #[test]
1677    fn assembly_never_replaces_a_referenced_project_artifact() {
1678        let (directory, manifest) = fixture();
1679        let source = directory.path().join("first.wav");
1680        let before = std::fs::read(&source).unwrap();
1681        let error = assemble_project_timeline(
1682            &manifest,
1683            "main",
1684            directory.path(),
1685            &source,
1686            CommitMode::Replace,
1687            DecodeLimits::default(),
1688        )
1689        .unwrap_err();
1690        assert!(error.contains("collides"));
1691        assert_eq!(std::fs::read(source).unwrap(), before);
1692    }
1693
1694    #[test]
1695    fn assembly_preflights_complete_source_geometry_before_staging() {
1696        let (directory, mut manifest) = fixture();
1697        manifest.sources[0].presentation_frames += 1;
1698        let output = directory.path().join("must-not-exist.wav");
1699
1700        let error = assemble_project_timeline(
1701            &manifest,
1702            "main",
1703            directory.path(),
1704            &output,
1705            CommitMode::NoClobber,
1706            DecodeLimits::default(),
1707        )
1708        .unwrap_err();
1709
1710        assert!(error.contains("differs from its manifest"));
1711        assert!(!output.exists());
1712    }
1713}