mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 07:38:24 +00:00
Merge main into #1739; keep both footage re-exports (Cineon + VIDEO_EXTENSIONS) and main's gaps.md localization row with the PR's AVI row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9e6332ea27
181 files changed
+8437
-619
No files matched your search
@@ -197,7 +197,8 @@ pub(crate) struct Work {
|
||||
}
|
||||
|
||||
/// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels).
|
||||
/// Footage at its native size is used straight from the footage source (no copy).
|
||||
/// Footage at its native size is used straight from the footage source (no copy unless its
|
||||
/// Cineon Settings convert it).
|
||||
pub(crate) fn source_frame(
|
||||
r: &Renderer,
|
||||
project: &Project,
|
||||
@@ -215,7 +216,7 @@ pub(crate) fn source_frame(
|
||||
&& img.width == f.width
|
||||
&& img.height == f.height
|
||||
{
|
||||
return Some((img, [0.0; 2]));
|
||||
return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2]));
|
||||
}
|
||||
let buf = r.layer_source(&ctx, layer)?;
|
||||
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
|
||||
|
||||
@@ -240,7 +240,7 @@ fn replace_with_precomp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<ItemId> = proj.items.keys().copied().filter(|c| *c != comp).collect();
|
||||
for cid in ids {
|
||||
if let Some(c) = proj.comp_mut(cid) {
|
||||
for l in c.layers.iter_mut().filter(|l| is_user(l)) {
|
||||
for l in c.layers.iter_mut_where(|l| is_user(l)) {
|
||||
l.source = LayerSource::Comp { item: comp };
|
||||
n += 1;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, always, bad, str_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Commands that can't run inside a batch (they replace the project or nest batches).
|
||||
const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"];
|
||||
/// Commands that can't run inside a batch (they nest batches), besides the commands that run
|
||||
/// scripts ([`super::scripts::RUNS_SCRIPTS`]).
|
||||
const NOT_IN_BATCH: &[&str] = &["engine.batch"];
|
||||
|
||||
/// Resolve a `$N` / `$N.key.0.key` reference against the results so far.
|
||||
fn reference(s: &str, results: &[Value]) -> Option<Result<Value>> {
|
||||
@@ -78,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.or_else(|| step.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?;
|
||||
if NOT_IN_BATCH.contains(&id) {
|
||||
if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) {
|
||||
return Err(bad("engine.batch", format!("`{id}` can't run inside a batch")));
|
||||
}
|
||||
let params = substitute(step.get("params").unwrap_or(&json!({})), results)?;
|
||||
|
||||
@@ -35,7 +35,7 @@ fn apply_settings(c: &mut Comp, p: &Value) {
|
||||
let dx = (c.width as f64 - ow as f64) * (a % 3) as f64 / 2.0;
|
||||
let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0;
|
||||
if dx != 0.0 || dy != 0.0 {
|
||||
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
|
||||
for l in c.layers.iter_mut_where(|l| l.parent.is_none()) {
|
||||
if let Some(tr) = l.transform_mut() {
|
||||
super::layer::offset_position(tr, dx, dy);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &
|
||||
let (old_w, old_h) = (c.width, c.height);
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
|
||||
for l in c.layers.iter_mut_where(|l| l.parent.is_none()) {
|
||||
if let Some(tr) = l.transform_mut() {
|
||||
super::layer::offset_position(tr, -x, -y);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn relink_copies(comp: &mut Comp, map: &[(LayerId, LayerId)]) {
|
||||
let present: Vec<LayerId> = comp.layers.iter().map(|l| l.id).collect();
|
||||
let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, c)| *c).or(present.contains(&id).then_some(id));
|
||||
for l in comp.layers.iter_mut().filter(|l| map.iter().any(|(_, c)| *c == l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| map.iter().any(|(_, c)| *c == l.id)) {
|
||||
l.parent = l.parent.and_then(link);
|
||||
l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m }));
|
||||
}
|
||||
@@ -685,7 +685,7 @@ fn label(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if let Some(comp) = proj.comp_mut(cid) {
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.label = lab;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,8 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they
|
||||
/// also become layers of the active comp ([`add_to_comp`]).
|
||||
/// also become layers of the active comp ([`add_to_comp`]); with `newComp` (the empty viewer's
|
||||
/// New Composition From Footage) they become one new composition instead ([`new_comp`]).
|
||||
///
|
||||
/// With `background` (files dropped on the window or picked in the Import dialog) the files are
|
||||
/// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows
|
||||
@@ -175,10 +176,10 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`;
|
||||
/// what couldn't be added joins the result's errors.
|
||||
/// After an import (its result `r`): with `newComp`, the new items become one new composition;
|
||||
/// otherwise with `addToComp`, layers of `target`. What couldn't be added joins the result's errors.
|
||||
fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Value) {
|
||||
let errors = add_to_comp(s, r, target, p);
|
||||
let errors = if b_p(p, "newComp").unwrap_or(false) { new_comp(s, r) } else { add_to_comp(s, r, target, p) };
|
||||
if !errors.is_empty()
|
||||
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
|
||||
{
|
||||
@@ -192,18 +193,7 @@ fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Val
|
||||
/// Project panel) and the other footage. `target` stays open. Returns what couldn't be added.
|
||||
pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p: &Value) -> Vec<String> {
|
||||
let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] };
|
||||
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
|
||||
let comps = ids("comps");
|
||||
let in_comps: std::collections::HashSet<u64> = comps
|
||||
.iter()
|
||||
.filter_map(|c| s.project.comp(ItemId(*c)))
|
||||
.flat_map(|c| c.layers.iter())
|
||||
.filter_map(|l| match l.source {
|
||||
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let add: Vec<u64> = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect();
|
||||
let add = imported(s, r);
|
||||
let mut errors = vec![];
|
||||
for (k, item) in add.into_iter().enumerate() {
|
||||
let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")});
|
||||
@@ -220,6 +210,55 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p:
|
||||
errors
|
||||
}
|
||||
|
||||
/// What an import (its result `r`) made that belongs in a comp: the comps of layered files
|
||||
/// (their layers' footage, in their precomps too, stays in the Project panel), then the other items,
|
||||
/// without those comps again or the files' folders.
|
||||
fn imported(s: &Session, r: &Value) -> Vec<u64> {
|
||||
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
|
||||
let comps = ids("comps");
|
||||
let mut in_comps = std::collections::HashSet::new();
|
||||
let mut todo = comps.clone();
|
||||
while let Some(c) = todo.pop() {
|
||||
for l in s.project.comp(ItemId(c)).into_iter().flat_map(|c| c.layers.iter()) {
|
||||
match l.source {
|
||||
LayerSource::Comp { item } if in_comps.insert(item.0) => todo.push(item.0),
|
||||
LayerSource::Footage { item } | LayerSource::Solid { item } => {
|
||||
in_comps.insert(item.0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let rest = ids("items").into_iter().filter(|i| !in_comps.contains(i) && !comps.contains(i) && !s.project.item(ItemId(*i)).is_some_and(|it| it.is_folder()));
|
||||
comps.iter().copied().chain(rest).collect()
|
||||
}
|
||||
|
||||
/// With `newComp`, make one composition of the footage and solids an import (its result `r`)
|
||||
/// made, as New Comp from Selection does with `single` (first item on top, its settings). A comp
|
||||
/// the import made (a layered file) already is the composition, so it stays out, as does data
|
||||
/// footage; none left, no new comp. Returns what went wrong.
|
||||
fn new_comp(s: &mut Session, r: &Value) -> Vec<String> {
|
||||
let items: Vec<ItemId> = imported(s, r)
|
||||
.into_iter()
|
||||
.map(ItemId)
|
||||
.filter(|i| {
|
||||
s.project.item(*i).is_some_and(|it| match &it.kind {
|
||||
ItemKind::Footage(f) => f.kind != FootageKind::Data,
|
||||
ItemKind::Solid(_) => true,
|
||||
ItemKind::Comp(_) | ItemKind::Folder => false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
s.state.project_selection = items;
|
||||
match s.execute("file.newCompFromSelection", json!({"single": true})) {
|
||||
Ok(_) => vec![],
|
||||
Err(e) => vec![e.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`].
|
||||
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pending = prepare_import(s, p)?;
|
||||
@@ -746,7 +785,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"File...",
|
||||
["File", "Import"],
|
||||
Some("Cmd+I"),
|
||||
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), newComp?: bool (make one new composition of the footage and solids imported, as file.newCompFromSelection with single; a layered file imported as a composition is that composition and gets no other; takes precedence over addToComp), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
always,
|
||||
import_cmd
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
|
||||
use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -501,7 +501,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))),
|
||||
};
|
||||
if id == "file.runScript" || id == "script.run" {
|
||||
if super::scripts::RUNS_SCRIPTS.contains(&id.as_str()) {
|
||||
return Err(bad("file.runScript", "scripts can't run scripts"));
|
||||
}
|
||||
let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) };
|
||||
@@ -536,7 +536,7 @@ fn is_jsxbin(name: &str) -> bool {
|
||||
}
|
||||
|
||||
/// A script error becomes a toast and a command error.
|
||||
fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
|
||||
pub(crate) fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
|
||||
if let Some(e) = out.get("error").filter(|e| !e.is_null()) {
|
||||
let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default();
|
||||
let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error");
|
||||
@@ -552,6 +552,13 @@ pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result<Va
|
||||
report_js(s, name, out)
|
||||
}
|
||||
|
||||
/// Run the code of the script file at `path` (its `#include`s and `$.fileName` use that path),
|
||||
/// reported under its file name `name` (Startup and Shutdown scripts).
|
||||
pub(crate) fn run_js_file(s: &mut Session, code: &str, path: &str, name: &str) -> Result<Value> {
|
||||
let out = run_js(s, code, path, false)?;
|
||||
report_js(s, name, out)
|
||||
}
|
||||
|
||||
/// Run JavaScript through the session's scripting engine.
|
||||
fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Value> {
|
||||
let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?;
|
||||
@@ -594,6 +601,131 @@ pub(crate) struct Interpretation {
|
||||
preserve_rgb: Option<bool>,
|
||||
/// Start Timecode.
|
||||
start: Option<StartTimecode>,
|
||||
/// Cineon Settings (DPX and Cineon footage).
|
||||
cineon: CineonEdit,
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Cineon Settings parsed from command parameters (every field optional).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct CineonEdit {
|
||||
/// Logarithmic Conversion.
|
||||
log: Option<bool>,
|
||||
units: Option<CineonUnits>,
|
||||
/// The units the Converted Black and White Points are given in (`pointUnits`, else `cineonUnits`,
|
||||
/// else the footage's own Units).
|
||||
point_units: Option<CineonUnits>,
|
||||
converted_black: Option<f64>,
|
||||
converted_white: Option<f64>,
|
||||
/// 10 Bit Black and White Points.
|
||||
black: Option<f64>,
|
||||
white: Option<f64>,
|
||||
gamma: Option<f64>,
|
||||
rolloff: Option<f64>,
|
||||
}
|
||||
|
||||
impl CineonEdit {
|
||||
fn parse(p: &Value, cmd: &str) -> Result<CineonEdit> {
|
||||
let units = |key: &str| -> Result<Option<CineonUnits>> {
|
||||
match str_p(p, key) {
|
||||
None => Ok(None),
|
||||
Some(u) => CineonUnits::parse(u).map(Some).ok_or_else(|| bad(cmd, format!("{key}: 10bit|8bit|float, not `{u}`"))),
|
||||
}
|
||||
};
|
||||
let num = |key: &str, lo: f64, hi: f64| -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && (lo..=hi).contains(x))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, format!("{key}: a number from {lo} to {hi}"))),
|
||||
}
|
||||
};
|
||||
let units_now = units("cineonUnits")?;
|
||||
Ok(CineonEdit {
|
||||
log: b_p(p, "logConversion"),
|
||||
units: units_now,
|
||||
point_units: units("pointUnits")?.or(units_now),
|
||||
// Range-checked once their units are known (`settings_for`).
|
||||
converted_black: num("convertedBlack", f64::MIN, f64::MAX)?,
|
||||
converted_white: num("convertedWhite", f64::MIN, f64::MAX)?,
|
||||
black: num("tenBitBlack", 0.0, 1023.0)?,
|
||||
white: num("tenBitWhite", 0.0, 1023.0)?,
|
||||
gamma: num("currentGamma", 0.1, 10.0)?,
|
||||
rolloff: num("highlightRolloff", 0.0, 100.0)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.log.is_none()
|
||||
&& self.units.is_none()
|
||||
&& self.converted_black.is_none()
|
||||
&& self.converted_white.is_none()
|
||||
&& self.black.is_none()
|
||||
&& self.white.is_none()
|
||||
&& self.gamma.is_none()
|
||||
&& self.rolloff.is_none()
|
||||
}
|
||||
|
||||
/// Footage `f`'s Cineon Settings after this edit; `None` when nothing changes them (no Cineon
|
||||
/// parameters, or `f` is not a DPX or Cineon file).
|
||||
fn settings_for(&self, f: &Footage, cmd: &str) -> Result<Option<CineonSettings>> {
|
||||
if self.is_empty() || !f.is_cineon_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut c = f.cineon;
|
||||
let units = self.point_units.unwrap_or(c.units);
|
||||
let point = |v: f64| -> Result<f32> {
|
||||
let ten = units.to_ten_bit(v);
|
||||
let max = units.from_ten_bit(f64::from(CineonSettings::MAX));
|
||||
if (0.0..=f64::from(CineonSettings::MAX)).contains(&ten) {
|
||||
Ok(ten as f32)
|
||||
} else {
|
||||
Err(bad(cmd, format!("converted points: 0 to {max} in {} units", units.label())))
|
||||
}
|
||||
};
|
||||
if let Some(v) = self.converted_black {
|
||||
c.converted_black = point(v)?;
|
||||
}
|
||||
if let Some(v) = self.converted_white {
|
||||
c.converted_white = point(v)?;
|
||||
}
|
||||
if let Some(v) = self.log {
|
||||
c.log_conversion = v;
|
||||
}
|
||||
if let Some(u) = self.units {
|
||||
c.units = u;
|
||||
}
|
||||
if let Some(v) = self.black {
|
||||
c.black = v as f32;
|
||||
}
|
||||
if let Some(v) = self.white {
|
||||
c.white = v as f32;
|
||||
}
|
||||
if let Some(v) = self.gamma {
|
||||
c.gamma = v as f32;
|
||||
}
|
||||
if let Some(v) = self.rolloff {
|
||||
c.rolloff = v as f32;
|
||||
}
|
||||
if c.black >= c.white {
|
||||
return Err(bad(cmd, "the 10 Bit Black Point must be below the 10 Bit White Point"));
|
||||
}
|
||||
Ok(Some(c))
|
||||
}
|
||||
}
|
||||
|
||||
/// Footage `f`'s Cineon Settings as commands report them (`null` for files they don't apply to).
|
||||
fn cineon_json(f: &Footage) -> Value {
|
||||
if !f.is_cineon_file() {
|
||||
return Value::Null;
|
||||
}
|
||||
let c = &f.cineon;
|
||||
json!({
|
||||
"logConversion": c.log_conversion, "units": c.units.id(),
|
||||
"convertedBlack": c.units.from_ten_bit(f64::from(c.converted_black)), "convertedWhite": c.units.from_ten_bit(f64::from(c.converted_white)),
|
||||
"tenBitBlack": c.black, "tenBitWhite": c.white, "currentGamma": c.gamma, "highlightRolloff": c.rolloff,
|
||||
})
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Start Timecode.
|
||||
@@ -687,9 +819,23 @@ impl Interpretation {
|
||||
v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?,
|
||||
)),
|
||||
};
|
||||
it.cineon = CineonEdit::parse(p, cmd)?;
|
||||
Ok(it)
|
||||
}
|
||||
|
||||
/// Check the Cineon Settings this interpretation gives each of `footage`: an error names a
|
||||
/// value out of range, or Cineon parameters when none of the footage is DPX or Cineon.
|
||||
pub(crate) fn check<'a>(&self, footage: impl IntoIterator<Item = &'a Footage>, cmd: &str) -> Result<()> {
|
||||
if self.cineon.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut any = false;
|
||||
for f in footage {
|
||||
any |= self.cineon.settings_for(f, cmd)?.is_some();
|
||||
}
|
||||
if any { Ok(()) } else { Err(bad(cmd, "Cineon Settings apply to DPX and Cineon footage only")) }
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
|
||||
let before = f.frame_rate;
|
||||
self.apply_settings(f, guessed);
|
||||
@@ -749,6 +895,10 @@ impl Interpretation {
|
||||
if let Some(v) = self.preserve_rgb {
|
||||
f.preserve_rgb = v;
|
||||
}
|
||||
// Checked before the edit (`Interpretation::check`).
|
||||
if let Ok(Some(c)) = self.cineon.settings_for(f, "file.interpretFootage") {
|
||||
f.cineon = c;
|
||||
}
|
||||
// After the frame rate: timecode is read at the footage's rate.
|
||||
if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) {
|
||||
f.start_timecode = start;
|
||||
@@ -795,6 +945,11 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("file.interpretFootage", "select footage"));
|
||||
}
|
||||
let it = Interpretation::parse(p, "file.interpretFootage")?;
|
||||
let footage = items.iter().filter_map(|i| match s.project.item(*i).map(|x| &x.kind) {
|
||||
Some(ItemKind::Footage(f)) => Some(f),
|
||||
_ => None,
|
||||
});
|
||||
it.check(footage, "file.interpretFootage")?;
|
||||
let guesses: Vec<Option<(AlphaMode, [f32; 3])>> = items
|
||||
.iter()
|
||||
.map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) {
|
||||
@@ -830,7 +985,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
|
||||
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
|
||||
"linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(),
|
||||
"frames": f.sequence_frames(),
|
||||
"frames": f.sequence_frames(), "cineon": cineon_json(f),
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
@@ -861,6 +1016,9 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
f.linear_light = src.linear_light;
|
||||
f.preserve_rgb = src.preserve_rgb;
|
||||
f.color_profile = src.color_profile;
|
||||
if f.is_cineon_file() && src.is_cineon_file() {
|
||||
f.cineon = src.cineon;
|
||||
}
|
||||
if src.native_rate.is_some() {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
@@ -1090,7 +1248,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
|
||||
"{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
|
||||
@@ -56,6 +56,12 @@ pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time::
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame the Footage panel shows: footage `f` at source time `t`, with its Cineon Settings
|
||||
/// applied.
|
||||
pub fn frame(s: &Session, item: ItemId, f: &effectcraft_project::Footage, t: Tick) -> Option<std::sync::Arc<effectcraft_raster::Image>> {
|
||||
s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f))
|
||||
}
|
||||
|
||||
fn source_time(s: &Session, p: &Value, item: ItemId) -> Option<Tick> {
|
||||
let (_, fr) = source_timing(s, item);
|
||||
if let Some(t) = f_p(p, "time") {
|
||||
|
||||
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add (Shape)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|wiggler|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
|
||||
has_layers,
|
||||
add_shape_item
|
||||
),
|
||||
|
||||
@@ -526,7 +526,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] };
|
||||
s.edit("Parent", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.parent = parent;
|
||||
if let Some(f) = fixes.iter().find(|f| f.layer == l.id) {
|
||||
f.apply(l);
|
||||
|
||||
@@ -19,7 +19,7 @@ pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false));
|
||||
let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
|
||||
let target = v.unwrap_or(!first);
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
set_switch_value(l, &name, target);
|
||||
last = target;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str,
|
||||
alone &= on == (ids.contains(&l.id) == active);
|
||||
}
|
||||
let others = value.unwrap_or(alone == active);
|
||||
for l in comp.layers.iter_mut().filter(|l| applies(l)) {
|
||||
for l in comp.layers.iter_mut_where(|l| applies(l)) {
|
||||
let v = if ids.contains(&l.id) { active } else { others };
|
||||
set_switch_value(l, name, v);
|
||||
}
|
||||
@@ -152,7 +152,7 @@ pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_p(p, "comment").ok_or_else(|| bad("layer.setComment", "missing `comment`"))?.to_string();
|
||||
s.edit("Layer Comment", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.comment = text.clone();
|
||||
}
|
||||
Ok(())
|
||||
@@ -169,7 +169,7 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.edit("Blending Mode", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.blend_mode = match mode {
|
||||
Some(m) => m,
|
||||
None if step > 0 => l.blend_mode.next(),
|
||||
|
||||
@@ -18,7 +18,7 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fd = comp.frame_duration();
|
||||
let comp_dur = comp.duration;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
let shift = |l: &mut Layer, d: Tick| {
|
||||
l.start_time += d;
|
||||
l.in_point += d;
|
||||
@@ -161,14 +161,14 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for i in 1..n {
|
||||
if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) {
|
||||
let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n);
|
||||
comp.layers[i - 1..run_end].rotate_left(1);
|
||||
comp.layers.rotate_range_left(i - 1..run_end, 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i in (0..n.saturating_sub(1)).rev() {
|
||||
if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) {
|
||||
let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1);
|
||||
comp.layers[run_start..=i + 1].rotate_right(1);
|
||||
comp.layers.rotate_range_right(run_start..=i + 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ fn quality(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q => return Err(bad("layer.quality", format!("quality must be best|draft|wireframe, not `{q}`"))),
|
||||
};
|
||||
s.edit("Layer Quality", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.switches.quality = q;
|
||||
}
|
||||
Ok(())
|
||||
@@ -84,7 +84,7 @@ fn sampling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q => q,
|
||||
};
|
||||
s.edit("Layer Sampling", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.switches.sampling = q;
|
||||
}
|
||||
Ok(())
|
||||
@@ -101,7 +101,7 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
m => return Err(bad("layer.frameBlending", format!("mode must be off|frameMix|pixelMotion, not `{m}`"))),
|
||||
};
|
||||
s.edit("Frame Blending", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.switches.frame_blend = m;
|
||||
}
|
||||
Ok(())
|
||||
@@ -116,7 +116,7 @@ fn hide_other_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Hide Other Video", None, |proj, _| {
|
||||
// Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's
|
||||
// eye cell shows): the selection stays shown.
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| !ids.contains(&l.id)) {
|
||||
l.switches.video = false;
|
||||
}
|
||||
Ok(())
|
||||
@@ -130,7 +130,7 @@ fn show_all_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
// Track mattes stay hidden (their video switch is off by design).
|
||||
let mattes: Vec<LayerId> = c.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect();
|
||||
for l in c.layers.iter_mut().filter(|l| !mattes.contains(&l.id)) {
|
||||
for l in c.layers.iter_mut_where(|l| !mattes.contains(&l.id)) {
|
||||
l.switches.video = true;
|
||||
}
|
||||
Ok(())
|
||||
@@ -154,7 +154,7 @@ fn expressions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = b_p(p, "enabled").unwrap_or(true);
|
||||
let n = s.edit(if on { "Enable Expressions" } else { "Disable Expressions" }, None, |proj, _| {
|
||||
let mut n = 0;
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.props.walk_mut(&mut |pr| {
|
||||
if let Some(e) = &mut pr.expr {
|
||||
e.enabled = on;
|
||||
@@ -586,7 +586,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = s.edit("Lock Markers", None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let target = v.unwrap_or_else(|| !c.layers.iter().find(|l| ids.contains(&l.id)).is_some_and(|l| l.markers_locked));
|
||||
for l in c.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in c.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.markers_locked = target;
|
||||
}
|
||||
Ok(target)
|
||||
@@ -597,7 +597,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn delete_all_markers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Delete All Markers", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id) && !l.markers_locked) {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id) && !l.markers_locked) {
|
||||
l.markers.clear();
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -34,7 +34,7 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fd = comp_rate.frame_duration();
|
||||
s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
let old = l.stretch;
|
||||
if reverse {
|
||||
// Keep the in/out points; play the visible span backwards.
|
||||
@@ -109,7 +109,7 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut next = proj.next_id;
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut on = false;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id) && remappable(l)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id) && remappable(l)) {
|
||||
let has = l.props.get("timeRemap").is_some();
|
||||
let target = want.unwrap_or(!has);
|
||||
on |= target;
|
||||
|
||||
@@ -64,6 +64,12 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) {
|
||||
s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer");
|
||||
}
|
||||
if r == Renderer::Classic3D {
|
||||
let env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: effectcraft_project::LightKind::Environment });
|
||||
if s.project.comp(cid).is_some_and(|c| c.layers.iter().any(env)) {
|
||||
s.toast("Classic 3D doesn't light Environment light layers: they light with the Advanced 3D renderer");
|
||||
}
|
||||
}
|
||||
Ok(json!({"renderer": renderer_id(r)}))
|
||||
}
|
||||
|
||||
@@ -77,7 +83,7 @@ fn renderer_id(r: Renderer) -> &'static str {
|
||||
/// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a
|
||||
/// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether
|
||||
/// the renderer changed.
|
||||
fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {
|
||||
pub(crate) fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let switched = c.renderer != Renderer::Advanced3D;
|
||||
c.renderer = Renderer::Advanced3D;
|
||||
@@ -86,10 +92,10 @@ fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Resu
|
||||
}
|
||||
|
||||
/// Tell the user when [`use_advanced_3d`] changed the comp's renderer.
|
||||
fn report_switch(s: &mut Session, cid: ItemId, switched: bool) {
|
||||
pub(crate) fn report_switch(s: &mut Session, cid: ItemId, switched: bool, why: &str) {
|
||||
if switched {
|
||||
let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string();
|
||||
s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models"));
|
||||
s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, {why}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +162,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = insert_layer_at(proj, st, cid, l, index)?;
|
||||
Ok((id, use_advanced_3d(proj, cid)?))
|
||||
})?;
|
||||
report_switch(s, cid, switched);
|
||||
report_switch(s, cid, switched, "which draws 3D models");
|
||||
Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true}))
|
||||
}
|
||||
|
||||
@@ -226,7 +232,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = insert_layer(proj, st, cid, l)?;
|
||||
Ok((id, use_advanced_3d(proj, cid)?))
|
||||
})?;
|
||||
report_switch(s, cid, switched);
|
||||
report_switch(s, cid, switched, "which draws 3D models");
|
||||
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true}))
|
||||
}
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
const SHAPE_ITEMS: &[&str] = &[
|
||||
"group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag",
|
||||
"wiggle", "merge", "front", "bevel", "side", "back",
|
||||
"wiggle", "wiggler", "merge", "front", "bevel", "side", "back",
|
||||
];
|
||||
|
||||
/// Selected shape items (contents entries) of shape layers: (layer, uid).
|
||||
|
||||
@@ -1189,6 +1189,15 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn set_muted(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let muted = b_p(p, "muted").unwrap_or(true);
|
||||
let label = if muted { "Mute Keyframes" } else { "Unmute Keyframes" };
|
||||
edit_keys(s, label, None, move |keys, i, _| {
|
||||
keys[i].muted = muted;
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// A number or a per-dimension array of numbers.
|
||||
fn nums_p(p: &Value, k: &str, scale: f64) -> Option<Vec<f64>> {
|
||||
match p.get(k)? {
|
||||
@@ -1381,6 +1390,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
&json!({"which": "out"})
|
||||
)),
|
||||
cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold),
|
||||
cmd!("keys.setMuted", "Mute Keyframes", ["Animation"], None, "{muted?: bool (default true)}", has_keys, set_muted),
|
||||
cmd!(
|
||||
"keys.interpolation",
|
||||
"Keyframe Interpolation...",
|
||||
|
||||
@@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "the selected items have no proxy"));
|
||||
}
|
||||
let it = super::file_more::Interpretation::parse(p, C)?;
|
||||
it.check(items.iter().filter_map(|i| s.project.item(*i).and_then(|x| x.proxy.as_ref()).map(|px| &px.footage)), C)?;
|
||||
let guesses: Vec<_> = items
|
||||
.iter()
|
||||
.map(|i| {
|
||||
@@ -229,7 +230,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Proxy...",
|
||||
["File", "Interpret Footage"],
|
||||
None,
|
||||
"{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage)}",
|
||||
"{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage), logConversion?, cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff? (Cineon Settings of DPX and Cineon proxies)}",
|
||||
has_proxy,
|
||||
interpret_proxy
|
||||
),
|
||||
|
||||
@@ -277,7 +277,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
set_expr(&mut cam, "cameraOptions/zoom", ze);
|
||||
cam.auto_orient = AutoOrient::TowardsPointOfInterest;
|
||||
let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?;
|
||||
comp.layers = vec![cam, nest];
|
||||
comp.layers = vec![cam, nest].into();
|
||||
eye_comps.push(eid);
|
||||
}
|
||||
// The output comp: both eyes through 3D Glasses.
|
||||
@@ -300,7 +300,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fxg.children.push(g.into());
|
||||
}
|
||||
let (lid, rid) = (left.id, right.id);
|
||||
proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right];
|
||||
proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right].into();
|
||||
st.project_selection = vec![oid];
|
||||
Ok(json!({
|
||||
"controls": ctl_id.0,
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
//! panels appear at the bottom of the Window menu and open as dockable panels
|
||||
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of
|
||||
//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`.
|
||||
//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by
|
||||
//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft
|
||||
//! quits, after the project is closed (`file.runShutdownScripts`).
|
||||
//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
|
||||
//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
|
||||
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
|
||||
@@ -23,6 +26,18 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui};
|
||||
/// The settings-store folders of installed scripts.
|
||||
pub const SCRIPTS_DIR: &str = "Scripts";
|
||||
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
|
||||
/// The settings-store folders whose scripts run at launch and as EffectCraft quits.
|
||||
pub const STARTUP_DIR: &str = "Scripts/Startup";
|
||||
pub const SHUTDOWN_DIR: &str = "Scripts/Shutdown";
|
||||
/// Commands that run script files or script code (a ScriptUI panel runs its script when it
|
||||
/// opens). Scripts and batches can't run them, and confined automation sessions refuse them.
|
||||
pub const RUNS_SCRIPTS: &[&str] =
|
||||
&["script.run", "file.runScript", "file.runStartupScripts", "file.runShutdownScripts", "window.scriptPanel", "window.restoreScriptPanels"];
|
||||
/// Largest script file the Startup and Shutdown folders run (bytes), as `#include` and
|
||||
/// `$.evalFile` allow.
|
||||
const MAX_SCRIPT_BYTES: usize = 16 << 20;
|
||||
/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled.
|
||||
const MAX_SHUTDOWN_DIALOGS: usize = 64;
|
||||
/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts.
|
||||
const PANELS_FOLDER: &str = "ScriptUI Panels";
|
||||
/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in
|
||||
@@ -221,6 +236,7 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result<Value> {
|
||||
let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?;
|
||||
let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR };
|
||||
cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?;
|
||||
s.scripts_changed();
|
||||
// After Effects asks for a restart; our menus pick it up at once.
|
||||
s.toast(if panel {
|
||||
format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu")
|
||||
@@ -256,6 +272,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
s.scripts_changed();
|
||||
if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) }
|
||||
}
|
||||
|
||||
@@ -337,6 +354,85 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"opened": opened, "failed": failed}))
|
||||
}
|
||||
|
||||
/// Run the scripts in `dir` of the settings store one after another, in alphabetical order (case
|
||||
/// ignored), as After Effects runs its Startup and Shutdown folders. A script that fails is
|
||||
/// reported in a toast and the log and listed in `failed`; the next one still runs. A script waiting in a
|
||||
/// modal dialog is listed in `waiting` and goes on when the dialog closes; the next one runs
|
||||
/// meanwhile. With `quitting`, no window is left to show a dialog in, so each dialog closes as
|
||||
/// if canceled (`show()` returns 2) and the script runs on.
|
||||
fn run_folder(s: &mut Session, dir: &str, quitting: bool) -> Value {
|
||||
let mut names: Vec<String> = s.config.as_ref().map(|c| c.list(dir)).unwrap_or_default().into_iter().filter(|n| is_script(n) && is_script_name(n)).collect();
|
||||
names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
|
||||
let (mut ran, mut waiting, mut failed) = (vec![], vec![], vec![]);
|
||||
for name in names {
|
||||
let code = match s.config.as_ref().map(|c| c.read_bounded(&format!("{dir}/{name}"), MAX_SCRIPT_BYTES)) {
|
||||
Some(Ok(Some(code))) => code,
|
||||
read => {
|
||||
let why = match read {
|
||||
Some(Err(e)) => format!(": {e}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
let error = format!("{name}: cannot read the script{why}");
|
||||
log::warn!("{dir}: {error}");
|
||||
s.events.push(Event::Toast { message: error.clone(), error: true });
|
||||
failed.push(json!({"name": name, "error": error}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let is_waiting = |r: &Result<Value>| r.as_ref().is_ok_and(|v| v.get("waiting").and_then(Value::as_bool) == Some(true));
|
||||
// In a settings folder on disk, the script runs as its file, so `#include`, `$.fileName`
|
||||
// and `$.evalFile` find the files beside it.
|
||||
let path = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(dir).join(&name).to_string_lossy().into_owned()).unwrap_or_else(|| name.clone());
|
||||
// Windows open before this script runs belong to other scripts, perhaps of the same name.
|
||||
let before: Vec<u32> = s.script_ui.windows.iter().map(|w| w.id).collect();
|
||||
let hosts_before = s.script_ui.task_hosts.clone();
|
||||
let mut r = super::file_more::run_js_file(s, &code, &path, &name);
|
||||
if !quitting {
|
||||
// The Startup script's context: the one that scheduled tasks or holds the windows it
|
||||
// opened (a task scheduled after its dialog closes runs there too).
|
||||
let tasks = s.script_ui.task_hosts.iter().filter(|h| !hosts_before.contains(h)).copied();
|
||||
let windows = s.script_ui.windows.iter().filter(|w| !before.contains(&w.id)).map(|w| w.host);
|
||||
let hosts: Vec<u32> = tasks.chain(windows).collect();
|
||||
for h in hosts {
|
||||
if !s.script_ui.startup_hosts.contains(&h) {
|
||||
s.script_ui.startup_hosts.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut closed = 0;
|
||||
while quitting && closed < MAX_SHUTDOWN_DIALOGS && is_waiting(&r) {
|
||||
let Some(id) = s.script_ui.windows.iter().rev().find(|w| w.modal && w.script == path && !before.contains(&w.id)).map(|w| w.id) else { break };
|
||||
// report_js reports an error the script throws after its dialog closes, as it does for an
|
||||
// error in the script's first run.
|
||||
r = scriptui::close(s, &json!({"window": id})).and_then(|out| super::file_more::report_js(s, &name, out));
|
||||
closed += 1;
|
||||
}
|
||||
match r {
|
||||
Ok(_) if is_waiting(&r) => waiting.push(name),
|
||||
Ok(_) => ran.push(name),
|
||||
Err(e) => {
|
||||
// Also in the log: no frame draws a Shutdown script's toast.
|
||||
log::warn!("{dir}: {e}");
|
||||
failed.push(json!({"name": name, "error": e.to_string()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({"ran": ran, "waiting": waiting, "failed": failed})
|
||||
}
|
||||
|
||||
/// `file.runStartupScripts`: run the scripts in `Scripts/Startup` (frontends call it once at
|
||||
/// launch, before a project opens).
|
||||
fn run_startup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(run_folder(s, STARTUP_DIR, false))
|
||||
}
|
||||
|
||||
/// `file.runShutdownScripts`: close the project, then run the scripts in `Scripts/Shutdown`
|
||||
/// (frontends call it as the app quits, after the unsaved-changes prompt).
|
||||
fn run_shutdown(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.replace_project(effectcraft_project::Project::default(), None);
|
||||
Ok(run_folder(s, SHUTDOWN_DIR, true))
|
||||
}
|
||||
|
||||
/// Window ▸ `<ScriptUI panel>`: run the panel script (or bring its panel forward).
|
||||
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "window.scriptPanel";
|
||||
@@ -414,6 +510,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
restore_panels
|
||||
),
|
||||
cmd!(
|
||||
"file.runStartupScripts",
|
||||
"Run Startup Scripts",
|
||||
[],
|
||||
None,
|
||||
"{} → runs the scripts in Scripts/Startup in alphabetical order (frontends call it at launch) → {ran: [name], waiting: [name] (in a modal dialog), failed: [{name, error}]}",
|
||||
always,
|
||||
run_startup
|
||||
),
|
||||
cmd!(
|
||||
"file.runShutdownScripts",
|
||||
"Run Shutdown Scripts",
|
||||
[],
|
||||
None,
|
||||
"{} → closes the project, then runs the scripts in Scripts/Shutdown in alphabetical order (frontends call it as the app quits; their modal dialogs close as if canceled) → {ran: [name], waiting: [name], failed: [{name, error}]}",
|
||||
always,
|
||||
run_shutdown
|
||||
),
|
||||
crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get),
|
||||
cmd!(
|
||||
"script.settings.save",
|
||||
@@ -463,7 +577,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Mouse Event on Script Window Control",
|
||||
[],
|
||||
None,
|
||||
"{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent",
|
||||
"{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), buttons? (held: 1 left, 2 right, 4 middle), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent",
|
||||
always,
|
||||
scriptui::mouse
|
||||
),
|
||||
@@ -948,4 +1062,64 @@ mod tests {
|
||||
assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Edit ▸ Keyboard Shortcuts lists the scripts File ▸ Scripts lists (not ScriptUI panels),
|
||||
/// as soon as one is installed and until it is uninstalled; a key given to one is saved with
|
||||
/// the preset.
|
||||
#[test]
|
||||
fn file_scripts_entries_take_keyboard_shortcuts() {
|
||||
use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, binding_key};
|
||||
let (mut s, root) = session_in_tmp("shortcuts");
|
||||
let (script, panel) = (root.join("Hello.jsx"), root.join("Hello Panel.jsx"));
|
||||
std::fs::write(&script, "hello").unwrap();
|
||||
std::fs::write(&panel, "panel").unwrap();
|
||||
let listed = |s: &Session, name: &str| {
|
||||
s.shortcuts().find(&binding_key("file.runScript", &json!({"name": name}))).map(|b| (b.label.clone(), b.path.join(" ▸ "), b.defaults.clone()))
|
||||
};
|
||||
assert_eq!(listed(&s, "Rename Layers.jsx"), Some(("Rename Layers.jsx".into(), "File ▸ Scripts".into(), vec![])), "the bundled samples");
|
||||
assert_eq!(listed(&s, "Hello.jsx"), None);
|
||||
s.execute("file.installScript", json!({"path": script.to_string_lossy()})).unwrap();
|
||||
s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap();
|
||||
assert_eq!(listed(&s, "Hello.jsx"), Some(("Hello.jsx".into(), "File ▸ Scripts".into(), vec![])));
|
||||
assert_eq!(listed(&s, "Hello Panel.jsx"), None, "panels are in the Window menu");
|
||||
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Hello.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap();
|
||||
let key = binding_key("file.runScript", &json!({"name": "Hello.jsx"}));
|
||||
assert_eq!((&r["command"], &r["keys"]), (&json!(key), &json!(["Cmd+Alt+Shift+F7"])), "{r}");
|
||||
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"name": "Hello.jsx"})), Some("Cmd+Alt+Shift+F7"));
|
||||
let saved = Keymaps::from_json(&std::fs::read_to_string(root.join("settings").join(SHORTCUTS_FILE)).unwrap());
|
||||
assert_eq!(saved.active_preset().and_then(|p| p.overrides.get(&key)), Some(&vec!["Cmd+Alt+Shift+F7".to_string()]));
|
||||
s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap();
|
||||
assert_eq!(listed(&s, "Hello.jsx"), None);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `shortcuts.list` and `shortcuts.set` see a script copied into the Scripts folder by hand
|
||||
/// after the shortcut table was built; a script the folder doesn't hold (never there, or
|
||||
/// removed by hand) can't take a key, and Run Script File… keeps its own keys.
|
||||
#[test]
|
||||
fn shortcut_commands_find_scripts_copied_into_the_folder() {
|
||||
let (mut s, root) = session_in_tmp("copied");
|
||||
assert!(s.shortcuts().find("file.runScript").is_some(), "the table is built");
|
||||
std::fs::create_dir_all(root.join("settings").join(SCRIPTS_DIR)).unwrap();
|
||||
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx"), "copied").unwrap();
|
||||
let list = s.execute("shortcuts.list", json!({"query": "copied.jsx"})).unwrap();
|
||||
assert_eq!(list["count"], 1, "{list}");
|
||||
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap();
|
||||
assert_eq!(r["command"], r#"file.runScript {"name":"Copied.jsx"}"#, "{r}");
|
||||
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Missing.jsx"}, "keys": "Cmd+Alt+Shift+F8"}));
|
||||
assert!(bad_params(&r), "{r:?}");
|
||||
// The binding-key form finds a script copied in since the last lookup, too.
|
||||
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx"), "second").unwrap();
|
||||
let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F10"})).unwrap();
|
||||
assert_eq!(r["command"], r#"file.runScript {"name":"Second.jsx"}"#, "{r}");
|
||||
// Removed by hand: no longer in the folder.
|
||||
std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx")).unwrap();
|
||||
std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx")).unwrap();
|
||||
let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F11"}));
|
||||
assert!(bad_params(&r), "{r:?}");
|
||||
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F9"}));
|
||||
assert!(bad_params(&r), "{r:?}");
|
||||
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &Value::Null), None, "Run Script File… has no key");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -73,13 +73,21 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
// ---------------------------------------------------------------- shortcuts
|
||||
|
||||
/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`).
|
||||
fn bindable(s: &Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {
|
||||
/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). A
|
||||
/// File ▸ Scripts entry (`file.runScript {"name": …}`) is looked up in File ▸ Scripts' list as it
|
||||
/// is now (the Scripts folder and the bundled scripts), and a script it doesn't list is an error,
|
||||
/// not Run Script File….
|
||||
fn bindable(s: &mut Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {
|
||||
let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?;
|
||||
let params = p.get("params").cloned().unwrap_or(Value::Null);
|
||||
let t = s.shortcuts();
|
||||
let key = binding_key(c, ¶ms);
|
||||
t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)")))
|
||||
let script = (c == "file.runScript" && params.get("name").is_some()) || c.starts_with("file.runScript {");
|
||||
if script {
|
||||
s.scripts_changed();
|
||||
}
|
||||
let t = s.shortcuts();
|
||||
let found = if script { t.find(&key) } else { t.find(&key).or_else(|| t.find(c)) };
|
||||
found.cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)")))
|
||||
}
|
||||
|
||||
fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
@@ -93,6 +101,8 @@ fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
}
|
||||
|
||||
fn sc_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Scripts copied into the Scripts folder since the table was built are listed too.
|
||||
s.scripts_changed();
|
||||
let q = str_p(p, "query").unwrap_or("").to_lowercase();
|
||||
let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false);
|
||||
let keys_q = str_p(p, "keys").and_then(normalize);
|
||||
|
||||
@@ -12,6 +12,7 @@ use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer::{color_p, insert_layer};
|
||||
use super::model3d::{report_switch, use_advanced_3d};
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, layer_mut, layer_p, merge_p, str_p, time_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
@@ -227,12 +228,17 @@ fn new_light(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let name = str_p(p, "name").unwrap_or("Light 1").to_string();
|
||||
let t = time_p(s, p, Some(&comp));
|
||||
let id = s.edit("New Light", None, |proj, st| {
|
||||
// Only the Advanced 3D renderer lights with Environment lights, so adding one switches a
|
||||
// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step).
|
||||
let env = kind == LightKind::Environment;
|
||||
let (id, switched) = s.edit("New Light", None, |proj, st| {
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Light { kind }, (comp.width, comp.height), None);
|
||||
apply_light(&mut l, p, t, "layer.newLight")?;
|
||||
insert_layer(proj, st, cid, l)
|
||||
let id = insert_layer(proj, st, cid, l)?;
|
||||
Ok((id, if env { use_advanced_3d(proj, cid)? } else { false }))
|
||||
})?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
report_switch(s, cid, switched, "which Environment lights need to light the scene");
|
||||
Ok(json!({"layer": id.0, "advanced3d": env}))
|
||||
}
|
||||
|
||||
fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -248,7 +254,7 @@ fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
s.edit("Light Settings", None, |proj, _| {
|
||||
let switched = s.edit("Light Settings", None, |proj, _| {
|
||||
let old = proj.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?;
|
||||
let mut l = old.clone();
|
||||
if let Some(k) = kind
|
||||
@@ -288,9 +294,14 @@ fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
l.name = n.clone();
|
||||
}
|
||||
apply_light(&mut l, p, t, "layer.lightSettings")?;
|
||||
// A light that becomes an Environment light switches a Classic 3D comp to Advanced 3D,
|
||||
// like adding one does; editing one that already is leaves the renderer to the user.
|
||||
let was_env = matches!(old.source, LayerSource::Light { kind: LightKind::Environment });
|
||||
let is_env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment });
|
||||
*layer_mut(proj, cid, lid)? = l;
|
||||
Ok(())
|
||||
Ok(if !was_env && is_env { use_advanced_3d(proj, cid)? } else { false })
|
||||
})?;
|
||||
report_switch(s, cid, switched, "which Environment lights need to light the scene");
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -533,7 +544,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
s.edit("Auto-Orient", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
let rig = l.is_camera() || l.is_light();
|
||||
let ok = match mode {
|
||||
AutoOrient::Off | AutoOrient::AlongPath => true,
|
||||
@@ -683,7 +694,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Light...",
|
||||
["Layer", "New"],
|
||||
Some("Cmd+Alt+Shift+L"),
|
||||
"{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer, see layer.environment), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",
|
||||
"{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer or a flat default environment of its colour and intensity, see layer.environment; an Environment light switches a Classic 3D comp to Advanced 3D), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",
|
||||
has_comp,
|
||||
new_light
|
||||
),
|
||||
@@ -710,7 +721,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Light Settings...",
|
||||
[],
|
||||
None,
|
||||
"{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",
|
||||
"{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment (changing to Environment switches a Classic 3D comp to Advanced 3D), color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",
|
||||
has_comp,
|
||||
light_settings
|
||||
),
|
||||
|
||||
@@ -121,7 +121,7 @@ fn lower_third(p: &mut Project) -> Comp {
|
||||
[440.0, 914.0],
|
||||
);
|
||||
anim(&mut title, "transform/opacity", keys(&[(0.4, Value::Scalar(0.0)), (0.9, Value::Scalar(100.0))]));
|
||||
c.layers = vec![title, bar];
|
||||
c.layers = vec![title, bar].into();
|
||||
c
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp {
|
||||
);
|
||||
anim(&mut caption, "transform/opacity", keys(&[(0.3, Value::Scalar(0.0)), (1.2, Value::Scalar(100.0))]));
|
||||
|
||||
c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor];
|
||||
c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor].into();
|
||||
c
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@ pub fn demo_project() -> Project {
|
||||
lower.out_point = t(9.5);
|
||||
|
||||
// Stack: top first.
|
||||
comp.layers = vec![lower, title, sub, line, burst, rings, null, bg];
|
||||
comp.layers = vec![lower, title, sub, line, burst, rings, null, bg].into();
|
||||
for l in &mut comp.layers {
|
||||
if l.name == "Controller" {
|
||||
l.switches.video = false;
|
||||
|
||||
@@ -1036,6 +1036,8 @@ Animation
|
||||
---
|
||||
Add Keyframe | anim.addKeyframe
|
||||
Toggle Hold Keyframe | keys.toggleHold
|
||||
Mute Keyframes | keys.setMuted
|
||||
Unmute Keyframes | keys.setMuted {"muted":false}
|
||||
Keyframe Interpolation... | keys.interpolation
|
||||
Keyframe Velocity... | keys.velocity
|
||||
Keyframe Assistant
|
||||
|
||||
@@ -686,7 +686,7 @@ impl Session {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = proj.comp_mut(cid) {
|
||||
for l in c.layers.iter_mut().filter(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) {
|
||||
for l in c.layers.iter_mut_where(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) {
|
||||
l.source = LayerSource::Footage { item: fid };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,12 +402,16 @@ pub struct ScriptUi {
|
||||
#[serde(skip)]
|
||||
pub tasks: Option<ScriptTaskRunner>,
|
||||
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
|
||||
/// tasks of scripts without an open window stop then.
|
||||
/// tasks of scripts without an open window stop then, except those of Startup scripts.
|
||||
#[serde(skip)]
|
||||
pub project_epoch: u64,
|
||||
/// The script contexts of this session with scheduled tasks (kept by the scripting engine).
|
||||
#[serde(skip)]
|
||||
pub task_hosts: Vec<u32>,
|
||||
/// The script contexts of Startup scripts: their tasks go on when the project is closed or
|
||||
/// replaced, until EffectCraft quits.
|
||||
#[serde(skip)]
|
||||
pub startup_hosts: Vec<u32>,
|
||||
}
|
||||
|
||||
impl ScriptUi {
|
||||
@@ -755,6 +759,7 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"screenX": num("screenX", -FAR, FAR, 0.0),
|
||||
"screenY": num("screenY", -FAR, FAR, 0.0),
|
||||
"button": num("button", 0.0, 2.0, 0.0).round(),
|
||||
"buttons": num("buttons", 0.0, 7.0, 0.0).round(),
|
||||
"detail": num("detail", 0.0, 1000.0, clicks).round(),
|
||||
"altKey": flag("altKey"),
|
||||
"ctrlKey": flag("ctrlKey"),
|
||||
|
||||
@@ -96,7 +96,7 @@ impl Session {
|
||||
effectcraft_project::ItemKind::Footage(f) if f.has_video => {
|
||||
let f = f.clone();
|
||||
Some(Box::new(move || {
|
||||
let img = footage.frame(item, &f, Tick(0))?;
|
||||
let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f);
|
||||
// Point-sample down to at most `max_side` on the long side.
|
||||
let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32;
|
||||
let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step));
|
||||
|
||||
@@ -8,6 +8,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[
|
||||
("Deutsch", "de"),
|
||||
("Español", "es"),
|
||||
("Français", "fr"),
|
||||
("Polski", "pl"),
|
||||
("Português (Brasil)", "pt-br"),
|
||||
("Русский", "ru"),
|
||||
("Українська", "uk"),
|
||||
|
||||
@@ -195,9 +195,19 @@ impl Session {
|
||||
self.shortcut_table = std::sync::OnceLock::new();
|
||||
}
|
||||
|
||||
/// The active shortcut preset resolved against every bindable command.
|
||||
/// The scripts in the Scripts folder changed: list File ▸ Scripts' entries again in the
|
||||
/// shortcut table.
|
||||
pub fn scripts_changed(&mut self) {
|
||||
self.shortcut_table = std::sync::OnceLock::new();
|
||||
}
|
||||
|
||||
/// The active shortcut preset resolved against every bindable command, File ▸ Scripts'
|
||||
/// entries included.
|
||||
pub fn shortcuts(&self) -> &ShortcutTable {
|
||||
self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands))
|
||||
self.shortcut_table.get_or_init(|| {
|
||||
let scripts: Vec<String> = crate::commands::scripts::scripts(self).into_iter().filter(|e| !e.panel).map(|e| e.name).collect();
|
||||
ShortcutTable::build_with_scripts(&self.keymaps, &self.ui_commands, &scripts)
|
||||
})
|
||||
}
|
||||
|
||||
/// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts).
|
||||
//!
|
||||
//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key
|
||||
//! Every menu entry, registry command and frontend command, and every script File ▸ Scripts
|
||||
//! lists, is *bindable*: it has a binding key
|
||||
//! (the command id, plus its bound parameters for entries such as `app.settings
|
||||
//! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in
|
||||
//! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom
|
||||
@@ -157,6 +158,27 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The File ▸ Scripts entries of the scripts `names` (file names, as the menu lists them), with
|
||||
/// no default keys. A key is bound to the script's name, so it stays with that script when
|
||||
/// scripts are added to or removed from the folder.
|
||||
pub fn script_bindables(names: &[String]) -> Vec<Bindable> {
|
||||
names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let params = json!({"name": n});
|
||||
Bindable {
|
||||
key: binding_key("file.runScript", ¶ms),
|
||||
command: "file.runScript".into(),
|
||||
params,
|
||||
label: n.clone(),
|
||||
path: vec!["File".into(), "Scripts".into()],
|
||||
scope: APP_SCOPE.into(),
|
||||
defaults: vec![],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it
|
||||
/// has no key.
|
||||
pub fn normalize(s: &str) -> Option<String> {
|
||||
@@ -436,7 +458,16 @@ pub struct ShortcutTable {
|
||||
|
||||
impl ShortcutTable {
|
||||
pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable {
|
||||
let bindables = bindables(ui);
|
||||
Self::build_with_scripts(maps, ui, &[])
|
||||
}
|
||||
|
||||
/// [`ShortcutTable::build`] with the File ▸ Scripts entries of the scripts `scripts`, listed
|
||||
/// after the other File ▸ Scripts entries.
|
||||
pub fn build_with_scripts(maps: &Keymaps, ui: &[UiCommand], scripts: &[String]) -> ShortcutTable {
|
||||
let mut bindables = bindables(ui);
|
||||
let at = bindables.iter().rposition(|b| b.path.starts_with(&["File".to_string(), "Scripts".to_string()])).map_or(bindables.len(), |i| i + 1);
|
||||
let new: Vec<Bindable> = script_bindables(scripts).into_iter().filter(|s| !bindables.iter().any(|b| b.key == s.key)).collect();
|
||||
bindables.splice(at..at, new);
|
||||
let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect();
|
||||
ShortcutTable { bindables, keys }
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ pub fn large_project(spec: &LargeSpec) -> Project {
|
||||
*n += 1;
|
||||
layers.push(l);
|
||||
}
|
||||
comp.layers = layers;
|
||||
comp.layers = layers.into();
|
||||
};
|
||||
let others = spec.comps.saturating_sub(1);
|
||||
let depth = spec.nest_depth.min(others);
|
||||
@@ -215,7 +215,7 @@ pub fn large_project(spec: &LargeSpec) -> Project {
|
||||
n += 1;
|
||||
layers.push(l);
|
||||
}
|
||||
main.layers = layers;
|
||||
main.layers = layers.into();
|
||||
p.add_item("Main", crate::color::Label::None, None, ItemKind::Comp(Arc::new(main)));
|
||||
// `Main` must be the first comp `replace_project` opens: give it the smallest id.
|
||||
let main_id = p.items.values().find(|i| i.name == "Main").map(|i| i.id).expect("main");
|
||||
|
||||
@@ -41,6 +41,7 @@ pub(crate) mod roto;
|
||||
mod sequence;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod shared_layers;
|
||||
mod shortcut_cmds;
|
||||
pub(crate) mod stubs;
|
||||
mod styles;
|
||||
|
||||
@@ -550,7 +550,7 @@ fn opening_an_old_project_upgrades_effect_instances() {
|
||||
effectcraft_project::ItemKind::Comp(c) => c,
|
||||
_ => panic!("not a composition"),
|
||||
});
|
||||
let l = comp.layers.iter_mut().find(|l| l.id == LayerId(a)).unwrap();
|
||||
let l = comp.layers.find_mut(|l| l.id == LayerId(a)).unwrap();
|
||||
for n in &mut l.props.sub_mut("effects").unwrap().children {
|
||||
let Node::Group(g) = n else { continue };
|
||||
if g.match_id == "ec.blur.gaussian" {
|
||||
|
||||
@@ -1483,6 +1483,68 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn muting_keys_skips_them_in_the_timeline_value_and_undoes() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 100.0), (2.0, 300.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let value_at = |s: &Session, t: f64| layer(s, a).props.prop("transform/opacity").unwrap().value_at(Tick::from_seconds_f64(t)).as_f64();
|
||||
let select = |s: &mut Session| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
select(&mut s);
|
||||
let before = value_at(&s, 0.5);
|
||||
s.execute("keys.setMuted", json!({"muted": true})).unwrap();
|
||||
assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
|
||||
// The muted key no longer bends the 0 → 300 segment.
|
||||
assert!((value_at(&s, 0.5) - 75.0).abs() < 1e-6);
|
||||
assert!((value_at(&s, 1.5) - 225.0).abs() < 1e-6);
|
||||
s.undo();
|
||||
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
|
||||
assert!((value_at(&s, 0.5) - before).abs() < 1e-6);
|
||||
// The same command unmutes.
|
||||
select(&mut s);
|
||||
s.execute("keys.setMuted", json!({"muted": true})).unwrap();
|
||||
s.execute("keys.setMuted", json!({"muted": false})).unwrap();
|
||||
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
|
||||
}
|
||||
|
||||
/// Both Animation-menu rows mute and unmute through `Session::execute`, and undo/redo work.
|
||||
#[test]
|
||||
fn mute_and_unmute_keyframes_menu_rows_work_and_undo() {
|
||||
fn rows(nodes: &[crate::menus::MenuNode], out: &mut Vec<(String, Value)>) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
crate::menus::MenuNode::Item(e) if e.command == "keys.setMuted" => out.push((e.label.clone(), e.params.clone())),
|
||||
crate::menus::MenuNode::Submenu { children, .. } => rows(children, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut menu = vec![];
|
||||
rows(&crate::menus::parse(crate::menus::TREE, false).unwrap(), &mut menu);
|
||||
assert_eq!(menu.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(), ["Mute Keyframes", "Unmute Keyframes"], "{menu:?}");
|
||||
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let muted = |s: &Session| layer(s, a).props.prop("transform/opacity").unwrap().keys[1].muted;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
let run = |s: &mut Session, params: &Value| {
|
||||
s.execute("keys.setMuted", if params.is_null() { json!({}) } else { params.clone() }).unwrap();
|
||||
};
|
||||
run(&mut s, &menu[0].1);
|
||||
assert!(muted(&s));
|
||||
s.undo();
|
||||
assert!(!muted(&s));
|
||||
s.redo();
|
||||
assert!(muted(&s));
|
||||
run(&mut s, &menu[1].1);
|
||||
assert!(!muted(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() {
|
||||
let mut s = comp();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and
|
||||
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through
|
||||
//! a synthetic footage source.
|
||||
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light, Cineon Settings),
|
||||
//! rendered through a synthetic footage source.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -36,6 +36,12 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
|
||||
"matte_black.png" => [0.0, 0.5, 0.0, 0.5],
|
||||
"matte_white.png" => [0.5, 1.0, 0.5, 0.5],
|
||||
"half.png" => [1.0, 1.0, 1.0, 0.25],
|
||||
// A log film scan: code values 95, 685, 400 and 1023 across, as the DPX reader hands
|
||||
// them out (code / 1023).
|
||||
"scan.dpx" | "plate.dpx" => {
|
||||
let v = [95.0, 685.0, 400.0, 1023.0][x.min(3) as usize] / 1023.0;
|
||||
[v, v, v, 1.0]
|
||||
}
|
||||
_ => {
|
||||
let _ = x;
|
||||
[1.0, 1.0, 1.0, 1.0]
|
||||
@@ -47,7 +53,7 @@ fn size_of(path: &str) -> (u32, u32) {
|
||||
match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") {
|
||||
"red16.png" => (16, 16),
|
||||
"blue8.png" => (8, 8),
|
||||
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4),
|
||||
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" | "scan.dpx" | "plate.dpx" => (4, 4),
|
||||
_ => (10, 10),
|
||||
}
|
||||
}
|
||||
@@ -64,7 +70,12 @@ impl FootageSource for Synthetic {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Decoders hand out premultiplied pixels (straight file colour × alpha).
|
||||
let p = file_px(&f.path, x, y);
|
||||
let mut p = file_px(&f.path, x, y);
|
||||
// A log scan sequence: code 95 on even frames, 685 on odd ones.
|
||||
if f.path.ends_with("frames.dpx") {
|
||||
let v = if f.frame_rate.frame_at(t) % 2 == 0 { 95.0 } else { 685.0 } / 1023.0;
|
||||
p = [v, v, v, 1.0];
|
||||
}
|
||||
img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]);
|
||||
}
|
||||
}
|
||||
@@ -76,7 +87,7 @@ struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
let (w, h) = size_of(path);
|
||||
let video = path.ends_with(".mov");
|
||||
let video = path.ends_with(".mov") || path.ends_with("frames.dpx");
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: if video { FootageKind::Video } else { FootageKind::Still },
|
||||
@@ -431,3 +442,125 @@ fn guess_alpha_invert_and_linear_light() {
|
||||
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
|
||||
assert!(foot(&s, w).preserve_rgb);
|
||||
}
|
||||
|
||||
/// Color ▸ Cineon Settings: Logarithmic Conversion turns a DPX file's log code values into the
|
||||
/// Current Gamma between the converted points (off, they show as stored); the converted points
|
||||
/// are read and reported in the chosen Units; out-of-range values and footage that is not DPX
|
||||
/// or Cineon are refused; Remember / Apply Interpretation and undo carry the settings.
|
||||
#[test]
|
||||
fn cineon_settings_convert_dpx_code_values() {
|
||||
let mut s = session();
|
||||
let d = import(&mut s, "/m/scan.dpx");
|
||||
let other = import(&mut s, "/m/plate.dpx");
|
||||
let png = import(&mut s, "/m/red16.png");
|
||||
let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => panic!("not footage"),
|
||||
};
|
||||
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": d.0})).unwrap();
|
||||
let at = |s: &Session, x: i64| px(s, c, 0.0, RenderOpts::default(), x, 0)[0];
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0;
|
||||
// Off: the code values as stored.
|
||||
assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2));
|
||||
// The checked path (control channel, MCP) accepts every Cineon parameter the doc lists.
|
||||
let out = s.execute_checked("file.interpretFootage", json!({"items": [d.0], "logConversion": true, "highlightRolloff": 0})).unwrap();
|
||||
assert_eq!(out["items"][0]["cineon"]["logConversion"], true, "{out}");
|
||||
assert!(near(at(&s, 0), 0.0) && near(at(&s, 1), 1.0), "{} {}", at(&s, 0), at(&s, 1));
|
||||
assert!(near(at(&s, 2), 0.26190), "code 400 at gamma 1.7: {}", at(&s, 2));
|
||||
s.execute("file.interpretFootage", json!({"item": d.0, "currentGamma": 1})).unwrap();
|
||||
assert!(near(at(&s, 2), 0.10251), "code 400 in linear light: {}", at(&s, 2));
|
||||
// Converted points, in 8-bit units.
|
||||
let out = s
|
||||
.execute_checked("file.interpretFootage", json!({"items": [d.0], "cineonUnits": "8bit", "convertedBlack": 16, "convertedWhite": 235, "currentGamma": 1, "tenBitBlack": 95, "tenBitWhite": 685}))
|
||||
.unwrap();
|
||||
assert_eq!(out["items"][0]["cineon"]["units"], "8bit");
|
||||
assert!((out["items"][0]["cineon"]["convertedWhite"].as_f64().unwrap() - 235.0).abs() < 1e-3, "{out}");
|
||||
assert!(near(at(&s, 0), 16.0 / 255.0) && near(at(&s, 1), 235.0 / 255.0), "{} {}", at(&s, 0), at(&s, 1));
|
||||
// The form sends the points in the units it showed them in (`pointUnits`) with a new Units.
|
||||
s.execute_checked("file.interpretFootage", json!({"items": [d.0], "pointUnits": "8bit", "cineonUnits": "float", "convertedWhite": 255})).unwrap();
|
||||
let f = foot(&s, d);
|
||||
assert_eq!(f.cineon.units, effectcraft_project::CineonUnits::Float);
|
||||
assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon);
|
||||
// Refused: a black point at or above the white point, values out of range, and footage
|
||||
// that is not DPX or Cineon. Nothing changes.
|
||||
let before = foot(&s, d).cineon;
|
||||
for bad in [
|
||||
json!({"item": d.0, "tenBitBlack": 700}),
|
||||
json!({"item": d.0, "tenBitWhite": 2000}),
|
||||
json!({"item": d.0, "currentGamma": 0}),
|
||||
json!({"item": d.0, "highlightRolloff": -1}),
|
||||
json!({"item": d.0, "convertedWhite": 1.5}),
|
||||
json!({"item": d.0, "cineonUnits": "12bit"}),
|
||||
json!({"item": png.0, "logConversion": true}),
|
||||
] {
|
||||
assert!(s.execute("file.interpretFootage", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(foot(&s, d).cineon, before);
|
||||
assert!(foot(&s, png).cineon == effectcraft_project::CineonSettings::default());
|
||||
// With DPX and other footage selected, only the DPX file takes the settings.
|
||||
s.execute("file.interpretFootage", json!({"items": [png.0, other.0], "logConversion": true})).unwrap();
|
||||
assert!(foot(&s, other).cineon.log_conversion && !foot(&s, png).cineon.log_conversion);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!foot(&s, other).cineon.log_conversion);
|
||||
// Remember / Apply Interpretation.
|
||||
s.state.project_selection = vec![d];
|
||||
s.execute("file.rememberInterpretation", json!({"item": d.0})).unwrap();
|
||||
s.execute("file.applyInterpretation", json!({"items": [other.0, png.0]})).unwrap();
|
||||
assert_eq!(foot(&s, other).cineon, before);
|
||||
assert_eq!(foot(&s, png).cineon, effectcraft_project::CineonSettings::default());
|
||||
// Off again: as stored.
|
||||
s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": false})).unwrap();
|
||||
assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2));
|
||||
// Frame Mix blends converted frames: halfway between code 95 (black) and 685 (white) at
|
||||
// Current Gamma 1 is 0.5, not the conversion of the blended code values.
|
||||
let seq = import(&mut s, "/m/frames.dpx");
|
||||
s.execute("file.interpretFootage", json!({"item": seq.0, "logConversion": true, "currentGamma": 1, "highlightRolloff": 0})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Blend", "width": 10, "height": 10, "frameRate": 20, "duration": 1})).unwrap();
|
||||
let bc = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": seq.0})).unwrap();
|
||||
s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap();
|
||||
let mid = px(&s, bc, 0.05, RenderOpts::default(), 5, 5)[0];
|
||||
assert!(near(mid, 0.5), "frame-mixed log frames: {mid}");
|
||||
// A DPX proxy of a composition: Interpret Proxy's settings convert it where it stands in.
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/scan.dpx"})).unwrap();
|
||||
let proxied = |s: &Session, x: i64| px(s, outer, 0.0, RenderOpts::default(), x, 0)[0];
|
||||
assert!(near(proxied(&s, 1), 685.0 / 1023.0), "{}", proxied(&s, 1));
|
||||
s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap();
|
||||
assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1));
|
||||
}
|
||||
|
||||
/// The Footage panel, the Project panel thumbnail and the tracker's source frames show DPX
|
||||
/// footage with its Cineon Settings, as a composition renders it.
|
||||
#[test]
|
||||
fn cineon_settings_apply_to_footage_panel_thumbnail_and_tracking() {
|
||||
let mut s = session();
|
||||
let d = import(&mut s, "/m/scan.dpx");
|
||||
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": d.0})).unwrap();
|
||||
s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": true, "highlightRolloff": 0})).unwrap();
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0;
|
||||
let rendered = px(&s, c, 0.0, RenderOpts::default(), 2, 0)[0];
|
||||
assert!(near(rendered, 0.26190), "code 400 at gamma 1.7: {rendered}");
|
||||
let f = match &s.project.item(d).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => panic!("not footage"),
|
||||
};
|
||||
let panel = crate::commands::footage_panel::frame(&s, d, &f, Tick::ZERO).unwrap().get(2, 0)[0];
|
||||
assert!(near(panel, rendered), "Footage panel: {panel}");
|
||||
let (w, _, rgba) = (s.thumbnail_job(d, Tick::ZERO, 64).unwrap())().unwrap();
|
||||
assert_eq!(w, 4);
|
||||
let thumb = rgba[8] as f32 / 255.0;
|
||||
assert!(near(thumb, rendered), "Project panel thumbnail: {thumb}");
|
||||
let comp = s.project.comp(c).unwrap();
|
||||
let r = crate::render::Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default());
|
||||
let (img, _) = crate::tracking::source_frame(&r, &s.project, c, comp, &comp.layers[0], Tick::ZERO, None).unwrap();
|
||||
assert!(near(img.get(2, 0)[0], rendered), "tracker source frame: {}", img.get(2, 0)[0]);
|
||||
}
|
||||
@@ -86,7 +86,7 @@ fn imported_files_go_into_the_comp_where_they_were_dropped() {
|
||||
s.execute("file.import", params).unwrap();
|
||||
let comp = s.active_comp().unwrap();
|
||||
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["a.png", "b.png", "Bg"]);
|
||||
for l in &comp.layers[..2] {
|
||||
for l in comp.layers.iter().take(2) {
|
||||
assert_eq!(l.props.prop("transform/position").map(|p| p.value.clone()), Some(effectcraft_keyframe::Value::Vec3([40.0, 50.0, 0.0])));
|
||||
assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0));
|
||||
}
|
||||
@@ -159,6 +159,46 @@ fn background_imports_report_progress_and_select_what_arrived() {
|
||||
assert_eq!(r["items"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
/// #2573: New Composition From Footage (`file.import {newComp}`) makes one composition of what
|
||||
/// the import made, first file on top, at once or when the background job lands; an import that
|
||||
/// made nothing makes no comp.
|
||||
#[test]
|
||||
fn new_comp_from_footage_makes_a_comp_of_what_was_imported() {
|
||||
use effectcraft_project::{Footage, FootageKind, ItemKind};
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if path.ends_with(".xyz") {
|
||||
return Err("unsupported file".into());
|
||||
}
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() };
|
||||
let comps = |s: &Session| s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count();
|
||||
let layers = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
|
||||
// At once.
|
||||
s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.xyz"], "newComp": true})).unwrap();
|
||||
assert_eq!(comps(&s), 1);
|
||||
assert_eq!(layers(&s), ["a.png"]);
|
||||
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, 32));
|
||||
// In the background: the job's own items, whatever is selected or active meanwhile; it takes
|
||||
// precedence over `addToComp`.
|
||||
s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.png"], "background": true, "newComp": true, "addToComp": true})).unwrap();
|
||||
s.state.project_selection.clear();
|
||||
s.execute("jobs.wait", json!({})).unwrap();
|
||||
assert_eq!(comps(&s), 2);
|
||||
assert_eq!(layers(&s), ["c.png", "d.png"]);
|
||||
// Nothing imported: no comp, and the failure is reported.
|
||||
s.execute("file.import", json!({"paths": ["/drop/e.xyz"], "background": true, "newComp": true})).unwrap();
|
||||
s.execute("jobs.wait", json!({})).unwrap();
|
||||
assert_eq!(comps(&s), 2);
|
||||
assert!(s.job_log.last().unwrap().message.contains("unsupported file"));
|
||||
let r = s.execute("file.import", json!({"paths": ["/drop/f.xyz"], "newComp": true})).unwrap();
|
||||
assert_eq!(comps(&s), 2);
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
/// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103).
|
||||
#[test]
|
||||
fn audio_lock_and_shy_keep_cached_layers() {
|
||||
|
||||
@@ -1089,6 +1089,14 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
|
||||
cs.load_settings();
|
||||
assert_eq!(cs.prefs.general.language, "cs");
|
||||
assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs");
|
||||
// Polish, likewise.
|
||||
assert!(crate::prefs::LANGUAGES.contains(&("Polski", "pl")));
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "pl"})).unwrap();
|
||||
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pl"));
|
||||
let mut pl = Session { config: Some(store.clone()), ..Default::default() };
|
||||
pl.load_settings();
|
||||
assert_eq!(pl.prefs.general.language, "pl");
|
||||
assert_eq!(Prefs::from_json(r#"{"general":{"language":"pl"}}"#).general.language, "pl");
|
||||
// Georgian too.
|
||||
assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka")));
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! An edit copies only the layer it changes: the others stay shared with the undo state, so an
|
||||
//! edit's cost doesn't grow with the size of the comp (scripts setting many keys went quadratic).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn a_key_on_one_layer_leaves_the_other_layers_shared_with_undo() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Big", "duration": 10, "frameRate": 30})).unwrap();
|
||||
let ids: Vec<u64> = (0..20).map(|i| s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap()["layer"].as_u64().unwrap()).collect();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let before = s.project.clone();
|
||||
s.execute("prop.addKey", json!({"layer": ids[7], "path": "transform/position", "time": 1.0, "value": [5, 5]})).unwrap();
|
||||
let (old, new) = (before.comp(cid).unwrap(), s.project.comp(cid).unwrap());
|
||||
assert_eq!(old.layers.len(), new.layers.len());
|
||||
let edited = new.layers.iter().position(|l| l.id.0 == ids[7]).unwrap();
|
||||
for i in 0..new.layers.len() {
|
||||
assert_eq!(new.layers.same_at(&old.layers, i), i != edited, "layer {i} shared: {}", new.layers.same_at(&old.layers, i));
|
||||
}
|
||||
// Undo brings back the very same state, and the edited layer is the only one that differed.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(Arc::ptr_eq(&s.project, &before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_save_and_load_as_a_plain_list() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Round trip", "duration": 5, "frameRate": 25})).unwrap();
|
||||
for i in 0..3 {
|
||||
s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap();
|
||||
}
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let text = serde_json::to_value(&comp.layers).unwrap();
|
||||
assert_eq!(text.as_array().map(Vec::len), Some(3), "a JSON array of layers, as before");
|
||||
let back: effectcraft_project::Layers = serde_json::from_value(text).unwrap();
|
||||
assert_eq!(&back, &comp.layers);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 3D commands: cameras, lights, views, camera tools (with undo/redo), and the 3D demo comp.
|
||||
|
||||
use effectcraft_project::{AutoOrient, LayerSource, LightKind};
|
||||
use effectcraft_project::{AutoOrient, LayerSource, LightKind, Renderer};
|
||||
use effectcraft_render::three_d::View3D;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -148,6 +148,85 @@ fn deleting_a_source_layer_clears_the_reference() {
|
||||
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some());
|
||||
}
|
||||
|
||||
/// #2436: only the Advanced 3D renderer lights with Environment lights, and new comps are
|
||||
/// Classic 3D, so one had no visible effect. Adding one (or changing a light into one) switches
|
||||
/// the comp to Advanced 3D (in the same undo step, with a toast), as After Effects does.
|
||||
#[test]
|
||||
fn environment_lights_switch_classic_comps_to_advanced_3d() {
|
||||
let toasts = |s: &mut Session| -> Vec<String> {
|
||||
s.drain_events().into_iter().filter_map(|e| if let crate::Event::Toast { message, .. } = e { Some(message) } else { None }).collect()
|
||||
};
|
||||
let mut s = session();
|
||||
let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
|
||||
toasts(&mut s);
|
||||
let r = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap();
|
||||
assert_eq!(r["advanced3d"], true);
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
|
||||
// As with Composition Settings: the text layer gets Geometry Options.
|
||||
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).unwrap().props.sub("geometryOptions").is_some());
|
||||
assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D")));
|
||||
// A second Environment light leaves the comp (and the user) alone.
|
||||
s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap();
|
||||
assert!(toasts(&mut s).is_empty());
|
||||
// Undoing the creations takes the lights and the switch back together.
|
||||
s.undo();
|
||||
s.undo();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
|
||||
assert_eq!(layer_count(&s), 1);
|
||||
// Changing a light to Environment switches too, and undoes with the type.
|
||||
let spot = s.execute("layer.newLight", json!({"kind": "Spot"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
|
||||
s.execute("layer.lightSettings", json!({"layer": spot, "kind": "Environment"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
|
||||
s.undo();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
|
||||
assert_eq!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(spot)).unwrap().source, LayerSource::Light { kind: LightKind::Spot });
|
||||
// Other light types never switch it.
|
||||
s.execute("layer.newLight", json!({"kind": "Point"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
|
||||
// Switching back to Classic 3D with an Environment light in the comp explains itself.
|
||||
let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();
|
||||
toasts(&mut s);
|
||||
s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap();
|
||||
assert!(toasts(&mut s).iter().any(|m| m.contains("Environment light")), "the Classic 3D limit is explained");
|
||||
// Editing that Environment light (no type change) leaves the renderer the user chose.
|
||||
s.execute("layer.lightSettings", json!({"layer": env, "intensity": 80})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D, "an ordinary edit doesn't re-switch");
|
||||
assert!(toasts(&mut s).is_empty(), "and doesn't toast either");
|
||||
}
|
||||
|
||||
/// An Environment light takes Casts Shadows (with Shadow Darkness and Diffusion) like every
|
||||
/// other light — the Light Settings dialog offers them for all types but Ambient — and keeps no
|
||||
/// Falloff, which an environment light shining from every direction has no use for (#2436).
|
||||
#[test]
|
||||
fn environment_light_takes_shadow_options_but_no_falloff() {
|
||||
let mut s = session();
|
||||
let env = s.execute("layer.newLight", json!({"kind": "Environment", "castsShadows": true, "shadowDarkness": 60, "shadowDiffusion": 12})).unwrap()["layer"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());
|
||||
assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0);
|
||||
assert_eq!(prop(&s, env, "lightOptions/shadowDiffusion").as_f64(), 12.0);
|
||||
assert!(
|
||||
s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/falloff").is_none(),
|
||||
"an environment light has no Falloff"
|
||||
);
|
||||
// The shadow values carry over when the type changes, both ways.
|
||||
s.execute("layer.lightSettings", json!({"layer": env, "kind": "Spot", "coneAngle": 70})).unwrap();
|
||||
assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());
|
||||
assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0);
|
||||
assert_eq!(prop(&s, env, "lightOptions/coneAngle").as_f64(), 70.0);
|
||||
s.execute("layer.lightSettings", json!({"layer": env, "kind": "Environment"})).unwrap();
|
||||
assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());
|
||||
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "back to Environment: it has a Source again");
|
||||
s.undo();
|
||||
assert!(
|
||||
s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/coneAngle").is_some(),
|
||||
"back to Spot: it has a Cone Angle again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_switch_undo_redo() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -160,6 +160,37 @@ fn psd_imports_as_composition() {
|
||||
assert!(s.project.comp(cid).is_none());
|
||||
}
|
||||
|
||||
/// #2573: New Composition From Footage on a Photoshop file imported as a composition opens that
|
||||
/// composition; it isn't wrapped in another.
|
||||
#[test]
|
||||
fn psd_imported_as_composition_with_new_comp_is_the_new_comp() {
|
||||
let path = tmp("newcomp.psd");
|
||||
std::fs::write(&path, sample_psd()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "newComp": true})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
|
||||
assert_eq!(s.state.active_comp, Some(cid));
|
||||
let roots = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_)) && i.name.starts_with("newcomp")).count();
|
||||
assert_eq!(roots, 1, "no wrapper comp");
|
||||
}
|
||||
|
||||
/// A Photoshop file imported as a composition and dropped on the Timeline (`addToComp`) adds
|
||||
/// its composition once, not its folder too.
|
||||
#[test]
|
||||
fn psd_imported_as_composition_into_a_comp_adds_one_layer() {
|
||||
let path = tmp("dropped.psd");
|
||||
std::fs::write(&path, sample_psd()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "addToComp": true})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let psd = r["comps"][0].as_u64().unwrap();
|
||||
let layers = &s.project.comp(effectcraft_project::ItemId(main)).unwrap().layers;
|
||||
assert_eq!(layers.len(), 1, "{layers:?}");
|
||||
assert!(matches!(layers[0].source, LayerSource::Comp { item } if item.0 == psd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_retain_layer_sizes() {
|
||||
let path = tmp("retain.psd");
|
||||
|
||||
Reference in new issue
Block a user