mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 05:58:19 -06:00
Merge enhance/filmcraft-avi-backend into enhance/avi-import
# Conflicts: # crates/export/tests/it/movie_bytes.rs
This commit is contained in:
@@ -881,6 +881,14 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A footage layer starts with the markers its file carries (layer time is footage time).
|
||||
let footage_markers = match &source {
|
||||
LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) {
|
||||
Some(crate::ItemKind::Footage(f)) => f.markers.clone(),
|
||||
_ => vec![],
|
||||
},
|
||||
_ => vec![],
|
||||
};
|
||||
let has_audio = matches!(&source, LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(crate::ItemKind::Footage(f)) if f.has_audio))
|
||||
|| matches!(source, LayerSource::Comp { .. });
|
||||
if has_audio {
|
||||
@@ -910,7 +918,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
|
||||
preserve_transparency: false,
|
||||
track_matte: None,
|
||||
parent: None,
|
||||
markers: vec![],
|
||||
markers: footage_markers,
|
||||
markers_locked: false,
|
||||
environment: false,
|
||||
environment_background: false,
|
||||
|
||||
+42
-23
@@ -275,15 +275,35 @@ impl Comp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A protected time marker with a duration sets the comp's timing (Responsive Design), so it is
|
||||
/// part of the pixels even where plain markers are not.
|
||||
fn is_timing_marker(m: &Marker) -> bool {
|
||||
m.protected && m.duration > Tick::ZERO
|
||||
}
|
||||
|
||||
fn timing_markers(m: &[Marker]) -> impl Iterator<Item = (Tick, Tick)> + '_ {
|
||||
m.iter().filter(|m| is_timing_marker(m)).map(|m| (m.time, m.duration))
|
||||
}
|
||||
|
||||
impl Comp {
|
||||
/// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide
|
||||
/// Shy Layers and the viewer's Resolution.
|
||||
pub fn same_pixels(&self, o: &Comp) -> bool {
|
||||
self.same_settings(o) && self.layers.len() == o.layers.len() && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b))
|
||||
self.same_pixels_with(o, true)
|
||||
}
|
||||
|
||||
/// [`Self::same_pixels`] but for the layers.
|
||||
fn same_settings(&self, o: &Comp) -> bool {
|
||||
/// [`Self::same_pixels`], with the comp's and its layers' markers counting only when
|
||||
/// `with_markers`: only expressions read them, so while none does, adding or moving a marker
|
||||
/// (even during playback) leaves every rendered frame valid. Protected time markers with a
|
||||
/// duration set the timing (Responsive Design), so those always count.
|
||||
pub fn same_pixels_with(&self, o: &Comp, with_markers: bool) -> bool {
|
||||
self.same_settings(o, with_markers)
|
||||
&& self.layers.len() == o.layers.len()
|
||||
&& self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels_with(b, with_markers))
|
||||
}
|
||||
|
||||
/// [`Self::same_pixels_with`] but for the layers.
|
||||
fn same_settings(&self, o: &Comp, with_markers: bool) -> bool {
|
||||
// Every field (no `..`: a new field must be considered here).
|
||||
let Comp {
|
||||
width,
|
||||
@@ -321,7 +341,7 @@ impl Comp {
|
||||
&& *display_start == o.display_start
|
||||
&& *background == o.background
|
||||
&& *work_area == o.work_area
|
||||
&& *markers == o.markers
|
||||
&& (if with_markers { *markers == o.markers } else { timing_markers(markers).eq(timing_markers(&o.markers)) })
|
||||
&& *shutter_angle == o.shutter_angle
|
||||
&& *shutter_phase == o.shutter_phase
|
||||
&& *motion_blur_samples == o.motion_blur_samples
|
||||
@@ -356,7 +376,7 @@ impl Comp {
|
||||
})
|
||||
}
|
||||
// Solo hides every other layer at every frame.
|
||||
if !self.same_settings(o) || self.any_solo() != o.any_solo() {
|
||||
if !self.same_settings(o, true) || self.any_solo() != o.any_solo() {
|
||||
return None;
|
||||
}
|
||||
let common = |a: &Comp, b: &Comp| a.layers.iter().map(|l| l.id).filter(|id| b.layer(*id).is_some()).collect::<Vec<_>>();
|
||||
@@ -393,34 +413,33 @@ impl Comp {
|
||||
self.layers.iter().any(|l| l.switches.solo && l.source.is_av())
|
||||
}
|
||||
|
||||
/// Draws the same pixels as `o` once plain markers are ignored: they change no pixels. Protected
|
||||
/// time markers with a duration set the timing (Responsive Design), so those still count.
|
||||
pub fn same_pixels_ignoring_markers(&self, o: &Comp) -> bool {
|
||||
let protected =
|
||||
|m: &[Marker]| -> Vec<(Tick, Tick)> { m.iter().filter(|m| m.protected && m.duration > Tick::ZERO).map(|m| (m.time, m.duration)).collect() };
|
||||
if protected(&self.markers) != protected(&o.markers) {
|
||||
return false;
|
||||
}
|
||||
let mut c = self.clone();
|
||||
c.markers = o.markers.clone();
|
||||
for (a, b) in c.layers.iter_mut().zip(&o.layers) {
|
||||
a.markers = b.markers.clone();
|
||||
a.markers_locked = b.markers_locked;
|
||||
}
|
||||
c.same_pixels(o)
|
||||
}
|
||||
|
||||
/// The comp with those switches and the Resolution at their defaults (borrowed when they
|
||||
/// already are), for keys that hash the whole comp.
|
||||
pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> {
|
||||
if !self.hide_shy && self.resolution.is_auto() && self.layers.iter().all(|l| l.switches == l.switches.pixels()) {
|
||||
self.pixel_form_with(true)
|
||||
}
|
||||
|
||||
/// [`Self::pixel_form`], also without the comp's plain markers and its layers' markers when not
|
||||
/// `with_markers` (see [`Self::same_pixels_with`] and [`Project::reads_markers`]).
|
||||
pub fn pixel_form_with(&self, with_markers: bool) -> std::borrow::Cow<'_, Comp> {
|
||||
if !self.hide_shy
|
||||
&& self.resolution.is_auto()
|
||||
&& self.layers.iter().all(|l| l.switches == l.switches.pixels())
|
||||
&& (with_markers || (self.markers.iter().all(is_timing_marker) && self.layers.iter().all(|l| l.markers.is_empty())))
|
||||
{
|
||||
return std::borrow::Cow::Borrowed(self);
|
||||
}
|
||||
let mut c = self.clone();
|
||||
c.hide_shy = false;
|
||||
c.resolution = Resolution::Auto;
|
||||
if !with_markers {
|
||||
c.markers.retain(is_timing_marker);
|
||||
}
|
||||
for l in &mut c.layers {
|
||||
l.switches = l.switches.pixels();
|
||||
if !with_markers {
|
||||
l.markers.clear();
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(c)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::settings::yes;
|
||||
use super::settings::{Marker, yes};
|
||||
use super::{ColorSpace, Deserialize, FrameRate, Serialize, Tick};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -120,6 +120,10 @@ pub struct Footage {
|
||||
/// timecode isn't read). It only relabels source time, as in After Effects.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start_timecode: Option<i64>,
|
||||
/// Markers the file carries (a WAV's cue points and XMP markers), at footage time. A layer
|
||||
/// made from the footage starts with them, and Update Markers From Source copies new ones.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub markers: Vec<Marker>,
|
||||
}
|
||||
|
||||
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
|
||||
@@ -155,6 +159,7 @@ impl Default for Footage {
|
||||
page: 0,
|
||||
alphabetical: false,
|
||||
start_timecode: None,
|
||||
markers: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,21 @@ impl Default for Project {
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Some layer has an expression, which can read markers (`marker.key(1)`, `numKeys`,
|
||||
/// `nearestKey`...). Only then do the markers of comps and layers affect what is rendered, so
|
||||
/// frame caches ignore them otherwise.
|
||||
pub fn reads_markers(&self) -> bool {
|
||||
// Called for every frame identity and disk-cache key: stops at the first expression and
|
||||
// builds no paths (`PropGroup::walk` formats one per node).
|
||||
fn any_expr(g: &crate::PropGroup) -> bool {
|
||||
g.children.iter().any(|c| match c {
|
||||
crate::Node::Prop(p) => p.expr.is_some(),
|
||||
crate::Node::Group(sub) => any_expr(sub),
|
||||
})
|
||||
}
|
||||
self.comps().any(|(_, c)| c.layers.iter().any(|l| any_expr(&l.props)))
|
||||
}
|
||||
|
||||
/// Allocate an id (items, layers and property uids share one counter).
|
||||
pub fn alloc(&mut self) -> u64 {
|
||||
let id = self.next_id;
|
||||
@@ -252,10 +267,11 @@ impl Project {
|
||||
/// (NaN or infinity, written as `null`, and nesting too deep to parse) are refused on every
|
||||
/// save; debug builds also read the text back.
|
||||
pub fn to_file_json(&self) -> Result<String, ProjectError> {
|
||||
self.to_file_json_with_view(&SavedView::default())
|
||||
self.to_file_json_with_view(&SavedView::default(), false)
|
||||
}
|
||||
/// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]).
|
||||
pub fn to_file_json_with_view(&self, view: &SavedView) -> Result<String, ProjectError> {
|
||||
/// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]), on one
|
||||
/// line when `compact` (Settings ▸ Project ▸ Save Compact Project Files).
|
||||
pub fn to_file_json_with_view(&self, view: &SavedView, compact: bool) -> Result<String, ProjectError> {
|
||||
if self.schema > SCHEMA_VERSION {
|
||||
return Err(ProjectError::Invalid(format!(
|
||||
"the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))",
|
||||
@@ -274,7 +290,8 @@ impl Project {
|
||||
let file = File { saved_by: APP_VERSION, view, project: self };
|
||||
file_check::check(&file)
|
||||
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?;
|
||||
let text = serde_json::to_string_pretty(&file).map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?;
|
||||
let text = if compact { serde_json::to_string(&file) } else { serde_json::to_string_pretty(&file) }
|
||||
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?;
|
||||
#[cfg(debug_assertions)]
|
||||
Project::from_json(&text)
|
||||
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?;
|
||||
|
||||
@@ -195,6 +195,12 @@ impl Switches {
|
||||
impl Layer {
|
||||
/// Draws the same pixels as `o`: equal but for the Audio, Lock and Shy switches.
|
||||
pub fn same_pixels(&self, o: &Layer) -> bool {
|
||||
self.same_pixels_with(o, true)
|
||||
}
|
||||
|
||||
/// [`Self::same_pixels`], with the markers counting only when `with_markers` (they are read by
|
||||
/// expressions alone, so a project without any can edit them freely).
|
||||
pub fn same_pixels_with(&self, o: &Layer, with_markers: bool) -> bool {
|
||||
// Every field (no `..`: a new field must be considered here); the property tree last.
|
||||
let Layer {
|
||||
id,
|
||||
@@ -232,7 +238,7 @@ impl Layer {
|
||||
&& *preserve_transparency == o.preserve_transparency
|
||||
&& *track_matte == o.track_matte
|
||||
&& *parent == o.parent
|
||||
&& *markers == o.markers
|
||||
&& (!with_markers || *markers == o.markers)
|
||||
&& *markers_locked == o.markers_locked
|
||||
&& *auto_orient == o.auto_orient
|
||||
&& *environment == o.environment
|
||||
|
||||
@@ -205,7 +205,8 @@ pub enum MaskMotionBlur {
|
||||
Off,
|
||||
}
|
||||
|
||||
/// How a mask's feather ramps: Smooth (Gaussian) or Linear.
|
||||
/// How a mask's feather ramps: Smooth (three box passes, an S-shaped ramp of finite reach, as
|
||||
/// measured in After Effects) or Linear (one box pass: a straight ramp).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum FeatherFalloff {
|
||||
#[default]
|
||||
|
||||
@@ -442,9 +442,18 @@ impl RenderSettings {
|
||||
pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick {
|
||||
self.rate(comp).tick_of(self.first_frame(comp) + i as i64)
|
||||
}
|
||||
pub fn output_size(&self, comp: &Comp) -> (u32, u32) {
|
||||
pub fn effective_scale(&self, comp: &Comp) -> f64 {
|
||||
let s = self.resolution.clamp(0.01, 4.0);
|
||||
(((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1))
|
||||
let max = f64::from(crate::limits::MAX_SIZE);
|
||||
s.min(max / f64::from(comp.width.max(1))).min(max / f64::from(comp.height.max(1)))
|
||||
}
|
||||
pub fn output_size(&self, comp: &Comp) -> (u32, u32) {
|
||||
let s = self.effective_scale(comp);
|
||||
// The scale is clamped so neither side exceeds the comp limit, keeping the aspect
|
||||
// ratio: a 30 000 px side at 4x renders at 1x instead of cropping to a corner.
|
||||
let w = ((comp.width as f64 * s).round() as u32).max(1);
|
||||
let h = ((comp.height as f64 * s).round() as u32).max(1);
|
||||
(w, h)
|
||||
}
|
||||
pub fn resolution_label(&self) -> &'static str {
|
||||
match self.resolution {
|
||||
@@ -779,15 +788,18 @@ impl Crop {
|
||||
if self.use_roi
|
||||
&& let Some([x, y, rw, rh]) = self.roi
|
||||
{
|
||||
// The quad arrives unvalidated (control channel or a hand-edited project):
|
||||
// clamp like the sibling edge fields (plus/minus 20 000) and subtract
|
||||
// without overflow.
|
||||
let conv = |v: f64| v.round().clamp(-20_000.0, 20_000.0) as i32;
|
||||
let l = conv(x * scale);
|
||||
let t = conv(y * scale);
|
||||
let r = (w as i32).saturating_sub(conv((x + rw) * scale));
|
||||
let b = (h as i32).saturating_sub(conv((y + rh) * scale));
|
||||
return (t, l, b, r);
|
||||
// The quad arrives unvalidated (control channel or a hand-edited project). As in
|
||||
// After Effects, the region of interest lies inside the frame: each converted edge
|
||||
// is clamped to [0, w] / [0, h] (inf clamps to a side, NaN converts to 0), and the
|
||||
// far edge never passes the near one. Every edge then lies inside the frame, so the
|
||||
// crop can't be larger than the frame and no subtraction can overflow.
|
||||
let (wi, hi) = (i32::try_from(w).unwrap_or(i32::MAX), i32::try_from(h).unwrap_or(i32::MAX));
|
||||
let conv = |v: f64, side: i32| v.round().clamp(0.0, f64::from(side)) as i32;
|
||||
let l = conv(x * scale, wi);
|
||||
let t = conv(y * scale, hi);
|
||||
let r = conv((x + rw) * scale, wi).max(l);
|
||||
let b = conv((y + rh) * scale, hi).max(t);
|
||||
return (t, l, hi - b, wi - r);
|
||||
}
|
||||
(self.top, self.left, self.bottom, self.right)
|
||||
}
|
||||
@@ -1126,7 +1138,7 @@ impl OutputModule {
|
||||
/// rounding).
|
||||
pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) {
|
||||
let (w, h) = settings.output_size(comp);
|
||||
let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0));
|
||||
let (w, h) = self.frame_size(w, h, settings.effective_scale(comp));
|
||||
self.format.coded_size(w, h)
|
||||
}
|
||||
/// Every setting as `(label, value)` lines (render logs).
|
||||
@@ -1659,8 +1671,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hostile_roi_cannot_overflow_crop_math() {
|
||||
// `-1e12` saturates to `i32::MIN` on conversion: `w as i32 - l` must saturate,
|
||||
// not overflow, and still yield a usable (degenerate) frame size.
|
||||
// `-1e12` is clamped into the frame on conversion: it must not overflow and must still
|
||||
// yield a usable (degenerate) frame size.
|
||||
let c = Comp::new(1920, 1080, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
|
||||
let rs = RenderSettings::default();
|
||||
let mut m = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
@@ -1673,6 +1685,66 @@ mod tests {
|
||||
assert_eq!(m.output_size(&c, &half), (400, 200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_size_never_exceeds_the_comp_limit() {
|
||||
// The effective scale is clamped so neither side exceeds the limit (exporter
|
||||
// `w * h * 4` would overflow at 120 000 px sides); the pipeline renders at the
|
||||
// same scale, so it never requests a frame larger than this reports.
|
||||
let rs = RenderSettings { resolution: 4.0, ..Default::default() };
|
||||
let m = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let square = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
|
||||
assert_eq!(rs.effective_scale(&square), 1.0);
|
||||
assert_eq!(rs.output_size(&square), (30_000, 30_000));
|
||||
assert_eq!(m.output_size(&square, &rs), (30_000, 30_000));
|
||||
// Wide comps keep their aspect ratio instead of clamping each side on its own.
|
||||
let wide = Comp::new(30_000, 15_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
|
||||
assert_eq!(rs.effective_scale(&wide), 1.0);
|
||||
assert_eq!(rs.output_size(&wide), (30_000, 15_000));
|
||||
assert_eq!(m.output_size(&wide, &rs), (30_000, 15_000));
|
||||
// A mid-size comp renders at the largest scale that still fits.
|
||||
let mid = Comp::new(20_000, 10_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
|
||||
assert_eq!(mid.width as f64 * rs.effective_scale(&mid), 30_000.0);
|
||||
assert_eq!(rs.output_size(&mid), (30_000, 15_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_wide_roi_endpoints_are_preserved() {
|
||||
// A supported 30 000 x 100 frame at scale 1: ROI [10000, 0, 20000, 100] spans x 10 000..30 000
|
||||
// and must give 20 000 x 100 (the absolute right edge 30 000 exceeds the old +/-20 000 clamp).
|
||||
let c = Comp::new(30_000, 100, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
|
||||
let rs = RenderSettings::default();
|
||||
let mut m = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
m.crop = Crop { enabled: true, use_roi: true, roi: Some([10_000.0, 0.0, 20_000.0, 100.0]), ..Default::default() };
|
||||
assert_eq!(m.output_size(&c, &rs), (20_000, 100));
|
||||
assert_eq!(m.crop.edges(30_000, 100, 1.0), (0, 10_000, 0, 0));
|
||||
// Scale 4.0 pushes a valid edge to 120 000 px (30 000 x 4); the left edge 40 000 and the
|
||||
// right edge 120 000 are both beyond 20 000 and must survive.
|
||||
assert_eq!(m.crop.edges(120_000, 400, 4.0), (0, 40_000, 0, 0));
|
||||
assert_eq!(m.frame_size(120_000, 400, 4.0), (80_000, 400));
|
||||
// Hostile quads stay inside the frame: never empty, never larger than the frame, so the
|
||||
// export can't be asked for an unbounded crop (an ROI reaching far outside the comp
|
||||
// used to pad the frame instead).
|
||||
let (fw, fh) = (1920, 1080);
|
||||
let hostile = [
|
||||
[1e300, 1e300, 1e300, 1e300],
|
||||
[-1e300, -1e300, 1e300, 1e300],
|
||||
[-1e6, -1e6, 2e6 + 1920.0, 2e6 + 1080.0],
|
||||
[-1e12, -1e12, -1e12, -1e12],
|
||||
[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::NAN],
|
||||
[f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY],
|
||||
];
|
||||
for roi in hostile {
|
||||
m.crop.roi = Some(roi);
|
||||
let (t, l, b, r) = m.crop.edges(fw, fh, 1.0);
|
||||
assert!([t, l, b, r].iter().all(|e| *e >= 0), "{roi:?}: an edge pads the frame: {:?}", (t, l, b, r));
|
||||
let (w, h) = m.frame_size(fw, fh, 1.0);
|
||||
assert!((1..=fw).contains(&w) && (1..=fh).contains(&h), "{roi:?}: {w}x{h} is outside 1x1..={fw}x{fh}");
|
||||
}
|
||||
// An ROI covering more than the frame is the whole frame.
|
||||
m.crop.roi = Some([-1e6, -1e6, 2e6 + 1920.0, 2e6 + 1080.0]);
|
||||
assert_eq!(m.frame_size(fw, fh, 1.0), (fw, fh));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_quicktime_format() {
|
||||
let f = OutputFormat::from_name("uncompressed").unwrap();
|
||||
|
||||
@@ -183,7 +183,7 @@ fn project_files_refuse_newer_schemas() {
|
||||
crate::SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(p.to_file_json().unwrap_err().to_string(), expected);
|
||||
assert_eq!(p.to_file_json_with_view(&crate::SavedView::default()).unwrap_err().to_string(), expected);
|
||||
assert_eq!(p.to_file_json_with_view(&crate::SavedView::default(), false).unwrap_err().to_string(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -197,7 +197,7 @@ fn saved_files_reopen_in_pretty_and_compact_form() {
|
||||
fn project_files_remember_the_open_comps() {
|
||||
let (p, cid) = project_with_layer();
|
||||
let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid], comp_times: [(cid, effectcraft_time::Tick(4_237_833_600))].into() };
|
||||
let text = p.to_file_json_with_view(&view).unwrap();
|
||||
let text = p.to_file_json_with_view(&view, false).unwrap();
|
||||
assert_eq!(crate::saved_view(&text), view);
|
||||
assert_eq!(Project::from_json(&text).unwrap(), p, "the view is not part of the project");
|
||||
// Files from before #748 have no view; a garbled one is ignored, not an error.
|
||||
|
||||
Reference in New Issue
Block a user