mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 05:58:19 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
use super::item::{ItemId, LayerId};
|
||||
use super::settings::Marker;
|
||||
use super::{BlendMode, Deserialize, Label, PropGroup, Serialize, Tick};
|
||||
use super::{BlendMode, Deserialize, Label, ParamUi, PropGroup, Property, Serialize, Tick};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LightKind {
|
||||
@@ -357,6 +357,20 @@ impl Layer {
|
||||
self.props.sub("masks")
|
||||
}
|
||||
|
||||
/// The labels popup property `p` of this layer offers: Text Path Options ▸ Path lists None and
|
||||
/// the layer's masks (value k = mask k), every other popup its own options. `None` when `p` is
|
||||
/// not a popup.
|
||||
pub fn popup_options<'a>(&'a self, p: &'a Property) -> Option<std::borrow::Cow<'a, [String]>> {
|
||||
if self.props.prop("text/pathOptions/path").is_some_and(|q| q.uid == p.uid) {
|
||||
let masks = self.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()));
|
||||
return Some(std::iter::once("None".to_string()).chain(masks).collect::<Vec<_>>().into());
|
||||
}
|
||||
match &p.ui {
|
||||
ParamUi::Popup { options } => Some(options.as_slice().into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_camera(&self) -> bool {
|
||||
matches!(self.source, LayerSource::Camera)
|
||||
}
|
||||
|
||||
@@ -1477,22 +1477,56 @@ pub fn expand_template(template: &str, format: OutputFormat, v: &TemplateVars) -
|
||||
/// Replace the first run of `#` (optionally in brackets, `[####]`) with the zero-padded frame
|
||||
/// number. Paths without a run get `_NNNNN` before the extension.
|
||||
pub fn sequence_path(path: &str, frame: i64) -> String {
|
||||
let (before, width, after) = sequence_parts(path);
|
||||
format!("{before}{frame:0width$}{after}")
|
||||
}
|
||||
|
||||
/// A sequence output path around its frame number: what comes before it, how many digits it is
|
||||
/// padded to and what comes after (`out/a_[#####].png` → `out/a_`, 5, `.png`).
|
||||
pub fn sequence_parts(path: &str) -> (String, usize, String) {
|
||||
if let Some(start) = path.find('#') {
|
||||
let len = path[start..].chars().take_while(|c| *c == '#').count();
|
||||
let (mut a, mut b) = (start, start + len);
|
||||
if a > 0 && path.as_bytes()[a - 1] == b'[' && path.as_bytes().get(b) == Some(&b']') {
|
||||
if a > 0 && path.as_bytes().get(a - 1) == Some(&b'[') && path.as_bytes().get(b) == Some(&b']') {
|
||||
a -= 1;
|
||||
b += 1;
|
||||
}
|
||||
return format!("{}{:0width$}{}", &path[..a], frame, &path[b..], width = len);
|
||||
return (path[..a].to_string(), len, path[b..].to_string());
|
||||
}
|
||||
let p = std::path::Path::new(path);
|
||||
match p.extension() {
|
||||
Some(ext) => format!("{}_{frame:05}.{}", p.with_extension("").to_string_lossy(), ext.to_string_lossy()),
|
||||
None => format!("{path}_{frame:05}"),
|
||||
Some(ext) => (format!("{}_", p.with_extension("").to_string_lossy()), 5, format!(".{}", ext.to_string_lossy())),
|
||||
None => (format!("{path}_"), 5, String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame number `candidate` is the file of, when `path` (a sequence output path) writes it:
|
||||
/// the inverse of [`sequence_path`].
|
||||
pub fn sequence_frame(path: &str, candidate: &str) -> Option<i64> {
|
||||
let (before, width, after) = sequence_parts(path);
|
||||
let digits = candidate.strip_prefix(before.as_str())?.strip_suffix(after.as_str())?;
|
||||
let frame: i64 = digits.parse().ok()?;
|
||||
(format!("{frame:0width$}") == digits).then_some(frame)
|
||||
}
|
||||
|
||||
/// `template` with `_n` added to the name: before the frame-number run of a sequence, else before
|
||||
/// the extension (`[compName].[fileExtension]` → `[compName]_1.[fileExtension]`,
|
||||
/// `[compName]_[#####].[fileExtension]` → `[compName]_1_[#####].[fileExtension]`).
|
||||
pub fn numbered_template(template: &str, n: u32) -> String {
|
||||
let at = match template.find('#') {
|
||||
Some(hash) => {
|
||||
// The run's `[`, then the separator that sets it off from the name.
|
||||
let run = if template[..hash].ends_with('[') { hash - 1 } else { hash };
|
||||
if template[..run].ends_with(['_', '-', '.', ' ']) { run - 1 } else { run }
|
||||
}
|
||||
None => {
|
||||
let name = template.rfind(['/', '\\']).map_or(0, |i| i + 1);
|
||||
template[name..].rfind('.').map_or(template.len(), |i| name + i)
|
||||
}
|
||||
};
|
||||
format!("{}_{n}{}", &template[..at], &template[at..])
|
||||
}
|
||||
|
||||
/// Make a name safe as a file name component.
|
||||
pub fn sanitize(name: &str) -> String {
|
||||
name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect()
|
||||
@@ -1512,6 +1546,44 @@ mod tests {
|
||||
assert_eq!(sequence_path("a.png", 3), "a_00003.png");
|
||||
}
|
||||
|
||||
/// `sequence_frame` reads back what `sequence_path` writes, and nothing else.
|
||||
#[test]
|
||||
fn sequence_frames_read_back() {
|
||||
for path in ["out/a_[#####].png", "a###.tif", "a.png", "/x/[compName]/shot_[##].exr", "noext", "dir.v2/clip"] {
|
||||
for frame in [0, 7, 42, 99_999, 123_456, -3] {
|
||||
assert_eq!(sequence_frame(path, &sequence_path(path, frame)), Some(frame), "{path} {frame}");
|
||||
}
|
||||
}
|
||||
let (before, width, after) = sequence_parts("out/a_[#####].png");
|
||||
assert_eq!((before.as_str(), width, after.as_str()), ("out/a_", 5, ".png"));
|
||||
let (before, width, after) = sequence_parts("a.png");
|
||||
assert_eq!((before.as_str(), width, after.as_str()), ("a_", 5, ".png"));
|
||||
// Other numbers, other widths, other names are not frames of the sequence.
|
||||
assert_eq!(sequence_frame("a_[#####].png", "a_0042.png"), None, "padded to four");
|
||||
assert_eq!(sequence_frame("a_[####].png", "a_+042.png"), None, "a sign is not padding");
|
||||
assert_eq!(sequence_frame("a_[#####].png", "a_00042.jpg"), None);
|
||||
assert_eq!(sequence_frame("a_[#####].png", "b_00042.png"), None);
|
||||
assert_eq!(sequence_frame("a_[#####].png", "a_00x42.png"), None);
|
||||
assert_eq!(sequence_frame("a_[#####].png", "a_.png"), None);
|
||||
assert_eq!(sequence_frame("a_[#####].png", "a_99999999999999999999.png"), None, "too big for a frame number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_templates() {
|
||||
assert_eq!(numbered_template(DEFAULT_TEMPLATE, 1), "[compName]_1.[fileExtension]");
|
||||
assert_eq!(numbered_template(DEFAULT_SEQUENCE_TEMPLATE, 2), "[compName]_2_[#####].[fileExtension]");
|
||||
assert_eq!(numbered_template("[compName]_frame.[fileExtension]", 3), "[compName]_frame_3.[fileExtension]");
|
||||
assert_eq!(numbered_template("out/[compName]###.png", 1), "out/[compName]_1###.png");
|
||||
assert_eq!(numbered_template("my.dir/clip", 4), "my.dir/clip_4", "a dot in a folder is not an extension");
|
||||
assert_eq!(numbered_template("clip.v2.mov", 1), "clip.v2_1.mov");
|
||||
assert_eq!(numbered_template("", 1), "_1");
|
||||
assert_eq!(numbered_template("#", 1), "_1#");
|
||||
assert_eq!(numbered_template("[#]", 1), "_1[#]");
|
||||
// A numbered default is still a default: it expands to the numbered name.
|
||||
let v = TemplateVars { comp_name: "footage", project_name: "P", width: 64, height: 36, frame_rate: 30.0, start_frame: 0, end_frame: 29 };
|
||||
assert_eq!(expand_template(&numbered_template(DEFAULT_TEMPLATE, 1), OutputFormat::H264, &v), "footage_1.mp4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_format_fixes_names() {
|
||||
let mut m = OutputModule::default();
|
||||
|
||||
Reference in New Issue
Block a user