Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	crates/project/src/render_queue.rs
This commit is contained in:
bflatastic
2026-10-30 05:10:05 +07:00
560 changed files with 33894 additions and 7539 deletions
+37 -2
View File
@@ -4,7 +4,7 @@ use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Gradient, ShapePath, TextDoc, Value};
use effectcraft_time::Tick;
use crate::props::{GroupKind, MaskMode, ParamUi, PropGroup, Property};
use crate::props::{GroupKind, MaskMode, Node, ParamUi, PropGroup, Property};
use crate::{Comp, Layer, LayerId, LayerSource, LightKind, Project, Switches};
/// Id allocator borrowing the project's counter.
@@ -594,6 +594,7 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
.with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0)))
.with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent))
.with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(wiggle_spatial_phase(ids))
.with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))),
"merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[
"Merge",
@@ -607,6 +608,32 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
Some(g)
}
/// Wiggle Paths' Spatial Phase.
fn wiggle_spatial_phase(ids: &mut Ids) -> Property {
ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)
}
/// Adds the properties that shape path operations have gained since a project was saved (Wiggle
/// Paths' Spatial Phase), at their defaults, to the operations in `contents` and its nested
/// groups. Returns whether anything was added. It walks the nested groups with a work list and
/// does not recurse.
pub fn upgrade_shape_contents(ids: &mut Ids, contents: &mut PropGroup) -> bool {
let mut changed = false;
let mut todo = vec![contents];
while let Some(group) = todo.pop() {
for n in &mut group.children {
let Node::Group(g) = n else { continue };
if g.match_id == "wiggle" && !g.children.iter().any(|c| c.match_id() == "spatialPhase") {
let at = g.children.iter().position(|c| c.match_id() == "phase").map_or(g.children.len(), |i| i + 1);
g.children.insert(at, wiggle_spatial_phase(ids).into());
changed = true;
}
todo.push(g);
}
}
changed
}
// ---------------------------------------------------------------- camera / light / 3D
pub fn camera_options(ids: &mut Ids, zoom: f64) -> PropGroup {
@@ -854,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 {
@@ -883,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,
+43 -24
View File
@@ -1,7 +1,7 @@
use super::item::LayerId;
use super::layer::{Layer, LayerSource};
use super::settings::{Marker, yes};
use super::*;
use super::{Deserialize, FrameRate, Node, PropGroup, Serialize, Tick, Value, essential, styles};
/// 3D renderer of a composition.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -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)
}
+7 -2
View File
@@ -1,5 +1,5 @@
use super::settings::yes;
use super::*;
use super::settings::{Marker, yes};
use super::{ColorSpace, Deserialize, FrameRate, Serialize, Tick};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum FootageKind {
@@ -115,6 +115,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 {
@@ -150,6 +154,7 @@ impl Default for Footage {
page: 0,
alphabetical: false,
start_timecode: None,
markers: vec![],
}
}
}
+22 -5
View File
@@ -2,7 +2,7 @@ use super::comp::Comp;
use super::footage::{Footage, FootageKind, Proxy, Solid};
use super::layer::LayerSource;
use super::settings::{APP_VERSION, ProjectError, ProjectSettings, SCHEMA_VERSION, SavedView};
use super::*;
use super::{Arc, BTreeMap, Deserialize, FrameRate, Label, Project, Serialize, Tick, file_check, limits};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ItemId(pub u64);
@@ -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})")))?;
+8 -2
View File
@@ -1,6 +1,6 @@
use super::item::{ItemId, LayerId};
use super::settings::Marker;
use super::*;
use super::{BlendMode, Deserialize, Label, PropGroup, Serialize, Tick};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum LightKind {
@@ -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
+2 -1
View File
@@ -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]
+69 -6
View File
@@ -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 {
@@ -509,6 +518,9 @@ pub enum OutputFormat {
TiffSequence,
/// OpenEXR sequence (32-bit float, linear light).
ExrSequence,
/// Targa sequence: uncompressed 24-bit RGB or 32-bit RGB + Alpha (8 alpha bits), top-left
/// origin.
TargaSequence,
/// Animated GIF.
Gif,
/// WebM: VP9 video (alpha with RGB + Alpha) + Opus audio.
@@ -528,7 +540,7 @@ pub enum OutputFormat {
}
impl OutputFormat {
pub const ALL: [OutputFormat; 13] = [
pub const ALL: [OutputFormat; 14] = [
OutputFormat::H264,
OutputFormat::Hevc,
OutputFormat::Av1,
@@ -539,6 +551,7 @@ impl OutputFormat {
OutputFormat::JpegSequence,
OutputFormat::TiffSequence,
OutputFormat::ExrSequence,
OutputFormat::TargaSequence,
OutputFormat::Gif,
OutputFormat::Wav,
OutputFormat::Aiff,
@@ -552,6 +565,7 @@ impl OutputFormat {
OutputFormat::JpegSequence => "JPEG Sequence",
OutputFormat::TiffSequence => "TIFF Sequence",
OutputFormat::ExrSequence => "OpenEXR Sequence",
OutputFormat::TargaSequence => "Targa Sequence",
OutputFormat::Gif => "Animated GIF",
OutputFormat::WebM => "WebM (VP9 + Opus)",
OutputFormat::Wav => "WAV",
@@ -569,6 +583,7 @@ impl OutputFormat {
OutputFormat::JpegSequence => "jpg",
OutputFormat::TiffSequence => "tif",
OutputFormat::ExrSequence => "exr",
OutputFormat::TargaSequence => "tga",
OutputFormat::Gif => "gif",
OutputFormat::WebM => "webm",
OutputFormat::Wav => "wav",
@@ -577,7 +592,10 @@ impl OutputFormat {
}
}
pub fn is_sequence(self) -> bool {
matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence)
matches!(
self,
OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence | OutputFormat::TargaSequence
)
}
pub fn is_movie(self) -> bool {
matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::WebM | OutputFormat::Hevc | OutputFormat::Av1)
@@ -603,7 +621,7 @@ impl OutputFormat {
if matches!(self, OutputFormat::H264 | OutputFormat::Hevc) { (even(w), even(h)) } else { (w, h) }
}
/// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `uncompressed`, `png`, `jpeg`/`jpg`,
/// `tiff`/`tif`, `exr`, `gif`, or a label.
/// `tiff`/`tif`, `exr`, `tga`/`targa`, `gif`, or a label.
pub fn from_name(s: &str) -> Option<OutputFormat> {
let n = s.trim().to_ascii_lowercase().replace([' ', '.', '-', '_'], "");
Some(match n.as_str() {
@@ -616,6 +634,7 @@ impl OutputFormat {
"jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence,
"tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence,
"exr" | "openexr" | "openexrsequence" => OutputFormat::ExrSequence,
"tga" | "targa" | "targasequence" => OutputFormat::TargaSequence,
"gif" | "animatedgif" => OutputFormat::Gif,
"webm" | "vp9" | "webmvp9opus" => OutputFormat::WebM,
"wav" | "wave" => OutputFormat::Wav,
@@ -1119,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).
@@ -1666,6 +1685,28 @@ 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
@@ -1723,6 +1764,28 @@ mod tests {
assert_eq!(serde_json::from_str::<OutputModule>(&json).unwrap().format, f);
}
#[test]
fn targa_sequence_format() {
let f = OutputFormat::from_name("tga").unwrap();
assert_eq!(f, OutputFormat::TargaSequence);
assert_eq!(OutputFormat::from_name(&format!("{f:?}")), Some(f));
assert_eq!(OutputFormat::from_name("Targa"), Some(f));
assert_eq!(OutputFormat::from_name("Targa Sequence"), Some(f));
assert_eq!(OutputFormat::from_path("/out/shot_00001.TGA"), Some(f));
assert!(OutputFormat::ALL.contains(&f));
assert!(f.is_sequence() && f.supports_alpha() && !f.supports_audio() && !f.is_movie());
assert_eq!((f.label(), f.extension()), ("Targa Sequence", "tga"));
assert_eq!(f.coded_size(3, 5), (3, 5));
// A movie name becomes a numbered sequence; RGB + Alpha survives the switch.
let mut m = OutputModule::for_format(OutputFormat::ProRes);
m.output = "/out/Comp 1.mov".into();
m.channels = Channels::Rgba;
m.set_format(f);
assert_eq!((m.output.as_str(), m.channels), ("/out/Comp 1_[#####].tga", Channels::Rgba));
let json = serde_json::to_string(&m).unwrap();
assert_eq!(serde_json::from_str::<OutputModule>(&json).unwrap().format, f);
}
#[test]
fn hevc_av1_codec_options() {
assert_eq!(OutputFormat::from_name("H.265"), Some(OutputFormat::Hevc));
+4 -1
View File
@@ -8,7 +8,9 @@
use serde::{Deserialize, Serialize};
use crate::render_queue::*;
use crate::render_queue::{
AlphaMode, Channels, CurrentOrOff, FieldRender, OutputFormat, OutputModule, ProResProfile, ProxyUse, RenderQuality, RenderSettings, SwitchOverride,
};
use effectcraft_time::FrameRate;
/// A default slot (the Defaults section of the Templates dialogs).
@@ -288,6 +290,7 @@ impl RenderTemplates {
#[cfg(test)]
mod tests {
use super::*;
use crate::render_queue::{ColorDepth, Crop};
#[test]
fn builtins_defaults_and_round_trip() {
+1 -1
View File
@@ -1,5 +1,5 @@
use super::item::{ItemId, LayerId};
use super::*;
use super::{BTreeMap, ColorSpace, Deserialize, Label, Serialize, Tick};
/// The project file schema. 2: effects on layers without a source rectangle (shape, text)
/// measure positions from the top-left of their comp-sized bounds (effect space), not from
+34 -2
View File
@@ -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.
@@ -290,3 +290,35 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() {
assert!(p.comp_contains(c[0], c[n - 1]));
assert!(!p.comp_contains(c[n - 1], c[0]));
}
/// A Wiggle Paths 100 000 groups deep gets Spatial Phase on a 2 MiB stack.
#[test]
fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() {
let n = 100_000;
let mut next = 1;
let mut contents = build::shape_simple_op(&mut Ids(&mut next), "wiggle").unwrap();
contents.children.retain(|c| c.match_id() != "spatialPhase");
for _ in 0..n {
let uid = Ids(&mut next).alloc();
contents = crate::PropGroup::new(uid, "group", "Group 1").with(contents);
}
let h = std::thread::Builder::new()
.stack_size(2 << 20)
.spawn(move || {
let added = build::upgrade_shape_contents(&mut Ids(&mut next), &mut contents);
(added, contents)
})
.unwrap();
let (added, mut g) = h.join().unwrap();
assert!(added);
// One level at a time: dropping the whole tree would recurse as deep as it is nested.
let mut depth = 0;
while g.match_id == "group" {
let Some(crate::Node::Group(child)) = g.children.pop() else { panic!("a nested group") };
g = child;
depth += 1;
}
assert_eq!(depth, n);
let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
assert_eq!(ids, ["size", "detail", "points", "speed", "correlation", "phase", "spatialPhase", "seed"]);
}