diff --git a/.gitignore b/.gitignore index 0c1ad61..9bb45b2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ Cargo.lock.bak /v1.otio /v2.otio .claude/ +.worktrees/ diff --git a/crates/vedit-core/src/lib.rs b/crates/vedit-core/src/lib.rs index 5609dd4..45414bb 100644 --- a/crates/vedit-core/src/lib.rs +++ b/crates/vedit-core/src/lib.rs @@ -6,3 +6,4 @@ pub mod model; pub mod object; pub mod otio; pub mod repo; +pub mod review_artifact; diff --git a/crates/vedit-core/src/review_artifact.rs b/crates/vedit-core/src/review_artifact.rs new file mode 100644 index 0000000..b0821bc --- /dev/null +++ b/crates/vedit-core/src/review_artifact.rs @@ -0,0 +1,97 @@ +//! Review artifact metadata. +//! +//! Review artifacts describe generated review outputs and their provenance. +//! The schema is intentionally separate from commit objects: commits identify +//! version-control history, while review artifacts identify downstream review +//! packages, renders, and reasoning trails derived from that history. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewArtifact { + /// Always `"vedit.review_artifact.1"` for this schema version. + pub schema: String, + /// Path or URI for the generated review render. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub render_path: Option, + /// UTC generation timestamp, formatted as RFC 3339 / ISO 8601 text. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generated_at: Option, + /// vedit commit hash that the review output was generated from. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_commit: Option, + /// Timeline object hash that the review output was generated from. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeline: Option, + /// Freeform labels for downstream review package routing. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Human-facing summary/header associated with the reviewed commit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commit_header: Option, + /// Reasoning or explanation body attached to the review package. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_body: Option, +} + +impl ReviewArtifact { + pub const SCHEMA: &'static str = "vedit.review_artifact.1"; + + pub fn new() -> Self { + Self { + schema: Self::SCHEMA.to_string(), + render_path: None, + generated_at: None, + source_commit: None, + timeline: None, + tags: Vec::new(), + commit_header: None, + reasoning_body: None, + } + } + + pub fn with_render_path(mut self, render_path: impl Into) -> Self { + self.render_path = Some(render_path.into()); + self + } + + pub fn with_generated_at(mut self, generated_at: impl Into) -> Self { + self.generated_at = Some(generated_at.into()); + self + } + + pub fn with_source_commit(mut self, source_commit: impl Into) -> Self { + self.source_commit = Some(source_commit.into()); + self + } + + pub fn with_timeline(mut self, timeline: impl Into) -> Self { + self.timeline = Some(timeline.into()); + self + } + + pub fn with_tags(mut self, tags: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.tags = tags.into_iter().map(Into::into).collect(); + self + } + + pub fn with_commit_header(mut self, commit_header: impl Into) -> Self { + self.commit_header = Some(commit_header.into()); + self + } + + pub fn with_reasoning_body(mut self, reasoning_body: impl Into) -> Self { + self.reasoning_body = Some(reasoning_body.into()); + self + } +} + +impl Default for ReviewArtifact { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/vedit-core/tests/review_artifact.rs b/crates/vedit-core/tests/review_artifact.rs new file mode 100644 index 0000000..141fffe --- /dev/null +++ b/crates/vedit-core/tests/review_artifact.rs @@ -0,0 +1,65 @@ +use serde_json::json; +use vedit_core::review_artifact::ReviewArtifact; + +#[test] +fn review_artifact_serializes_versioned_provenance_fields() { + let artifact = ReviewArtifact::new() + .with_render_path("renders/review.mp4") + .with_generated_at("2026-05-21T07:29:30Z") + .with_source_commit( + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .with_timeline("sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + .with_tags(["review", "agent"]) + .with_commit_header("Trim intro, add crossfade") + .with_reasoning_body("The edit removes dead air before the first title card."); + + let value = serde_json::to_value(&artifact).unwrap(); + + assert_eq!( + value, + json!({ + "schema": "vedit.review_artifact.1", + "render_path": "renders/review.mp4", + "generated_at": "2026-05-21T07:29:30Z", + "source_commit": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "timeline": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "tags": ["review", "agent"], + "commit_header": "Trim intro, add crossfade", + "reasoning_body": "The edit removes dead air before the first title card." + }) + ); +} + +#[test] +fn review_artifact_deserializes_legacy_artifacts_with_missing_optional_fields() { + let artifact: ReviewArtifact = serde_json::from_value(json!({ + "schema": "vedit.review_artifact.1", + "source_commit": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + })) + .unwrap(); + + assert_eq!(artifact.schema, ReviewArtifact::SCHEMA); + assert_eq!( + artifact.source_commit.as_deref(), + Some("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + assert_eq!(artifact.render_path, None); + assert_eq!(artifact.generated_at, None); + assert_eq!(artifact.timeline, None); + assert!(artifact.tags.is_empty()); + assert_eq!(artifact.commit_header, None); + assert_eq!(artifact.reasoning_body, None); +} + +#[test] +fn empty_review_artifact_omits_optional_fields() { + let value = serde_json::to_value(ReviewArtifact::new()).unwrap(); + + assert_eq!( + value, + json!({ + "schema": "vedit.review_artifact.1" + }) + ); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5912a48..ba64879 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -110,6 +110,27 @@ Both projects can coexist. They serve different layers. Not a video editor. Not media storage. Not a rendering service. Not a collaboration platform. Not an asset manager. vedit is the version-control layer for one OTIO file. Everything above is somebody else's product. +## Review artifact metadata + +`vedit-core` exposes a versioned review artifact metadata primitive for downstream systems that generate review packages, rendered previews, or signed reasoning trails from vedit history. This is a shared serialization shape, not a CLI-generated object yet. + +The serialized format is canonical JSON-compatible and can be written to the content-addressed object store like any other JSON value. The current schema is `vedit.review_artifact.1`: + +```json +{ + "schema": "vedit.review_artifact.1", + "render_path": "renders/review.mp4", + "generated_at": "2026-05-21T07:29:30Z", + "source_commit": "sha256:abc...", + "timeline": "sha256:def...", + "tags": ["review", "agent"], + "commit_header": "Trim intro, add crossfade", + "reasoning_body": "The edit removes dead air before the first title card." +} +``` + +All fields except `schema` are optional so older or partial artifacts remain readable. `render_path` may be a local path or URI supplied by the package generator. `generated_at` is UTC RFC 3339 / ISO 8601 text. `source_commit` stores the vedit commit hash, while `timeline` stores the timeline object hash, preserving both version-control identity and content identity. + ## License Apache 2.0.