PRJ-8: proxies — Set Proxy / None / Use Proxy / Create Proxy / Interpret Proxy, Proxy Use; PRJ-3 interpret fields, PAR, alpha guess
Items (footage and comps) carry a proxy rendered in their place at the source's size when its switch and Render Settings > Proxy Use allow it; Create Proxy queues a half-resolution render whose post-render action attaches it. Interpret Footage gains Separate Fields (upper/lower first, fields become frames at twice the rate), Guess alpha with matte colour, explicit matte colour, Invert Alpha, Interpret As Linear Light; non-square footage, solids and precomps are stretched by their pixel aspect in the comp. The four proxy stubs are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6d0d7de922
commit
c33b80614a
|
|
@ -400,56 +400,179 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
// ---------------------------------------------------------------- footage
|
||||
|
||||
/// Interpret Footage settings parsed from command parameters (every field optional).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Interpretation {
|
||||
rate: Option<FrameRate>,
|
||||
/// Back to the file's own rate (Use frame rate from file).
|
||||
native: bool,
|
||||
alpha: Option<AlphaMode>,
|
||||
/// Alpha ▸ Guess: decide Straight / Premultiplied (and the matte colour) from a frame.
|
||||
guess: bool,
|
||||
matte: Option<[f32; 3]>,
|
||||
invert_alpha: Option<bool>,
|
||||
loops: Option<u32>,
|
||||
par: Option<f64>,
|
||||
fields: Option<effectcraft_project::FieldOrder>,
|
||||
profile: Option<Option<effectcraft_project::ColorSpace>>,
|
||||
linear: Option<bool>,
|
||||
}
|
||||
|
||||
impl Interpretation {
|
||||
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Interpretation> {
|
||||
let mut it = Interpretation { rate: f_p(p, "frameRate").map(FrameRate::from_f64), ..Default::default() };
|
||||
if str_p(p, "frameRate") == Some("file") {
|
||||
it.native = true;
|
||||
}
|
||||
match str_p(p, "alpha") {
|
||||
Some("straight") => it.alpha = Some(AlphaMode::Straight),
|
||||
Some("premultiplied") => it.alpha = Some(AlphaMode::Premultiplied),
|
||||
Some("ignore") => it.alpha = Some(AlphaMode::Ignore),
|
||||
Some("guess") => it.guess = true,
|
||||
Some(a) => return Err(bad(cmd, format!("alpha: straight|premultiplied|ignore|guess, not `{a}`"))),
|
||||
None => {}
|
||||
}
|
||||
it.guess |= super::b_p(p, "guessAlpha").unwrap_or(false);
|
||||
it.matte = match p.get("matteColor").or(p.get("premulColor")) {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
effectcraft_keyframe::Value::Color([0.0, 0.0, 0.0, 1.0])
|
||||
.coerce_json(v)
|
||||
.map(|c| {
|
||||
let c = c.as_color();
|
||||
[c[0], c[1], c[2]]
|
||||
})
|
||||
.ok_or_else(|| bad(cmd, "matteColor: #hex or [r, g, b] (0..1)"))?,
|
||||
),
|
||||
};
|
||||
it.invert_alpha = super::b_p(p, "invertAlpha");
|
||||
it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32);
|
||||
it.par = f_p(p, "pixelAspect").filter(|x| *x > 0.0);
|
||||
it.fields = match str_p(p, "fields") {
|
||||
Some(f) => Some(effectcraft_project::FieldOrder::parse(f).ok_or_else(|| bad(cmd, "fields: off|upper|lower"))?),
|
||||
None => None,
|
||||
};
|
||||
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
|
||||
it.profile = match str_p(p, "colorProfile") {
|
||||
Some("auto" | "none" | "") => Some(None),
|
||||
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
|
||||
Some(cs) => Some(Some(cs)),
|
||||
None => return Err(bad(cmd, format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
it.linear = super::b_p(p, "linearLight");
|
||||
Ok(it)
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
|
||||
if self.native {
|
||||
if let Some(n) = f.native_rate.take() {
|
||||
f.frame_rate = n;
|
||||
}
|
||||
} else if let Some(r) = self.rate {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
}
|
||||
f.frame_rate = r;
|
||||
}
|
||||
if let Some(a) = self.alpha {
|
||||
f.alpha = a;
|
||||
}
|
||||
if let Some((a, m)) = guessed {
|
||||
f.alpha = a;
|
||||
f.premul_color = m;
|
||||
}
|
||||
if let Some(m) = self.matte {
|
||||
f.premul_color = m;
|
||||
}
|
||||
if let Some(v) = self.invert_alpha {
|
||||
f.invert_alpha = v;
|
||||
}
|
||||
if let Some(l) = self.loops {
|
||||
f.loop_count = l;
|
||||
}
|
||||
if let Some(x) = self.par {
|
||||
f.pixel_aspect = x;
|
||||
}
|
||||
if let Some(x) = self.fields {
|
||||
f.fields = x;
|
||||
}
|
||||
if let Some(c) = self.profile {
|
||||
f.color_profile = c;
|
||||
}
|
||||
if let Some(v) = self.linear {
|
||||
f.linear_light = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Alpha ▸ Guess: read the first frame as straight colour and decide
|
||||
/// whether the file is premultiplied (every partly transparent pixel's colour within its alpha
|
||||
/// of a black or white matte) or straight. `None` when the frame has no partial alpha.
|
||||
pub(crate) fn guess_alpha(s: &Session, item: ItemId, f: &Footage) -> Option<(AlphaMode, [f32; 3])> {
|
||||
let probe = Footage { alpha: AlphaMode::Straight, invert_alpha: false, ..f.clone() };
|
||||
let img = s.footage.frame(item, &probe, Tick::ZERO)?;
|
||||
let (mut partial, mut black, mut white) = (0usize, true, true);
|
||||
let eps = 1.5 / 255.0;
|
||||
for px in img.data.iter() {
|
||||
let a = px[3];
|
||||
if a <= eps || a >= 1.0 - eps {
|
||||
continue;
|
||||
}
|
||||
partial += 1;
|
||||
for c in &px[..3] {
|
||||
// The file's colour (the decoder premultiplied it as straight).
|
||||
let file = c / a;
|
||||
black &= file <= a + eps;
|
||||
white &= file >= 1.0 - a - eps;
|
||||
}
|
||||
}
|
||||
if partial == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(if black {
|
||||
(AlphaMode::Premultiplied, [0.0; 3])
|
||||
} else if white {
|
||||
(AlphaMode::Premultiplied, [1.0; 3])
|
||||
} else {
|
||||
(AlphaMode::Straight, f.premul_color)
|
||||
})
|
||||
}
|
||||
|
||||
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = items_p(s, p);
|
||||
if items.is_empty() {
|
||||
return Err(bad("file.interpretFootage", "select footage"));
|
||||
}
|
||||
let rate = f_p(p, "frameRate").map(FrameRate::from_f64);
|
||||
let alpha = match str_p(p, "alpha") {
|
||||
Some("straight") => Some(AlphaMode::Straight),
|
||||
Some("premultiplied") => Some(AlphaMode::Premultiplied),
|
||||
Some("ignore") => Some(AlphaMode::Ignore),
|
||||
Some(a) => return Err(bad("file.interpretFootage", format!("alpha: straight|premultiplied|ignore, not `{a}`"))),
|
||||
None => None,
|
||||
};
|
||||
let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let par = f_p(p, "pixelAspect");
|
||||
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
|
||||
let profile = match str_p(p, "colorProfile") {
|
||||
Some("auto" | "none" | "") => Some(None),
|
||||
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
|
||||
Some(cs) => Some(Some(cs)),
|
||||
None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let it = Interpretation::parse(p, "file.interpretFootage")?;
|
||||
let guesses: Vec<Option<(AlphaMode, [f32; 3])>> = items
|
||||
.iter()
|
||||
.map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) {
|
||||
(true, Some(ItemKind::Footage(f))) => guess_alpha(s, *i, f),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
s.edit("Interpret Footage", None, |proj, _| {
|
||||
for i in &items {
|
||||
for (i, g) in items.iter().zip(&guesses) {
|
||||
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
|
||||
if let Some(r) = rate {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
}
|
||||
f.frame_rate = r;
|
||||
}
|
||||
if let Some(a) = alpha {
|
||||
f.alpha = a;
|
||||
}
|
||||
if let Some(l) = loops {
|
||||
f.loop_count = l;
|
||||
}
|
||||
if let Some(x) = par {
|
||||
f.pixel_aspect = x;
|
||||
}
|
||||
if let Some(c) = profile {
|
||||
f.color_profile = c;
|
||||
}
|
||||
it.apply(f, *g);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
let out: Vec<Value> = items
|
||||
.iter()
|
||||
.filter_map(|i| match s.project.item(*i).map(|x| &x.kind) {
|
||||
Some(ItemKind::Footage(f)) => Some(json!({
|
||||
"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,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"items": out}))
|
||||
}
|
||||
|
||||
fn remember_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -470,6 +593,10 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
f.premul_color = src.premul_color;
|
||||
f.pixel_aspect = src.pixel_aspect;
|
||||
f.loop_count = src.loop_count;
|
||||
f.fields = src.fields;
|
||||
f.invert_alpha = src.invert_alpha;
|
||||
f.linear_light = src.linear_light;
|
||||
f.color_profile = src.color_profile;
|
||||
if src.native_rate.is_some() {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
|
|
@ -648,7 +775,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}",
|
||||
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
|
|||
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
|
||||
let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| {
|
||||
let folder = solids_folder(proj);
|
||||
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
|
||||
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: comp.pixel_aspect }));
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None);
|
||||
if adjustment {
|
||||
l.switches.adjustment = true;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ mod effect;
|
|||
pub mod essential;
|
||||
pub mod expr_tools;
|
||||
mod file;
|
||||
mod file_more;
|
||||
pub(crate) mod file_more;
|
||||
mod frontend;
|
||||
mod help;
|
||||
mod key_transform;
|
||||
|
|
@ -29,6 +29,7 @@ pub mod paint;
|
|||
mod paths;
|
||||
mod project_items;
|
||||
mod prop;
|
||||
mod proxy;
|
||||
pub mod puppet;
|
||||
mod query;
|
||||
mod render_queue;
|
||||
|
|
@ -140,11 +141,17 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(settings::specs());
|
||||
v.extend(essential::specs());
|
||||
v.extend(expr_tools::specs());
|
||||
v.extend(proxy::specs());
|
||||
v.extend(stubs::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
/// Set `footage` as the proxy of `item` (File ▸ Create Proxy's post-render action).
|
||||
pub(crate) fn proxy_set(s: &mut Session, item: ItemId, footage: effectcraft_project::Footage) -> Result<()> {
|
||||
proxy::set_proxy_footage(s, &[item], footage)
|
||||
}
|
||||
|
||||
/// (id, name) of the text animation presets.
|
||||
pub fn text_preset_list() -> Vec<(String, String)> {
|
||||
text_anim::preset_names()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
//! Proxies (File ▸ Create Proxy / Set Proxy / Interpret Footage ▸ Proxy): a stand-in file for
|
||||
//! a footage item or a composition. The Project panel's proxy switch (`file.useProxy`) turns
|
||||
//! it on or off per item; renders follow Render Settings ▸ Proxy Use (the viewer uses each
|
||||
//! item's switch).
|
||||
|
||||
use effectcraft_project::render_queue::{Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, TimeSpan};
|
||||
use effectcraft_project::{Footage, ItemId, ItemKind, Proxy};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_project_selection, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// Footage items and comps a proxy command acts on: `items`/`item`, else the Project panel
|
||||
/// selection.
|
||||
fn targets(s: &Session, p: &Value) -> Vec<ItemId> {
|
||||
let ids: Vec<ItemId> = match p.get("items").or(p.get("item")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(ItemId).collect(),
|
||||
Some(Value::Number(n)) => n.as_u64().map(ItemId).into_iter().collect(),
|
||||
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).into_iter().collect(),
|
||||
_ => s.state.project_selection.clone(),
|
||||
};
|
||||
ids.into_iter().filter(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_) | ItemKind::Comp(_)))).collect()
|
||||
}
|
||||
|
||||
fn has_targets(s: &Session) -> std::result::Result<(), String> {
|
||||
has_project_selection(s)?;
|
||||
if targets(s, &Value::Null).is_empty() { Err("select footage or a composition in the Project panel".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn has_proxy(s: &Session) -> std::result::Result<(), String> {
|
||||
has_targets(s)?;
|
||||
if targets(s, &Value::Null).iter().any(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("the selected items have no proxy".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe a proxy file through the importer.
|
||||
fn probe(s: &Session, path: &str, cmd: &str) -> Result<Footage> {
|
||||
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
|
||||
let f = importer.probe(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
if !f.has_video {
|
||||
return Err(bad(cmd, format!("{path} has no video to use as a proxy")));
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// Attach `footage` as the proxy of `items` (Use Proxy on). One undo step.
|
||||
pub(crate) fn set_proxy_footage(s: &mut Session, items: &[ItemId], footage: Footage) -> Result<()> {
|
||||
s.edit("Set Proxy", None, |proj, _| {
|
||||
for i in items {
|
||||
if let Some(it) = proj.item_mut(*i) {
|
||||
it.proxy = Some(Box::new(Proxy { footage: footage.clone(), enabled: true }));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.setProxy";
|
||||
let items = targets(s, p);
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "select footage or a composition"));
|
||||
}
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path`"))?.to_string();
|
||||
let f = probe(s, &path, C)?;
|
||||
let (w, h) = (f.width, f.height);
|
||||
set_proxy_footage(s, &items, f)?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "path": path, "width": w, "height": h}))
|
||||
}
|
||||
|
||||
fn set_proxy_none(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = targets(s, p);
|
||||
s.edit("Set Proxy None", None, |proj, _| {
|
||||
for i in &items {
|
||||
if let Some(it) = proj.item_mut(*i) {
|
||||
it.proxy = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// The Project panel's proxy switch: use the proxy (filled box) or the full-resolution source.
|
||||
fn use_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.useProxy";
|
||||
let items: Vec<ItemId> = targets(s, p).into_iter().filter(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())).collect();
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "the selected items have no proxy"));
|
||||
}
|
||||
let first = s.project.item(items[0]).and_then(|x| x.proxy.as_ref()).is_some_and(|px| px.enabled);
|
||||
let on = b_p(p, "on").unwrap_or(!first);
|
||||
s.edit(if on { "Use Proxy" } else { "Use Full Resolution" }, None, |proj, _| {
|
||||
for i in &items {
|
||||
if let Some(px) = proj.item_mut(*i).and_then(|x| x.proxy.as_mut()) {
|
||||
px.enabled = on;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "on": on}))
|
||||
}
|
||||
|
||||
/// File ▸ Interpret Footage ▸ Proxy: Interpret Footage settings for the proxy file.
|
||||
fn interpret_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.interpretProxy";
|
||||
let items: Vec<ItemId> = targets(s, p).into_iter().filter(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())).collect();
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "the selected items have no proxy"));
|
||||
}
|
||||
let it = super::file_more::Interpretation::parse(p, C)?;
|
||||
let guesses: Vec<_> = items
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let px = s.project.item(*i).and_then(|x| x.proxy.as_ref());
|
||||
match (p.get("alpha").and_then(Value::as_str) == Some("guess") || b_p(p, "guessAlpha") == Some(true), px) {
|
||||
(true, Some(px)) => super::file_more::guess_alpha(s, *i, &px.footage),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
s.edit("Interpret Proxy", None, |proj, _| {
|
||||
for (i, g) in items.iter().zip(&guesses) {
|
||||
if let Some(px) = proj.item_mut(*i).and_then(|x| x.proxy.as_mut()) {
|
||||
it.apply(&mut px.footage, *g);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// File ▸ Create Proxy ▸ Still / Movie: queue a half-resolution render of the composition whose
|
||||
/// post-render action sets the result as its proxy.
|
||||
fn create_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.createProxy";
|
||||
let cid = match p.get("comp").or(p.get("item")) {
|
||||
Some(_) => {
|
||||
let q = json!({"comp": p.get("comp").or(p.get("item")).cloned()});
|
||||
super::comp_id(s, &q)?
|
||||
}
|
||||
None => s
|
||||
.state
|
||||
.project_selection
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|i| s.project.comp(*i).is_some())
|
||||
.or(s.active_comp_id())
|
||||
.ok_or_else(|| bad(C, "select a composition"))?,
|
||||
};
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let still = match str_p(p, "kind").unwrap_or("movie") {
|
||||
"still" => true,
|
||||
"movie" => false,
|
||||
k => return Err(bad(C, format!("kind: still|movie, not `{k}`"))),
|
||||
};
|
||||
let mut it = RenderQueueItem::new(0, cid);
|
||||
it.settings.quality = RenderQuality::Draft;
|
||||
it.settings.resolution = p.get("resolution").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.05, 1.0);
|
||||
if still {
|
||||
// One frame at the current time (the comp's poster frame when not the active comp).
|
||||
let t = if s.active_comp_id() == Some(cid) { s.time() } else { comp.poster_time };
|
||||
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
|
||||
it.output = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
it.output.output = "[compName]_proxy_[#####].png".into();
|
||||
} else {
|
||||
it.settings.time_span = TimeSpan::LengthOfComp;
|
||||
it.output = OutputModule::for_format(OutputFormat::ProRes);
|
||||
it.output.prores_profile = ProResProfile::P4444;
|
||||
it.output.output = "[compName]_proxy.[fileExtension]".into();
|
||||
}
|
||||
it.output.channels = Channels::Rgba;
|
||||
if let Some(o) = str_p(p, "path").or(str_p(p, "output")) {
|
||||
it.output.output = o.to_string();
|
||||
}
|
||||
it.post_render = PostRenderAction::SetProxy;
|
||||
let id = s.edit("Create Proxy", None, |proj, _| {
|
||||
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
|
||||
let id = it.id;
|
||||
proj.render_queue.push(it);
|
||||
Ok(id)
|
||||
})?;
|
||||
let path = s.project.render_queue.last().and_then(|i| s.resolve_output(i));
|
||||
Ok(json!({"item": id, "comp": cid.0, "kind": if still { "still" } else { "movie" }, "output": path, "time": Tick::ZERO.seconds()}))
|
||||
}
|
||||
|
||||
fn has_comp_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.state.project_selection.iter().any(|i| s.project.comp(*i).is_some()) || s.active_comp_id().is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select a composition".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"file.createProxy",
|
||||
"Create Proxy",
|
||||
[],
|
||||
None,
|
||||
"{kind: still|movie, comp?|item?, path?|output? (template), resolution? (default 0.5)}",
|
||||
has_comp_selection,
|
||||
create_proxy
|
||||
),
|
||||
cmd!("file.setProxy", "File...", ["File", "Set Proxy"], None, "{path, items?|item?}", has_targets, set_proxy),
|
||||
cmd!("file.setProxyNone", "None", ["File", "Set Proxy"], None, "{items?|item?}", has_proxy, set_proxy_none),
|
||||
cmd!("file.useProxy", "Use Proxy", [], None, "{items?|item?, on?}", has_proxy, use_proxy),
|
||||
cmd!(
|
||||
"file.interpretProxy",
|
||||
"Proxy...",
|
||||
["File", "Interpret Footage"],
|
||||
None,
|
||||
"{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}",
|
||||
has_proxy,
|
||||
interpret_proxy
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -104,6 +104,10 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
|
|||
{
|
||||
return Err(bad(cmd, "end must be after start"));
|
||||
}
|
||||
if let Some(u) = str_p(p, "proxyUse") {
|
||||
rs.proxy_use = effectcraft_project::render_queue::ProxyUse::parse(u).ok_or_else(|| bad(cmd, "proxyUse: current|all|comp|none"))?;
|
||||
any = true;
|
||||
}
|
||||
match p.get("frameRate") {
|
||||
Some(Value::Null) => {
|
||||
rs.frame_rate = None;
|
||||
|
|
@ -509,7 +513,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Add to Render Queue",
|
||||
["Composition"],
|
||||
Some("Cmd+M"),
|
||||
"{comp?: id|name, format?: h264|prores|png|jpeg|tiff|exr|gif, output?: path|template, quality?: best|draft|1-100 (jpeg), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
|
||||
"{comp?: id|name, format?: h264|prores|png|jpeg|tiff|exr|gif, output?: path|template, quality?: best|draft|1-100 (jpeg), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
|
||||
can_add,
|
||||
add
|
||||
),
|
||||
|
|
@ -520,7 +524,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Render Settings...",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool}",
|
||||
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none}",
|
||||
has_items,
|
||||
set_render_settings
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,12 +20,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
// macOS application menu items handled by the OS.
|
||||
stub!("app.hideOthers", "Hide Others", [], "{}"),
|
||||
stub!("app.showAll", "Show All", [], "{}"),
|
||||
// Render queue / export (export milestone).
|
||||
stub!("file.createProxy", "Create Proxy", [], "{kind: still|movie}"),
|
||||
// Proxies (no proxy model yet).
|
||||
stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"),
|
||||
stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"),
|
||||
stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"),
|
||||
stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"),
|
||||
// Layer ▸ New (3D / content-aware fill).
|
||||
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
|
||||
|
|
|
|||
|
|
@ -340,7 +340,8 @@ impl Session {
|
|||
let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
if let Err(why) = (spec.enabled)(self) {
|
||||
// Explicit targets (agents, scripts) don't need a UI selection.
|
||||
let explicit = ["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok();
|
||||
let explicit = (["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok())
|
||||
|| ["item", "items"].iter().any(|k| params.get(k).is_some());
|
||||
if !explicit {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
|
|
@ -601,6 +602,8 @@ mod tests_model3d;
|
|||
#[cfg(test)]
|
||||
mod tests_project_items;
|
||||
#[cfg(test)]
|
||||
mod tests_proxy;
|
||||
#[cfg(test)]
|
||||
mod tests_settings;
|
||||
#[cfg(test)]
|
||||
mod tests_stubs;
|
||||
|
|
|
|||
|
|
@ -388,6 +388,11 @@ impl Session {
|
|||
}
|
||||
let importer = self.importer.clone().ok_or("media import is not available in this build")?;
|
||||
let footage = importer.probe(path)?;
|
||||
if item.post_render == PostRenderAction::SetProxy {
|
||||
crate::commands::proxy_set(self, item.comp, footage).map_err(|e| e.to_string())?;
|
||||
self.toast(format!("Proxy set from {path}"));
|
||||
return Ok(None);
|
||||
}
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let replace = item.post_render == PostRenderAction::ImportAndReplace;
|
||||
let comp = item.comp;
|
||||
|
|
|
|||
|
|
@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
|
|||
let ev = frontend_events(&mut s);
|
||||
assert_eq!(ev[0].0, "view.zoomIn");
|
||||
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
|
||||
for id in ["file.createProxy", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
|
||||
for id in ["keys.audioToKeyframes", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
|
||||
assert!(!s.is_enabled(id), "{id}");
|
||||
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,275 @@
|
|||
//! 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.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::render_queue::ProxyUse;
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId, ItemKind};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::{FootageSource, RenderOpts};
|
||||
|
||||
/// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced:
|
||||
/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a
|
||||
/// semi-transparent green premultiplied against black / white, read as straight), `half.png`
|
||||
/// (white at 25 % alpha), `wide.png` (10×10 white).
|
||||
struct Synthetic;
|
||||
|
||||
fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
|
||||
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
match name {
|
||||
"red16.png" => [1.0, 0.0, 0.0, 1.0],
|
||||
"blue8.png" => [0.0, 0.0, 1.0, 1.0],
|
||||
"lines.mov" => {
|
||||
if y.is_multiple_of(2) {
|
||||
[1.0, 0.0, 0.0, 1.0]
|
||||
} else {
|
||||
[0.0, 0.0, 1.0, 1.0]
|
||||
}
|
||||
}
|
||||
// Straight interpretation of a file whose colour is green·a + matte·(1 − a), a = 0.5.
|
||||
"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],
|
||||
_ => {
|
||||
let _ = x;
|
||||
[1.0, 1.0, 1.0, 1.0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn size_of(path: &str) -> (u32, u32) {
|
||||
match std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("") {
|
||||
"red16.png" => (16, 16),
|
||||
"blue8.png" => (8, 8),
|
||||
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4),
|
||||
_ => (10, 10),
|
||||
}
|
||||
}
|
||||
|
||||
impl FootageSource for Synthetic {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option<Arc<Image>> {
|
||||
let (w, h) = size_of(&f.path);
|
||||
let mut img = Image::new(w, h);
|
||||
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);
|
||||
img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]);
|
||||
}
|
||||
}
|
||||
Some(Arc::new(img))
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: if video { FootageKind::Video } else { FootageKind::Still },
|
||||
width: w,
|
||||
height: h,
|
||||
has_video: true,
|
||||
frame_rate: FrameRate::from_f64(10.0),
|
||||
duration: Tick::from_seconds_f64(if video { 2.0 } else { 0.0 }),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
Session { footage: Arc::new(Synthetic), importer: Some(Arc::new(Probe)), ..Default::default() }
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, path: &str) -> ItemId {
|
||||
ItemId(s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn px(s: &Session, comp: ItemId, t: f64, opts: RenderOpts, x: i64, y: i64) -> [f32; 4] {
|
||||
s.render(comp, Tick::from_seconds_f64(t), opts).get(x, y)
|
||||
}
|
||||
|
||||
fn red(p: [f32; 4]) -> bool {
|
||||
p[0] > 0.99 && p[2] < 0.01 && p[3] > 0.99
|
||||
}
|
||||
fn blue(p: [f32; 4]) -> bool {
|
||||
p[2] > 0.99 && p[0] < 0.01 && p[3] > 0.99
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footage_proxy_follows_use_proxy_and_proxy_use() {
|
||||
let mut s = session();
|
||||
let full = import(&mut s, "/m/red16.png");
|
||||
s.execute("comp.new", json!({"name": "C", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": full.0})).unwrap();
|
||||
let o = RenderOpts::default();
|
||||
assert!(red(px(&s, c, 0.0, o, 8, 8)));
|
||||
s.execute("file.setProxy", json!({"item": full.0, "path": "/m/blue8.png"})).unwrap();
|
||||
// The 8×8 proxy fills the 16×16 footage frame.
|
||||
for (x, y) in [(1, 1), (8, 8), (14, 14)] {
|
||||
assert!(blue(px(&s, c, 0.0, o, x, y)), "proxy at {x},{y}");
|
||||
}
|
||||
assert!(s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled);
|
||||
// Render Settings ▸ Proxy Use.
|
||||
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8)));
|
||||
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8)));
|
||||
// Use Proxy off: the viewer shows the source, Use All Proxies still uses it.
|
||||
s.execute("file.useProxy", json!({"item": full.0})).unwrap();
|
||||
assert!(!s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled);
|
||||
assert!(red(px(&s, c, 0.0, o, 8, 8)));
|
||||
assert!(blue(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8)));
|
||||
// Undo / Set Proxy ▸ None.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(blue(px(&s, c, 0.0, o, 8, 8)));
|
||||
s.execute("file.setProxyNone", json!({"item": full.0})).unwrap();
|
||||
assert!(s.project.item(full).unwrap().proxy.is_none());
|
||||
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8)));
|
||||
// Render queue items carry Proxy Use (Best Settings: none).
|
||||
let r = s.execute("renderQueue.add", json!({"proxyUse": "all"})).unwrap();
|
||||
assert_eq!(s.project.render_queue[0].settings.proxy_use, ProxyUse::UseAll, "{r}");
|
||||
assert_eq!(effectcraft_project::render_queue::RenderSettings::default().proxy_use, ProxyUse::UseNone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_proxy_and_create_proxy() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
let o = RenderOpts::default();
|
||||
assert!(red(px(&s, outer, 0.0, o, 8, 8)));
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/blue8.png"})).unwrap();
|
||||
assert!(blue(px(&s, outer, 0.0, o, 14, 1)));
|
||||
assert!(blue(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8)));
|
||||
assert!(red(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8)));
|
||||
s.execute("file.setProxyNone", json!({"item": inner.0})).unwrap();
|
||||
// Create Proxy queues a half-resolution render whose post-render action sets the proxy.
|
||||
s.state.project_selection = vec![inner];
|
||||
let r = s.execute("file.createProxy", json!({"kind": "still"})).unwrap();
|
||||
let it = s.project.render_queue.last().unwrap().clone();
|
||||
assert_eq!(it.comp, inner);
|
||||
assert_eq!(it.post_render, effectcraft_project::render_queue::PostRenderAction::SetProxy);
|
||||
assert_eq!(it.settings.resolution, 0.5);
|
||||
assert!(r["output"].as_str().unwrap().contains("Inner_proxy_"), "{r}");
|
||||
let m = s.execute("file.createProxy", json!({"kind": "movie", "comp": inner.0})).unwrap();
|
||||
assert!(m["output"].as_str().unwrap().ends_with("Inner_proxy.mov"), "{m}");
|
||||
// The post-render action (what a finished render runs) attaches the file.
|
||||
let id = it.id;
|
||||
s.post_render_action(id, "/m/blue8.png").unwrap();
|
||||
assert_eq!(s.project.item(inner).unwrap().proxy.as_ref().unwrap().footage.path, "/m/blue8.png");
|
||||
assert!(blue(px(&s, outer, 0.0, o, 8, 8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_proxy_settings_apply_to_the_proxy() {
|
||||
let mut s = session();
|
||||
let full = import(&mut s, "/m/red16.png");
|
||||
s.execute("file.setProxy", json!({"item": full.0, "path": "/m/half.png"})).unwrap();
|
||||
s.execute("file.interpretProxy", json!({"item": full.0, "alpha": "ignore", "invertAlpha": true})).unwrap();
|
||||
let px = s.project.item(full).unwrap().proxy.clone().unwrap();
|
||||
assert_eq!(px.footage.alpha, AlphaMode::Ignore);
|
||||
assert!(px.footage.invert_alpha);
|
||||
let f = match &s.project.item(full).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation");
|
||||
}
|
||||
|
||||
/// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines
|
||||
/// interpolated; the dominant field comes first.
|
||||
#[test]
|
||||
fn separate_fields_make_frames_from_fields() {
|
||||
let mut s = session();
|
||||
let mov = import(&mut s, "/m/lines.mov");
|
||||
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 20, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": mov.0})).unwrap();
|
||||
let o = RenderOpts::default();
|
||||
// Progressive: alternating rows.
|
||||
assert!(red(px(&s, c, 0.0, o, 1, 0)) && blue(px(&s, c, 0.0, o, 1, 1)));
|
||||
s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "upper"})).unwrap();
|
||||
// Field 1 (upper = even rows, red) then field 2 (odd rows, blue), each 1/20 s.
|
||||
for y in 0..3 {
|
||||
assert!(red(px(&s, c, 0.0, o, 1, y)), "upper field at row {y}");
|
||||
assert!(blue(px(&s, c, 0.05, o, 1, y + 1)), "lower field at row {}", y + 1);
|
||||
}
|
||||
s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "lower"})).unwrap();
|
||||
assert!(blue(px(&s, c, 0.0, o, 1, 2)));
|
||||
assert!(red(px(&s, c, 0.05, o, 1, 1)));
|
||||
let r = s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "off"})).unwrap();
|
||||
assert_eq!(r["items"][0]["fields"], "Off");
|
||||
}
|
||||
|
||||
/// Non-square pixels: a 10×10 footage with pixel aspect 2 covers 20×10 comp pixels.
|
||||
#[test]
|
||||
fn pixel_aspect_stretches_footage_in_the_comp() {
|
||||
let mut s = session();
|
||||
let w = import(&mut s, "/m/wide.png");
|
||||
s.execute("comp.new", json!({"name": "C", "width": 40, "height": 20, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": w.0})).unwrap();
|
||||
let o = RenderOpts::default();
|
||||
let covered = |s: &Session| s.render(c, Tick::ZERO, o).data.iter().filter(|p| p[3] > 0.5).count();
|
||||
assert_eq!(covered(&s), 100);
|
||||
s.execute("file.interpretFootage", json!({"item": w.0, "pixelAspect": 2.0})).unwrap();
|
||||
assert_eq!(covered(&s), 200);
|
||||
// Centered on the comp: x 10..30 (edge pixels are filtered).
|
||||
assert!(px(&s, c, 0.0, o, 11, 10)[3] > 0.99 && px(&s, c, 0.0, o, 28, 10)[3] > 0.99);
|
||||
assert!(px(&s, c, 0.0, o, 8, 10)[3] < 0.01 && px(&s, c, 0.0, o, 31, 10)[3] < 0.01);
|
||||
// A comp with the same pixel aspect shows the footage 1:1.
|
||||
s.execute("comp.settings", json!({"pixelAspect": 2.0})).unwrap();
|
||||
assert_eq!(covered(&s), 100);
|
||||
}
|
||||
|
||||
/// Alpha ▸ Guess: premultiplied files are recognised with their matte colour; Invert Alpha
|
||||
/// and Interpret As Linear Light change the decoded pixels.
|
||||
#[test]
|
||||
fn guess_alpha_invert_and_linear_light() {
|
||||
let mut s = session();
|
||||
let b = import(&mut s, "/m/matte_black.png");
|
||||
let w = import(&mut s, "/m/matte_white.png");
|
||||
let h = import(&mut s, "/m/half.png");
|
||||
let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
s.execute("file.interpretFootage", json!({"items": [b.0, w.0, h.0], "alpha": "guess"})).unwrap();
|
||||
assert_eq!(foot(&s, b).alpha, AlphaMode::Premultiplied);
|
||||
assert_eq!(foot(&s, b).premul_color, [0.0; 3]);
|
||||
assert_eq!(foot(&s, w).alpha, AlphaMode::Premultiplied);
|
||||
assert_eq!(foot(&s, w).premul_color, [1.0; 3]);
|
||||
// White at 25 %: not explained by a black matte (1 > 0.25) but it is by white… either way
|
||||
// the guess is consistent with the pixels.
|
||||
assert!(matches!(foot(&s, h).alpha, AlphaMode::Straight | AlphaMode::Premultiplied));
|
||||
// An explicit matte colour.
|
||||
s.execute("file.interpretFootage", json!({"item": b.0, "alpha": "premultiplied", "matteColor": "#808080"})).unwrap();
|
||||
assert!((foot(&s, b).premul_color[0] - 128.0 / 255.0).abs() < 1e-3);
|
||||
// Invert Alpha: 25 % → 75 %.
|
||||
s.execute("file.interpretFootage", json!({"item": h.0, "alpha": "straight", "invertAlpha": true})).unwrap();
|
||||
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": h.0})).unwrap();
|
||||
let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1);
|
||||
assert!((p[3] - 0.75).abs() < 0.01 && p[0] > 0.7, "{p:?}");
|
||||
// Interpret As Linear Light keeps alpha and re-encodes the colour.
|
||||
s.execute("file.interpretFootage", json!({"item": h.0, "invertAlpha": false, "linearLight": true})).unwrap();
|
||||
let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1);
|
||||
assert!((p[3] - 0.25).abs() < 0.01, "{p:?}");
|
||||
// Remember / Apply Interpretation carries the new settings.
|
||||
s.state.project_selection = vec![h];
|
||||
s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap();
|
||||
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
|
||||
assert!(foot(&s, w).linear_light);
|
||||
}
|
||||
|
|
@ -332,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
|
|||
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id)));
|
||||
}
|
||||
// What remains a stub is still disabled.
|
||||
for id in ["view.displayColorManagement", "file.createProxy", "track.camera"] {
|
||||
for id in ["view.displayColorManagement", "keys.audioToKeyframes", "track.camera"] {
|
||||
assert!(!s.is_enabled(id), "{id}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image {
|
|||
view: None,
|
||||
backend: effectcraft_render::Backend::Auto,
|
||||
roi: None,
|
||||
proxy: job.settings.proxy_use,
|
||||
};
|
||||
let mut r = Renderer::new(job.project, job.footage, opts);
|
||||
r.expr = job.expr;
|
||||
|
|
|
|||
|
|
@ -297,6 +297,59 @@ mod tests {
|
|||
assert!(px[1] > 0.99 && px[0] < 0.01, "the box is filled with the sampled green: {px:?}");
|
||||
}
|
||||
|
||||
/// Proxies through the real media and export layers: Render Settings ▸ Proxy Use decides
|
||||
/// whether an export uses the proxy, and Create Proxy renders and attaches one.
|
||||
#[test]
|
||||
fn proxies_in_exports_and_create_proxy() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-proxy");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = |n: &str| dir.join(n).to_string_lossy().to_string();
|
||||
let mut s = super::session();
|
||||
// Source and proxy stills, written by the app itself.
|
||||
s.execute("comp.new", json!({"name": "Red", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let red = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("comp.saveFrameAs", json!({"path": p("red.png")})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Blue", "width": 8, "height": 8, "frameRate": 10, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("comp.saveFrameAs", json!({"path": p("blue.png")})).unwrap();
|
||||
let foot = s.execute("file.import", json!({"paths": [p("red.png")]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
s.execute("file.setProxy", json!({"item": foot, "path": p("blue.png")})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Out", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let out = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": foot})).unwrap();
|
||||
// Export one frame with each Proxy Use.
|
||||
for (name, using) in [("all", "use_all_[#].png"), ("none", "use_none_[#].png")] {
|
||||
s.execute(
|
||||
"renderQueue.add",
|
||||
json!({"comp": out.0, "format": "png", "output": p(using), "timeSpan": "custom", "start": 0.0, "end": 0.1, "proxyUse": name}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
|
||||
s.poll_render();
|
||||
let centre = |s: &mut effectcraft_engine::Session, path: &str| {
|
||||
let id = s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Probe", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": id})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.render(c, Default::default(), Default::default()).get(8, 8)
|
||||
};
|
||||
let all = centre(&mut s, &p("use_all_0.png"));
|
||||
assert!(all[2] > 0.99 && all[0] < 0.01, "Use All Proxies renders the blue proxy: {all:?}");
|
||||
let none = centre(&mut s, &p("use_none_0.png"));
|
||||
assert!(none[0] > 0.99 && none[2] < 0.01, "Use No Proxies renders the red source: {none:?}");
|
||||
// Create Proxy ▸ Still: rendered at half size and attached to the comp.
|
||||
s.execute("file.createProxy", json!({"kind": "still", "comp": red.0, "path": p("red_proxy_[#####].png")})).unwrap();
|
||||
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
|
||||
s.poll_render();
|
||||
let px = s.project.item(red).unwrap().proxy.clone().expect("Create Proxy attached the rendered still");
|
||||
assert!(px.enabled);
|
||||
assert_eq!((px.footage.width, px.footage.height), (8, 8));
|
||||
assert!(std::path::Path::new(&px.footage.path).exists(), "{}", px.footage.path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expression_syntax_errors_disable_the_expression() {
|
||||
let mut s = super::session();
|
||||
|
|
|
|||
|
|
@ -39,6 +39,51 @@ pub enum TimeSpan {
|
|||
Custom { start: Tick, end: Tick },
|
||||
}
|
||||
|
||||
/// Render Settings ▸ Proxy Use: which proxies a render uses.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ProxyUse {
|
||||
/// Each item's Use Proxy switch (what the viewer shows).
|
||||
#[default]
|
||||
CurrentSettings,
|
||||
/// Every item that has a proxy uses it.
|
||||
UseAll,
|
||||
/// Only compositions' proxies.
|
||||
UseCompOnly,
|
||||
/// No proxies (full-resolution sources).
|
||||
UseNone,
|
||||
}
|
||||
|
||||
impl ProxyUse {
|
||||
pub const ALL: [ProxyUse; 4] = [ProxyUse::CurrentSettings, ProxyUse::UseAll, ProxyUse::UseCompOnly, ProxyUse::UseNone];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ProxyUse::CurrentSettings => "Current Settings",
|
||||
ProxyUse::UseAll => "Use All Proxies",
|
||||
ProxyUse::UseCompOnly => "Use Comp Proxies Only",
|
||||
ProxyUse::UseNone => "Use No Proxies",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<ProxyUse> {
|
||||
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"current" | "currentsettings" => Some(ProxyUse::CurrentSettings),
|
||||
"all" | "useall" | "useallproxies" => Some(ProxyUse::UseAll),
|
||||
"comp" | "componly" | "usecompproxiesonly" | "usecomponly" => Some(ProxyUse::UseCompOnly),
|
||||
"none" | "usenone" | "usenoproxies" => Some(ProxyUse::UseNone),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether an item's proxy is used: `comp` = the item is a composition, `enabled` = its Use
|
||||
/// Proxy switch.
|
||||
pub fn uses(self, comp: bool, enabled: bool) -> bool {
|
||||
match self {
|
||||
ProxyUse::CurrentSettings => enabled,
|
||||
ProxyUse::UseAll => true,
|
||||
ProxyUse::UseCompOnly => comp,
|
||||
ProxyUse::UseNone => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RenderSettings {
|
||||
|
|
@ -52,6 +97,8 @@ pub struct RenderSettings {
|
|||
pub motion_blur: bool,
|
||||
/// Image sequences: skip frames whose file already exists.
|
||||
pub skip_existing: bool,
|
||||
/// Proxy Use (Best Settings: Use No Proxies).
|
||||
pub proxy_use: ProxyUse,
|
||||
}
|
||||
|
||||
impl Default for RenderSettings {
|
||||
|
|
@ -63,6 +110,7 @@ impl Default for RenderSettings {
|
|||
frame_rate: None,
|
||||
motion_blur: true,
|
||||
skip_existing: false,
|
||||
proxy_use: ProxyUse::UseNone,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -419,6 +467,8 @@ pub enum PostRenderAction {
|
|||
Import,
|
||||
/// Import it and replace every use of the rendered composition (Composition ▸ Pre-render).
|
||||
ImportAndReplace,
|
||||
/// Set the rendered file as the composition's proxy (File ▸ Create Proxy).
|
||||
SetProxy,
|
||||
}
|
||||
|
||||
impl PostRenderAction {
|
||||
|
|
@ -430,6 +480,7 @@ impl PostRenderAction {
|
|||
PostRenderAction::None => "None",
|
||||
PostRenderAction::Import => "Import",
|
||||
PostRenderAction::ImportAndReplace => "Import & Replace Usage",
|
||||
PostRenderAction::SetProxy => "Set Proxy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -286,6 +286,8 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool
|
|||
let it = ctx.project.item(*item)?;
|
||||
let ItemKind::Footage(f) = &it.kind else { return None };
|
||||
hash_debug(&mut h, f);
|
||||
// A proxy (and its Use Proxy switch) changes the pixels.
|
||||
hash_debug(&mut h, &it.proxy);
|
||||
item.hash(&mut h);
|
||||
ctx.source_time(layer).0.hash(&mut h);
|
||||
// Frame blending mixes neighbouring source frames.
|
||||
|
|
|
|||
|
|
@ -120,6 +120,28 @@ impl<'a> EvalCtx<'a> {
|
|||
|
||||
/// Local (parent-space) transform of a layer.
|
||||
pub fn local_matrix(&self, layer: &Layer) -> Mat4 {
|
||||
self.local_matrix_par(layer, 1.0)
|
||||
}
|
||||
|
||||
/// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps
|
||||
/// with another pixel aspect) is stretched horizontally by this factor in the comp.
|
||||
pub fn par_ratio(&self, layer: &Layer) -> f64 {
|
||||
let par = match &layer.source {
|
||||
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) {
|
||||
Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect,
|
||||
Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect,
|
||||
Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect,
|
||||
_ => 1.0,
|
||||
},
|
||||
_ => 1.0,
|
||||
};
|
||||
let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 };
|
||||
if r.is_finite() && r > 0.0 && (r - 1.0).abs() > 1e-9 { r } else { 1.0 }
|
||||
}
|
||||
|
||||
/// [`Self::local_matrix`] with the source's pixel aspect folded into the scale (`par`; 1 for
|
||||
/// a parent's matrix, which children don't inherit).
|
||||
fn local_matrix_par(&self, layer: &Layer, par: f64) -> Mat4 {
|
||||
let Some(tr) = layer.transform() else { return Mat4::IDENTITY };
|
||||
let three = layer.is_3d();
|
||||
let pos = self.position(layer, tr);
|
||||
|
|
@ -130,6 +152,7 @@ impl<'a> EvalCtx<'a> {
|
|||
}
|
||||
let anchor = self.v3(layer, tr, "anchor", [0.0; 3]);
|
||||
let mut scale = self.v3(layer, tr, "scale", [100.0; 3]);
|
||||
scale[0] *= par;
|
||||
if !three {
|
||||
scale[2] = 100.0;
|
||||
let a = Vec3::from([anchor[0], anchor[1], 0.0]);
|
||||
|
|
@ -154,7 +177,7 @@ impl<'a> EvalCtx<'a> {
|
|||
|
||||
/// Layer space → comp (world) space, including parents.
|
||||
pub fn world_matrix(&self, layer: &Layer) -> Mat4 {
|
||||
let mut m = self.local_matrix(layer);
|
||||
let mut m = self.local_matrix_par(layer, self.par_ratio(layer));
|
||||
let mut cur = layer.parent;
|
||||
let mut guard = 0;
|
||||
while let Some(pid) = cur {
|
||||
|
|
|
|||
|
|
@ -247,11 +247,22 @@ pub struct RenderOpts {
|
|||
/// Region of interest `[x, y, w, h]` in comp pixels (viewer only): the top-level frame covers
|
||||
/// just this rectangle, and only its pixels are composited.
|
||||
pub roi: Option<[f64; 4]>,
|
||||
/// Which proxies stand in for footage and compositions (Render Settings ▸ Proxy Use).
|
||||
pub proxy: effectcraft_project::render_queue::ProxyUse,
|
||||
}
|
||||
|
||||
impl Default for RenderOpts {
|
||||
fn default() -> Self {
|
||||
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, roi: None, backend: Backend::Cpu }
|
||||
RenderOpts {
|
||||
scale: 1.0,
|
||||
motion_blur: true,
|
||||
guides: false,
|
||||
draft: false,
|
||||
view: None,
|
||||
roi: None,
|
||||
backend: Backend::Cpu,
|
||||
proxy: effectcraft_project::render_queue::ProxyUse::CurrentSettings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -675,6 +686,13 @@ impl<'a> Renderer<'a> {
|
|||
if self.project.comp_contains(*item, ctx.comp_id) {
|
||||
return None;
|
||||
}
|
||||
// A composition proxy (a rendered still or movie) stands in for the comp.
|
||||
if let Some(pf) = self.proxy_for(*item)
|
||||
&& let Some(nc) = self.project.comp(*item)
|
||||
{
|
||||
let img = self.footage.frame(*item, pf, ctx.source_time(layer))?;
|
||||
return Some(self.footage_buf(&img, pf, nc.width, nc.height));
|
||||
}
|
||||
// Essential Properties overrides render the nested comp with this instance's
|
||||
// values.
|
||||
let ov = essential_overrides(ctx, layer);
|
||||
|
|
@ -706,20 +724,10 @@ impl<'a> Renderer<'a> {
|
|||
return None;
|
||||
}
|
||||
let lt = ctx.source_time(layer);
|
||||
let img = self.footage_frame(ctx, layer, *item, f, lt)?;
|
||||
let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 };
|
||||
// Keep native pixels when downsampling is small; resample otherwise.
|
||||
let mut buf = if s < 0.75 {
|
||||
let w = ((f.width as f64 * s).round() as u32).max(1);
|
||||
let h = ((f.height as f64 * s).round() as u32).max(1);
|
||||
Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s }
|
||||
} else {
|
||||
Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k }
|
||||
};
|
||||
if let Some(c) = self.pipe.media_in(f.color_profile) {
|
||||
color::convert(&mut buf.img, &c);
|
||||
}
|
||||
Some(buf)
|
||||
// The proxy is decoded instead, at its own size, and fills the footage's frame.
|
||||
let pf = self.proxy_for(*item).unwrap_or(f);
|
||||
let img = self.footage_frame(ctx, layer, *item, pf, lt)?;
|
||||
Some(self.footage_buf(&img, pf, f.width, f.height))
|
||||
}
|
||||
LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))),
|
||||
LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))),
|
||||
|
|
@ -727,6 +735,32 @@ impl<'a> Renderer<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The proxy footage used for `item` under this render's Proxy Use, if any.
|
||||
pub fn proxy_for(&self, item: ItemId) -> Option<&'a Footage> {
|
||||
let it = self.project.item(item)?;
|
||||
let px = it.proxy.as_deref()?;
|
||||
(self.opts.proxy.uses(matches!(it.kind, ItemKind::Comp(_)), px.enabled) && px.footage.has_video).then_some(&px.footage)
|
||||
}
|
||||
|
||||
/// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's
|
||||
/// nominal size; proxies are scaled up to it), converted from its colour profile.
|
||||
fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf {
|
||||
let s = self.opts.scale;
|
||||
let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 };
|
||||
// Keep native pixels when downsampling is small; resample otherwise.
|
||||
let mut buf = if s < 0.75 && k <= 1.0 + 1e-9 {
|
||||
let rw = ((w as f64 * s).round() as u32).max(1);
|
||||
let rh = ((h as f64 * s).round() as u32).max(1);
|
||||
Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s }
|
||||
} else {
|
||||
Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k }
|
||||
};
|
||||
if let Some(c) = self.pipe.media_in(f.color_profile) {
|
||||
color::convert(&mut buf.img, &c);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Authored colours into the working space (linear working spaces).
|
||||
fn authored(&self, mut buf: Buf) -> Buf {
|
||||
if let Some(c) = self.pipe.authored_in() {
|
||||
|
|
@ -739,6 +773,21 @@ impl<'a> Renderer<'a> {
|
|||
/// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and
|
||||
/// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping).
|
||||
fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
// Interpret Footage ▸ Separate Fields: each field is a frame at twice the rate.
|
||||
if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
|
||||
let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den);
|
||||
let i = field_rate.frame_at(t).max(0);
|
||||
let img = self.footage.frame(item, f, f.frame_rate.tick_of(i / 2))?;
|
||||
let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst;
|
||||
// Upper field = even lines (0, 2, …).
|
||||
let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 };
|
||||
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f)));
|
||||
}
|
||||
let img = self.footage_frame_raw(ctx, layer, item, f, t)?;
|
||||
if f.invert_alpha || f.linear_light { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) }
|
||||
}
|
||||
|
||||
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
let mode = frame_blend_mode(ctx, layer);
|
||||
if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
|
||||
let (i, w) = frame_position(t, f.frame_rate);
|
||||
|
|
@ -1307,6 +1356,57 @@ impl<'a> Renderer<'a> {
|
|||
}
|
||||
|
||||
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
|
||||
/// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even /
|
||||
/// upper, 1 = odd / lower) kept, the other lines interpolated from their neighbours.
|
||||
pub fn field_frame(img: &Image, parity: usize) -> Image {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let mut out = img.clone();
|
||||
if h < 2 {
|
||||
return out;
|
||||
}
|
||||
for y in 0..h {
|
||||
if y % 2 == parity {
|
||||
continue;
|
||||
}
|
||||
let above = y.checked_sub(1);
|
||||
let below = (y + 1 < h).then_some(y + 1);
|
||||
for x in 0..w {
|
||||
let px = match (above, below) {
|
||||
(Some(a), Some(b)) => {
|
||||
let (p, q) = (img.data[a * w + x], img.data[b * w + x]);
|
||||
[(p[0] + q[0]) * 0.5, (p[1] + q[1]) * 0.5, (p[2] + q[2]) * 0.5, (p[3] + q[3]) * 0.5]
|
||||
}
|
||||
(Some(a), None) => img.data[a * w + x],
|
||||
(None, Some(b)) => img.data[b * w + x],
|
||||
(None, None) => img.data[y * w + x],
|
||||
};
|
||||
out.data[y * w + x] = px;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied)
|
||||
/// pixels.
|
||||
pub fn interpret_pixels(img: &Image, f: &Footage) -> Image {
|
||||
if !f.invert_alpha && !f.linear_light {
|
||||
return img.clone();
|
||||
}
|
||||
let space = f.color_profile.unwrap_or(effectcraft_color::ColorSpace::Srgb);
|
||||
let mut out = img.clone();
|
||||
for p in out.data.iter_mut() {
|
||||
let a = p[3];
|
||||
let mut c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] };
|
||||
if f.linear_light {
|
||||
// The file's values are linear light: encode them like the rest of the footage.
|
||||
c = c.map(|v| space.encode(v));
|
||||
}
|
||||
let a = if f.invert_alpha { 1.0 - a } else { a };
|
||||
*p = [c[0] * a, c[1] * a, c[2] * a, a];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn frame_blend_mode(ctx: &EvalCtx, layer: &Layer) -> FrameBlend {
|
||||
if ctx.comp.enable_frame_blending { layer.switches.frame_blend } else { FrameBlend::Off }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -419,6 +419,7 @@ impl EffectcraftApp {
|
|||
view: self.session.view_camera(comp),
|
||||
roi,
|
||||
backend: effectcraft_engine::render::Backend::Auto,
|
||||
proxy: Default::default(),
|
||||
};
|
||||
if urgent {
|
||||
self.frames.request_urgent(&self.render_source(), key, comp, t, opts);
|
||||
|
|
|
|||
Loading…
Reference in New Issue