1use 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
46pub const PROJECT_MANIFEST_SCHEMA: &str = "denoize-project-v1";
48pub const PROJECT_MANIFEST_SCHEMA_VERSION: u32 = 1;
50pub const PROJECT_VALIDATION_SCHEMA: &str = "denoize-project-verification-v1";
52pub 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#[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#[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#[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#[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#[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#[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#[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#[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#[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 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 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 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 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
687pub 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
734pub 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
746pub 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
770pub 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
871pub 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
905pub 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}