mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 13:04:59 +00:00
Merge pull request #3435 from nazar-gavrylyk/feat/fit-new-layers-to-work-area
Settings: option to fit new layers to the work area
This commit is contained in:
@@ -24,7 +24,7 @@ mod transform;
|
||||
|
||||
use contents::{add_mask, add_shape_item, mask_props, set_text};
|
||||
use create::{add_item, new_adjustment, new_null, new_shape, new_solid, new_text};
|
||||
pub(crate) use create::{color_p, index_p, insert_layer, insert_layer_at, place, position_p};
|
||||
pub(crate) use create::{color_p, fit_span, index_p, insert_layer, insert_layer_at, new_layer_span, place, position_p, show_span};
|
||||
pub(crate) use parent::parent_fixes;
|
||||
use parent::set_parent;
|
||||
use replace::{has_replace_source, replace_source};
|
||||
|
||||
@@ -14,6 +14,39 @@ pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings ▸ General ▸ Fit New Layers to Work Area: the `(start, end)` that new text, solid,
|
||||
/// shape, null and adjustment layers in `comp` take, or `None` for the whole composition.
|
||||
pub(crate) fn new_layer_span(s: &Session, comp: &effectcraft_project::Comp) -> Option<(Tick, Tick)> {
|
||||
let (start, end) = (comp.work_area.0, comp.work_area.1.min(comp.duration));
|
||||
(s.prefs.general.fit_new_layers_to_work_area && start >= Tick::ZERO && start < end).then_some((start, end))
|
||||
}
|
||||
|
||||
/// Start a new layer at `span`'s start and end it at `span`'s end, when there is a span.
|
||||
pub(crate) fn fit_span(l: &mut Layer, span: Option<(Tick, Tick)>) {
|
||||
if let Some((start, end)) = span {
|
||||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = end;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools draw on the layer they create: when it was fitted to a span the playhead is outside,
|
||||
/// move `comp`'s playhead to the first frame in the span so the new layer shows. The layer's timing
|
||||
/// is kept. The work area need not sit on frames (a typed start, a frame-rate change), so the
|
||||
/// frame is rounded up from the start, not to the nearest; a span holding no frame moves nothing.
|
||||
pub(crate) fn show_span(s: &mut Session, comp: ItemId, span: Option<(Tick, Tick)>) {
|
||||
let Some(fr) = s.project.comp(comp).map(|c| c.frame_rate) else { return };
|
||||
if let Some((start, end)) = span
|
||||
&& !(start..end).contains(&s.time_of(comp))
|
||||
{
|
||||
let first = fr.snap(start);
|
||||
let first = if first < start { fr.tick_of(fr.frame_at(start).saturating_add(1)) } else { first };
|
||||
if first < end {
|
||||
s.set_time_of(comp, first);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a new layer above the selection (or at the top) and select it.
|
||||
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
|
||||
insert_layer_at(proj, st, cid, layer, None)
|
||||
@@ -89,10 +122,12 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
|
||||
let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect);
|
||||
let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() };
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
|
||||
let span = new_layer_span(s, &comp);
|
||||
let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| {
|
||||
let folder = solids_folder(proj);
|
||||
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect }));
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None);
|
||||
fit_span(&mut l, span);
|
||||
if adjustment {
|
||||
l.switches.adjustment = true;
|
||||
l.label = Label::Purple;
|
||||
@@ -186,9 +221,11 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let edit = b_p(p, "edit").unwrap_or(false);
|
||||
let span = new_layer_span(s, &comp);
|
||||
let id = s.edit("New Text Layer", None, |proj, st| {
|
||||
let name: String = layer_name.clone().unwrap_or_else(|| effectcraft_project::text_layer_name(&doc.text));
|
||||
let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None);
|
||||
fit_span(&mut l, span);
|
||||
// Named after its text, the name follows the text until renamed; a given name does not.
|
||||
l.name_follows_text = Some(layer_name.as_deref().is_none_or(str::is_empty));
|
||||
if let Some(pr) = l.props.prop_mut("text/sourceText") {
|
||||
@@ -213,6 +250,9 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(id)
|
||||
})?;
|
||||
if edit {
|
||||
show_span(s, cid, span);
|
||||
}
|
||||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
||||
@@ -232,8 +272,9 @@ pub(super) fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
|
||||
let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string();
|
||||
let span = new_layer_span(s, &comp);
|
||||
let id = s.edit("New Shape Layer", None, |proj, st| {
|
||||
let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?;
|
||||
let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos, span)?;
|
||||
let mut next = proj.next_id;
|
||||
let g = crate::cmd::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint);
|
||||
proj.next_id = next;
|
||||
@@ -249,8 +290,10 @@ fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: &
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let name = str_p(p, "name").unwrap_or(name).to_string();
|
||||
let span = new_layer_span(s, &comp);
|
||||
let id = s.edit(label, None, |proj, st| {
|
||||
let l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None);
|
||||
let mut l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None);
|
||||
fit_span(&mut l, span);
|
||||
insert_layer(proj, st, cid, l)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
|
||||
@@ -8,6 +8,7 @@ use effectcraft_geom::vec2;
|
||||
use effectcraft_keyframe::{Gradient, ShapePath, Value as KV};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, ItemId, LayerId, LayerSource, Project, PropGroup};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -263,7 +264,8 @@ pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Res
|
||||
}
|
||||
|
||||
/// A new empty shape layer ("Shape Layer n" unless `name`) with its Position at `position` (comp
|
||||
/// pixels; else the comp centre), above the selected layer and selected.
|
||||
/// pixels; else the comp centre), above the selected layer and selected. `span` is the
|
||||
/// [`new_layer_span`](super::layer::new_layer_span) the layer takes, if any.
|
||||
pub(crate) fn new_shape_layer(
|
||||
proj: &mut Project,
|
||||
st: &mut EditorState,
|
||||
@@ -271,11 +273,13 @@ pub(crate) fn new_shape_layer(
|
||||
cid: ItemId,
|
||||
name: Option<&str>,
|
||||
position: Option<[f64; 2]>,
|
||||
span: Option<(Tick, Tick)>,
|
||||
) -> Result<LayerId> {
|
||||
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
|
||||
let name = name.map(str::to_string).unwrap_or_else(|| format!("Shape Layer {}", count + 1));
|
||||
let mut l = build::layer(proj, comp, &name, LayerSource::Shape, (comp.width, comp.height), None);
|
||||
place(&mut l, position);
|
||||
super::layer::fit_span(&mut l, span);
|
||||
insert_layer(proj, st, cid, l)
|
||||
}
|
||||
|
||||
@@ -346,10 +350,11 @@ fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => "Rectangle Tool",
|
||||
};
|
||||
let name = str_p(p, "name");
|
||||
let span = super::layer::new_layer_span(s, &comp);
|
||||
let (lid, group) = s.edit(label, None, |proj, st| {
|
||||
let lid = match target {
|
||||
Some(l) => l,
|
||||
None => new_shape_layer(proj, st, &comp, cid, name, Some(pos))?,
|
||||
None => new_shape_layer(proj, st, &comp, cid, name, Some(pos), span)?,
|
||||
};
|
||||
let mut next = proj.next_id;
|
||||
let g = shape_group(&mut Ids(&mut next), kind, size, &paint).ok_or_else(|| bad(c, "unknown kind"))?;
|
||||
@@ -358,6 +363,9 @@ fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.selected_layers = vec![lid];
|
||||
Ok((lid, group))
|
||||
})?;
|
||||
if target.is_none() {
|
||||
super::layer::show_span(s, cid, span);
|
||||
}
|
||||
Ok(json!({"layer": lid.0, "group": group}))
|
||||
}
|
||||
|
||||
|
||||
@@ -282,10 +282,11 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
path.vertices.iter().fold(([f64::MAX; 2], [f64::MIN; 2]), |(lo, hi), v| ([lo[0].min(v[0]), lo[1].min(v[1])], [hi[0].max(v[0]), hi[1].max(v[1])]));
|
||||
let bounds = [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]];
|
||||
let name = str_p(p, "name").map(str::to_string);
|
||||
let span = super::layer::new_layer_span(s, &comp);
|
||||
let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| {
|
||||
let lid = match target {
|
||||
Some(l) => l,
|
||||
None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None)?,
|
||||
None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None, span)?,
|
||||
};
|
||||
let mut next = proj.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
@@ -300,6 +301,9 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }];
|
||||
Ok((lid, group, path_uid))
|
||||
})?;
|
||||
if target.is_none() {
|
||||
super::layer::show_span(s, cid, span);
|
||||
}
|
||||
Ok(json!({"layer": lid.0, "group": group, "path": path_uid}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::{Comp, CompSelection, Event, ItemId, LayerId, Result, Session, Tick, Value, commands};
|
||||
|
||||
/// `t` as a current time of `comp`: a frame within its duration.
|
||||
/// `t` as a current time of `comp`: a frame within its duration. The last frame is the last one
|
||||
/// that starts before the end, so a final partial frame (a duration off the frame grid after a
|
||||
/// frame-rate change) stays reachable; on the grid it is `duration - frame_duration`.
|
||||
fn comp_frame_at(comp: &Comp, t: Tick) -> Tick {
|
||||
comp.frame_rate.snap_nearest(t.min(comp.duration - comp.frame_duration()).max(Tick::ZERO))
|
||||
let last = comp.frame_rate.snap(comp.duration - Tick(1)).max(Tick::ZERO);
|
||||
comp.frame_rate.snap_nearest(t.max(Tick::ZERO)).min(last)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -114,18 +117,23 @@ impl Session {
|
||||
/// editor is open, so playback with no text editor does no text copying.
|
||||
pub fn set_time(&mut self, t: Tick) {
|
||||
if let Some(c) = self.state.active_comp {
|
||||
let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t));
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
self.state.times.insert(c, t);
|
||||
if self.prefs.general.sync_time_related_items {
|
||||
// Direct call: this `set_time` owns the editor snapshot for both time changes,
|
||||
// so a synced move does not take a second snapshot or start a second epoch.
|
||||
self.sync_related_times_inner(c, t);
|
||||
}
|
||||
commands::text_edit::after_external_change(self, edited.as_ref());
|
||||
self.set_time_of(c, t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move `comp`'s current time, as [`Session::set_time`] does for the active comp.
|
||||
pub fn set_time_of(&mut self, c: ItemId, t: Tick) {
|
||||
let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t));
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
self.state.times.insert(c, t);
|
||||
if self.prefs.general.sync_time_related_items {
|
||||
// Direct call: this `set_time_of` owns the editor snapshot for both time changes,
|
||||
// so a synced move does not take a second snapshot or start a second epoch.
|
||||
self.sync_related_times_inner(c, t);
|
||||
}
|
||||
commands::text_edit::after_external_change(self, edited.as_ref());
|
||||
}
|
||||
|
||||
/// Settings ▸ General ▸ Synchronize Time of All Related Items: moving the current time of
|
||||
/// `comp` moves the current time of the comps nested in it (through their precomp layers'
|
||||
/// timing) and of the comps it is nested in, so every related viewer shows the same moment.
|
||||
@@ -139,8 +147,8 @@ impl Session {
|
||||
|
||||
/// Sets the time of every comp related to `comp` through precomp layers: down into the comps
|
||||
/// it nests (through each layer's timing) and up into the comps nesting it. A seen-set keeps
|
||||
/// cycles and diamonds finite; each found time is clamped to its comp's last frame, snapped to
|
||||
/// the nearest frame and stored in `state.times`. The caller owns the text-editor boundary.
|
||||
/// cycles and diamonds finite; each found time goes through [`comp_frame_at`] (as a direct move
|
||||
/// does) and is stored in `state.times`. The caller owns the text-editor boundary.
|
||||
fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) {
|
||||
let mut seen = std::collections::BTreeSet::from([comp]);
|
||||
let mut todo = vec![(comp, t)];
|
||||
@@ -170,7 +178,7 @@ impl Session {
|
||||
}
|
||||
for (cid, ct) in found {
|
||||
if let Some(c) = self.project.comp(cid) {
|
||||
let ct = c.frame_rate.snap_nearest(ct.clamp(Tick::ZERO, (c.duration - c.frame_duration()).max(Tick::ZERO)));
|
||||
let ct = comp_frame_at(c, ct);
|
||||
self.state.times.insert(cid, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ pub fn pages() -> Vec<Page> {
|
||||
s("general.showToolTips", "Show Tool Tips", B, true),
|
||||
s("general.viewerWheelScrubbing", "Scrub Composition Frames with Mouse Wheel", B, true),
|
||||
s("general.createLayersAtCompStart", "Create Layers at Composition Start Time", B, true),
|
||||
s("general.fitNewLayersToWorkArea", "Fit New Layers to Work Area", B, true),
|
||||
s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, true),
|
||||
s("general.defaultSpatialLinear", "Default Spatial Interpolation to Linear", B, true),
|
||||
s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, true),
|
||||
|
||||
@@ -36,6 +36,9 @@ page!(General {
|
||||
viewer_wheel_scrubbing: bool = false,
|
||||
/// Create New Layers at Composition Start Time (off: at the current time).
|
||||
create_layers_at_comp_start: bool = true,
|
||||
/// Fit New Layers to Work Area: new text, solid, shape, null and adjustment layers span the
|
||||
/// work area instead of the whole composition (off, as in After Effects).
|
||||
fit_new_layers_to_work_area: bool = false,
|
||||
switches_affect_nested_comps: bool = true,
|
||||
/// Default Spatial Interpolation to Linear.
|
||||
default_spatial_linear: bool = false,
|
||||
|
||||
@@ -382,6 +382,127 @@ fn new_layers_start_at_the_current_time_when_asked() {
|
||||
assert_eq!(s.active_comp().unwrap().layers[0].start_time.seconds().round(), 2.0);
|
||||
}
|
||||
|
||||
/// Settings ▸ General ▸ Fit New Layers to Work Area: new text, solid, shape, null and adjustment
|
||||
/// layers span the work area, ahead of the start-time setting; footage keeps its own length.
|
||||
#[test]
|
||||
fn new_layers_fit_the_work_area_when_asked() {
|
||||
let mut s = session_with_comp();
|
||||
s.execute("comp.workArea", json!({"start": 2, "end": 5})).unwrap();
|
||||
let span = |s: &Session| {
|
||||
let l = &s.active_comp().unwrap().layers[0];
|
||||
let r = |t: effectcraft_time::Tick| (t.seconds() * 10.0).round() / 10.0;
|
||||
(r(l.start_time), r(l.in_point), r(l.out_point))
|
||||
};
|
||||
s.execute("layer.newSolid", json!({})).unwrap();
|
||||
assert_eq!(span(&s), (0.0, 0.0, 10.0), "off by default");
|
||||
|
||||
s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "general.createLayersAtCompStart", "value": false})).unwrap();
|
||||
// The tools that draw on their new layer (Type, shape tools, Pen) move the playhead into it;
|
||||
// the menu commands leave it.
|
||||
for (cmd, p, draws) in [
|
||||
("layer.newSolid", json!({}), false),
|
||||
("layer.newAdjustment", json!({}), false),
|
||||
("layer.newNull", json!({}), false),
|
||||
("layer.newText", json!({"text": "a"}), false),
|
||||
("layer.newText", json!({"text": "a", "edit": true}), true),
|
||||
("layer.newShape", json!({"kind": "rect"}), false),
|
||||
("shape.newShape", json!({"kind": "ellipse"}), true),
|
||||
("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]}), true),
|
||||
] {
|
||||
s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0));
|
||||
s.state.selected_layers.clear();
|
||||
s.state.text_edit = None;
|
||||
s.execute(cmd, p).unwrap();
|
||||
assert_eq!(span(&s), (2.0, 2.0, 5.0), "{cmd}");
|
||||
assert_eq!(s.time().seconds().round(), if draws { 2.0 } else { 7.0 }, "{cmd} playhead");
|
||||
}
|
||||
s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0));
|
||||
|
||||
let nested = s.execute("comp.new", json!({"name": "Nested", "duration": 3, "open": false})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": nested})).unwrap();
|
||||
assert_eq!(span(&s), (7.0, 7.0, 10.0), "footage and comps keep their own timing");
|
||||
}
|
||||
|
||||
/// A tool command aimed at another comp moves that comp's playhead into its work area and
|
||||
/// leaves the active comp's playhead where it was.
|
||||
#[test]
|
||||
fn fitted_layers_in_another_comp_move_that_comps_playhead() {
|
||||
let mut s = session_with_comp();
|
||||
let active = s.active_comp_id().unwrap();
|
||||
s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap();
|
||||
let other = effectcraft_project::ItemId(s.execute("comp.new", json!({"name": "Other", "duration": 10, "open": false})).unwrap()["comp"].as_u64().unwrap());
|
||||
s.execute("comp.workArea", json!({"comp": other.0, "start": 2, "end": 5})).unwrap();
|
||||
s.execute("comp.workArea", json!({"start": 8, "end": 9})).unwrap();
|
||||
s.set_time(effectcraft_time::Tick::from_seconds_f64(7.0));
|
||||
for (cmd, p) in [
|
||||
("layer.newText", json!({"comp": other.0, "text": "a", "edit": true})),
|
||||
("shape.newShape", json!({"comp": other.0, "kind": "ellipse"})),
|
||||
("shape.newPath", json!({"comp": other.0, "vertices": [[0, 0], [10, 0], [10, 10]]})),
|
||||
] {
|
||||
s.set_time_of(other, effectcraft_time::Tick::from_seconds_f64(7.0));
|
||||
s.state.selected_layers.clear();
|
||||
s.state.text_edit = None;
|
||||
s.execute(cmd, p).unwrap();
|
||||
assert_eq!(s.time_of(other).seconds().round(), 2.0, "{cmd}: the target comp's playhead");
|
||||
assert_eq!(s.time_of(active).seconds().round(), 7.0, "{cmd}: the active comp's playhead");
|
||||
}
|
||||
}
|
||||
|
||||
/// A work area off the frame grid (set at 25 fps, then the comp went to 30 fps): the playhead
|
||||
/// goes to the first frame inside it, not the nearest frame before its start.
|
||||
#[test]
|
||||
fn fitted_layers_off_the_frame_grid_move_the_playhead_inside_them() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Grid", "duration": 2, "frameRate": 25})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap();
|
||||
s.execute("comp.workArea", json!({"start": 0.04, "end": 0.08})).unwrap();
|
||||
s.execute("comp.settings", json!({"frameRate": 30})).unwrap();
|
||||
for (cmd, p) in [("layer.newText", json!({"text": "a", "edit": true})), ("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]}))] {
|
||||
s.set_time(effectcraft_time::Tick::from_seconds_f64(1.0));
|
||||
s.state.selected_layers.clear();
|
||||
s.state.text_edit = None;
|
||||
s.execute(cmd, p).unwrap();
|
||||
let l = &s.active_comp().unwrap().layers[0];
|
||||
let t = s.time();
|
||||
assert!(l.in_point <= t && t < l.out_point, "{cmd}: {t:?} in {:?}..{:?}", l.in_point, l.out_point);
|
||||
assert_eq!(t, effectcraft_time::Tick::from_seconds_f64(2.0 / 30.0), "{cmd}: the first frame inside");
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration off the frame grid (2.01 s set at 100 fps, then 24 fps) keeps its final partial
|
||||
/// frame reachable: a layer fitted to a work area inside it gets the playhead on that frame.
|
||||
#[test]
|
||||
fn fitted_layers_in_the_final_partial_frame_get_the_playhead() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Tail", "duration": 2.01, "frameRate": 100})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap();
|
||||
s.execute("comp.workArea", json!({"start": 2.0, "end": 2.01})).unwrap();
|
||||
s.execute("comp.settings", json!({"frameRate": 24})).unwrap();
|
||||
s.set_time(effectcraft_time::Tick::ZERO);
|
||||
s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 0], [10, 10]]})).unwrap();
|
||||
let l = &s.active_comp().unwrap().layers[0];
|
||||
let t = s.time();
|
||||
assert!(l.in_point <= t && t < l.out_point, "{t:?} in {:?}..{:?}", l.in_point, l.out_point);
|
||||
assert_eq!(t, effectcraft_time::Tick::from_seconds_f64(2.0), "frame 48 of 24 fps");
|
||||
// The playhead can't go past that last frame.
|
||||
s.set_time(effectcraft_time::Tick::from_seconds_f64(5.0));
|
||||
assert_eq!(s.time(), effectcraft_time::Tick::from_seconds_f64(2.0));
|
||||
}
|
||||
|
||||
/// Synchronize Time of All Related Items reaches a nested comp's final partial frame as a direct
|
||||
/// move does: the inner comp is 2.01 s (set at 100 fps, then 24 fps), the outer sits at 2.0 s.
|
||||
#[test]
|
||||
fn synced_time_reaches_a_nested_final_partial_frame() {
|
||||
let mut s = Session::default();
|
||||
let inner = s.execute("comp.new", json!({"name": "Inner", "duration": 2.01, "frameRate": 100, "open": false})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("comp.settings", json!({"comp": inner, "frameRate": 24})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "duration": 4, "frameRate": 24})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner, "time": 0})).unwrap();
|
||||
s.set_time(effectcraft_time::Tick::from_seconds_f64(2.0));
|
||||
assert_eq!(s.time_of(effectcraft_project::ItemId(inner)), effectcraft_time::Tick::from_seconds_f64(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_spatial_interpolation_linear() {
|
||||
let mut s = session_with_comp();
|
||||
|
||||
@@ -169,6 +169,36 @@ fn type_tool_click_type_edit_and_commit() {
|
||||
assert!(h.state().session.state.text_edit.is_none());
|
||||
}
|
||||
|
||||
/// Fit New Layers to Work Area with the playhead past the work area: the Type tool's new layer
|
||||
/// spans the work area and the playhead moves into it, so the text shows while it is typed and
|
||||
/// after it is committed.
|
||||
#[test]
|
||||
fn type_tool_text_fitted_to_the_work_area_past_the_playhead_shows() {
|
||||
let mut h = harness(app());
|
||||
h.state_mut().show_panel(PanelKind::Composition);
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap();
|
||||
s.execute("comp.workArea", json!({"start": 0.5, "end": 1})).unwrap();
|
||||
s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.5));
|
||||
h.run_steps(3);
|
||||
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
|
||||
h.run_steps(1);
|
||||
let comp = rect(&h, "viewer.comp");
|
||||
click(&mut h, comp.center(), Modifiers::NONE);
|
||||
type_text(&mut h, "Hi");
|
||||
assert!(h.state().auto.find("viewer.textEdit").is_some(), "the edit region shows");
|
||||
let (lid, text, _, _) = edited(&h);
|
||||
assert_eq!(text, "Hi");
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
assert!(h.state().session.state.text_edit.is_none(), "committed");
|
||||
let s = &h.state().session;
|
||||
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
|
||||
let t = s.time();
|
||||
assert!(l.in_point <= t && t < l.out_point, "the committed text is on screen: {t:?} in {:?}..{:?}", l.in_point, l.out_point);
|
||||
assert_eq!(((l.in_point.seconds() * 10.0).round(), (l.out_point.seconds() * 10.0).round()), (5.0, 10.0), "the layer keeps the work area");
|
||||
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hi");
|
||||
}
|
||||
|
||||
/// #831: clicking another panel (a solid's row in the Timeline) ends text editing and gives the
|
||||
/// keyboard back, whether the edit began with the Type tool (which stays selected) or by command:
|
||||
/// typed characters change no text and Space plays. The Character panel keeps the edit, and a
|
||||
|
||||
@@ -1145,6 +1145,38 @@ fn shape_pen_draws_a_closed_shape_layer() {
|
||||
assert_eq!(sp.vertices.len(), 4);
|
||||
}
|
||||
|
||||
/// Fit New Layers to Work Area with the playhead past the work area: the Pen's new shape layer
|
||||
/// spans the work area and the playhead moves into it, so the path and its vertex controls show
|
||||
/// and the closing click lands.
|
||||
#[test]
|
||||
fn pen_draws_on_a_layer_fitted_to_the_work_area_past_the_playhead() {
|
||||
let mut h = harness();
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("prefs.set", json!({"key": "general.fitNewLayersToWorkArea", "value": true})).unwrap();
|
||||
s.execute("comp.workArea", json!({"start": 1, "end": 2})).unwrap();
|
||||
s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0));
|
||||
h.state_mut().ui.tool = Tool::Pen;
|
||||
h.run_steps(2);
|
||||
let n0 = h.state().session.active_comp().unwrap().layers.len();
|
||||
for p in [[200.0, 100.0], [400.0, 120.0], [300.0, 260.0]] {
|
||||
let at = screen(&h, p);
|
||||
click(&mut h, at);
|
||||
}
|
||||
let l = h.state().session.active_comp().unwrap().layers[0].clone();
|
||||
let t = h.state().session.time();
|
||||
assert!(l.in_point <= t && t < l.out_point, "the playhead is on the new layer: {t:?} in {:?}..{:?}", l.in_point, l.out_point);
|
||||
assert_eq!((l.in_point.seconds().round(), l.out_point.seconds().round()), (1.0, 2.0), "the layer keeps the work area");
|
||||
let path = l.props.sub("contents").unwrap().groups().next().unwrap().sub("contents").unwrap().groups().next().unwrap().clone();
|
||||
assert!(h.state().auto.find(&format!("viewer.shapePath.{}.vertex.0", path.uid)).is_some(), "the Pen's vertex controls show");
|
||||
let at = screen(&h, [200.0, 100.0]);
|
||||
click(&mut h, at);
|
||||
let comp = h.state().session.active_comp().unwrap().clone();
|
||||
assert_eq!(comp.layers.len(), n0 + 1);
|
||||
let sp = comp.layers[0].props.find_group(path.uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
|
||||
assert_eq!(sp.vertices.len(), 3);
|
||||
assert!(sp.closed, "the click on the first vertex closes the path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_key_drag_edits_that_key() {
|
||||
let mut h = harness();
|
||||
|
||||
@@ -37,6 +37,7 @@ Commands (CLI, MCP, control channel):
|
||||
- `general.showToolTips`: Show Tool Tips
|
||||
- `general.viewerWheelScrubbing`: Scrub Composition Frames with Mouse Wheel (off by default). On: an unmodified mouse wheel over the Composition viewer steps frames, and the smoothed tail of that notch does not zoom or pan. Ctrl/Cmd+wheel and a trackpad still zoom and pan.
|
||||
- `general.createLayersAtCompStart`: Create Layers at Composition Start Time
|
||||
- `general.fitNewLayersToWorkArea`: Fit New Layers to Work Area (off by default). On: new text, solid, shape, null and adjustment layers start at the work area start and end at its end, ahead of Create Layers at Composition Start Time. Footage and compositions keep their own length.
|
||||
- `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear
|
||||
- `startup.showHomeOnLaunch`: Show Home Screen When Launching
|
||||
- `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash
|
||||
|
||||
Reference in New Issue
Block a user