mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Let render --template --values fill a media replacement control with a file path (#1780)
This commit is contained in:
@@ -4,7 +4,7 @@ use std::io::Read;
|
|||||||
|
|
||||||
use effectcraft_engine::{
|
use effectcraft_engine::{
|
||||||
EngineError, Session,
|
EngineError, Session,
|
||||||
project::{ItemId, LayerId},
|
project::{ItemId, LayerId, essential::EgKind},
|
||||||
};
|
};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ fn preflight(s: &Session, path: &str, values: &Map<String, Value>) -> Result<(),
|
|||||||
project.fix_next_id();
|
project.fix_next_id();
|
||||||
let mut check = Session::new();
|
let mut check = Session::new();
|
||||||
check.project = project.into();
|
check.project = project.into();
|
||||||
Ok(instantiate(&mut check, source, values))
|
Ok(instantiate(&mut check, source, values, false))
|
||||||
})
|
})
|
||||||
.map_err(|e| Failure::Error(e.to_string()))?
|
.map_err(|e| Failure::Error(e.to_string()))?
|
||||||
}
|
}
|
||||||
@@ -105,10 +105,27 @@ pub(super) fn prepare(s: &mut Session, args: &Args) -> Result<(), Failure> {
|
|||||||
}
|
}
|
||||||
let imported = execute(s, "essential.importTemplate", json!({"path": path, "addToComp": false}))?;
|
let imported = execute(s, "essential.importTemplate", json!({"path": path, "addToComp": false}))?;
|
||||||
let source = imported.get("comp").and_then(Value::as_u64).ok_or_else(|| Failure::Error("template import did not return a composition".into()))?;
|
let source = imported.get("comp").and_then(Value::as_u64).ok_or_else(|| Failure::Error("template import did not return a composition".into()))?;
|
||||||
instantiate(s, source, &values)
|
instantiate(s, source, &values, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Result<(), Failure> {
|
/// The file a Media Replacement control is given: a path, or `{path|file|media: path}`.
|
||||||
|
fn media_path(value: &Value) -> Option<&str> {
|
||||||
|
match value {
|
||||||
|
Value::String(path) => Some(path.as_str()),
|
||||||
|
Value::Object(m) => ["path", "file", "media"].iter().find_map(|k| m.get(*k)).and_then(Value::as_str),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the template's control of that name (looked up as `essential.set` does) is a Media Replacement.
|
||||||
|
fn is_media_control(s: &Session, source: u64, name: &str) -> bool {
|
||||||
|
let Some(eg) = s.project.comp(ItemId(source)).and_then(|c| c.essential.as_ref()) else { return false };
|
||||||
|
let all = eg.flat();
|
||||||
|
let control = all.iter().find(|c| c.name == name).or_else(|| all.iter().find(|c| c.name.eq_ignore_ascii_case(name)));
|
||||||
|
control.is_some_and(|c| matches!(c.kind, EgKind::Media { .. }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>, import_media: bool) -> Result<(), Failure> {
|
||||||
// Duplicate and empty the source with commands: this preserves exact rational frame rate,
|
// Duplicate and empty the source with commands: this preserves exact rational frame rate,
|
||||||
// Tick duration, background, work area and renderer settings without round-tripping floats.
|
// Tick duration, background, work area and renderer settings without round-tripping floats.
|
||||||
let duplicate = execute(s, "project.duplicate", json!({"items": [source]}))?;
|
let duplicate = execute(s, "project.duplicate", json!({"items": [source]}))?;
|
||||||
@@ -145,6 +162,25 @@ fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Res
|
|||||||
// Let the wrapper carry the source composition's authored motion blur.
|
// Let the wrapper carry the source composition's authored motion blur.
|
||||||
execute(s, "layer.setSwitch", json!({"comp": parent, "layers": [instance], "switch": "motionBlur", "value": true}))?;
|
execute(s, "layer.setSwitch", json!({"comp": parent, "layers": [instance], "switch": "motionBlur", "value": true}))?;
|
||||||
for (control, value) in values {
|
for (control, value) in values {
|
||||||
|
if is_media_control(s, source, control) {
|
||||||
|
let path = media_path(value).ok_or_else(|| Failure::Error(format!("control `{control}` is a media control: give it a file path")))?;
|
||||||
|
// The preflight session cannot import files; the real run reports a bad path.
|
||||||
|
if !import_media {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let imported = execute(s, "file.import", json!({"paths": [path]}))?;
|
||||||
|
if let Some(err) = imported.get("errors").and_then(Value::as_array).and_then(|e| e.first()) {
|
||||||
|
return Err(Failure::Error(format!("control `{control}`: cannot import {path}: {err}")));
|
||||||
|
}
|
||||||
|
let item = imported
|
||||||
|
.get("items")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|items| items.first())
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.ok_or_else(|| Failure::Error(format!("control `{control}`: {path} did not import as footage")))?;
|
||||||
|
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "item": item}))?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "value": value}))?;
|
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "value": value}))?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -689,3 +689,35 @@ fn template_frame_output_cannot_alias_its_values_through_a_hard_link() {
|
|||||||
assert_eq!(reply["error"], "--out hard-linked-values.png would overwrite --values values.json", "{reply}");
|
assert_eq!(reply["error"], "--out hard-linked-values.png would overwrite --values values.json", "{reply}");
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_values_fill_a_media_replacement_control_with_a_file_path() {
|
||||||
|
let temp_root = std::env::temp_dir().canonicalize().unwrap();
|
||||||
|
let dir = temp_root.join(format!("ec-qa-template-media-control-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
|
||||||
|
ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
|
||||||
|
image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap();
|
||||||
|
image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 20, 40, 255])).save(dir.join("other.png")).unwrap();
|
||||||
|
let imported = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]);
|
||||||
|
let item = imported["result"]["items"][0].as_u64().unwrap();
|
||||||
|
let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string();
|
||||||
|
let added = ok(&dir, &["exec", "layer.addItem", ¶ms, "t.ecproj", "--save"]);
|
||||||
|
let layer = added["result"]["layer"].as_u64().unwrap();
|
||||||
|
let params = serde_json::json!({"comp": "Lower Third", "layer": layer, "name": "Clip"}).to_string();
|
||||||
|
ok(&dir, &["exec", "essential.addMedia", ¶ms, "t.ecproj", "--save"]);
|
||||||
|
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
|
||||||
|
|
||||||
|
std::fs::write(dir.join("values.json"), r#"{"Clip":"other.png"}"#).unwrap();
|
||||||
|
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "out.png"]);
|
||||||
|
let px = image::open(dir.join("out.png")).unwrap().to_rgba8().get_pixel(8, 8).0;
|
||||||
|
assert!((px[0] as i32 - 200).abs() <= 2 && (px[1] as i32 - 20).abs() <= 2 && (px[2] as i32 - 40).abs() <= 2, "replacement media rendered: {px:?}");
|
||||||
|
|
||||||
|
// A control that is not given a path is rejected with a clear message.
|
||||||
|
std::fs::write(dir.join("bad.json"), r#"{"Clip":5}"#).unwrap();
|
||||||
|
let (code, reply) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "bad.json", "--time", "1", "--out", "bad.png"]);
|
||||||
|
assert_eq!(code, 1, "{reply}");
|
||||||
|
assert!(reply["error"].as_str().unwrap().contains("media control"), "{reply}");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -303,7 +303,9 @@ build real projects through these interfaces; they are worked examples of everyt
|
|||||||
The CLI also renders an exported template directly:
|
The CLI also renders an exported template directly:
|
||||||
`effectcraft-cli render --template lt.ectemplate --values values.json --out 'title_[#####].png'`.
|
`effectcraft-cli render --template lt.ectemplate --values values.json --out 'title_[#####].png'`.
|
||||||
`values.json` is an object such as `{"Title":"John Smith","Bar Color":"#00c080","Bar Opacity":50}`;
|
`values.json` is an object such as `{"Title":"John Smith","Bar Color":"#00c080","Bar Opacity":50}`;
|
||||||
names follow `essential.set`, including its case-insensitive matching. Values override one
|
names follow `essential.set`, including its case-insensitive matching. A Media Replacement
|
||||||
|
control takes a footage file path (`{"Clip":"/in/clipB.mp4"}`, or `{"path": …}`), which is
|
||||||
|
imported and set as the control's `item`. Values override one
|
||||||
imported instance; the template file and its source controls keep their defaults. Omit
|
imported instance; the template file and its source controls keep their defaults. Omit
|
||||||
`--values` to render those defaults, or use the same options with `render-frame` for a PNG.
|
`--values` to render those defaults, or use the same options with `render-frame` for a PNG.
|
||||||
Values must fit in 1 MiB. Invalid JSON, unknown controls and invalid values fail before rendering.
|
Values must fit in 1 MiB. Invalid JSON, unknown controls and invalid values fail before rendering.
|
||||||
|
|||||||
Reference in New Issue
Block a user