mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
CMP-7: Essential Graphics panel, expression error bar and Expression Language menu, proxy indicator, Interpret Footage dialog
Window > Essential Graphics: Primary comp, Solo Supported Properties (timeline filter), template name, typed controls editing the source properties, drag a property name from the timeline onto the list, rename/reorder/remove, Add Formatting (group, comment), Media Replacement, Export Template, and the selected instance's Essential Properties with Revert / Push. The comp viewer shows an expression error bar (click to reveal); the expression editor's f button opens the categorised Expression Language menu. Project panel proxy switch; Interpret Footage dialog gains Guess, matte colour, Invert Alpha, Separate Fields, Linear Light, Proxy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c33b80614a
commit
2cba455175
@@ -65,7 +65,8 @@ fn set_primary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_name(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = eg_comp(s, p)?;
|
||||
let name = str_p(p, "name").ok_or_else(|| bad("essential.setName", "missing `name`"))?.to_string();
|
||||
s.edit("Essential Graphics Name", None, |proj, _| {
|
||||
s.edit("Essential Graphics Name", None, |proj, st| {
|
||||
st.essential_primary.get_or_insert(cid);
|
||||
eg_of(proj, cid)?.name = name.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -99,7 +100,10 @@ fn add_property(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
added.push((*cid, *lid, *uid, rename.clone().unwrap_or_else(|| pr.name.clone())));
|
||||
}
|
||||
let ids = s.edit("Add Property to Essential Graphics", None, |proj, _| {
|
||||
let ids = s.edit("Add Property to Essential Graphics", None, |proj, st| {
|
||||
if let Some((cid, ..)) = added.first() {
|
||||
st.essential_primary.get_or_insert(*cid);
|
||||
}
|
||||
let mut ids = vec![];
|
||||
for (k, (cid, lid, uid, name)) in added.iter().enumerate() {
|
||||
let id = proj.alloc();
|
||||
@@ -126,7 +130,8 @@ fn add_media(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("{} (Media)", l.name));
|
||||
let group = p.get("group").and_then(Value::as_u64);
|
||||
let id = s.edit("Add Media Replacement", None, |proj, _| {
|
||||
let id = s.edit("Add Media Replacement", None, |proj, st| {
|
||||
st.essential_primary.get_or_insert(cid);
|
||||
let id = proj.alloc();
|
||||
let eg = eg_of(proj, cid)?;
|
||||
if eg.flat().iter().any(|c| matches!(c.kind, EgKind::Media { layer } if layer == lid)) {
|
||||
@@ -142,7 +147,8 @@ fn add_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = eg_comp(s, p)?;
|
||||
let name = str_p(p, "name").unwrap_or("Group").to_string();
|
||||
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
let id = s.edit("Add Essential Graphics Group", None, |proj, _| {
|
||||
let id = s.edit("Add Essential Graphics Group", None, |proj, st| {
|
||||
st.essential_primary.get_or_insert(cid);
|
||||
let id = proj.alloc();
|
||||
insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] } }, None, index, "essential.addGroup")?;
|
||||
Ok(id)
|
||||
@@ -154,7 +160,8 @@ fn add_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = eg_comp(s, p)?;
|
||||
let text = str_p(p, "text").unwrap_or("Comment").to_string();
|
||||
let group = p.get("group").and_then(Value::as_u64);
|
||||
let id = s.edit("Add Essential Graphics Comment", None, |proj, _| {
|
||||
let id = s.edit("Add Essential Graphics Comment", None, |proj, st| {
|
||||
st.essential_primary.get_or_insert(cid);
|
||||
let id = proj.alloc();
|
||||
insert(eg_of(proj, cid)?, EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text } }, group, None, "essential.addComment")?;
|
||||
Ok(id)
|
||||
|
||||
@@ -212,7 +212,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)>
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer ▸ Sub-objects",
|
||||
"Layer > Sub-objects",
|
||||
vec![
|
||||
("source", "source"),
|
||||
("effect(name)", "effect(\"name\")"),
|
||||
@@ -222,7 +222,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)>
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer ▸ General",
|
||||
"Layer > General",
|
||||
vec![
|
||||
("width", "width"),
|
||||
("height", "height"),
|
||||
@@ -238,7 +238,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)>
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer ▸ Properties",
|
||||
"Layer > Properties",
|
||||
vec![
|
||||
("anchorPoint", "anchorPoint"),
|
||||
("position", "position"),
|
||||
@@ -250,7 +250,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)>
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer ▸ Space Transforms",
|
||||
"Layer > Space Transforms",
|
||||
vec![
|
||||
("toComp(point, t = time)", "toComp(point, t = time)"),
|
||||
("fromComp(point, t = time)", "fromComp(point, t = time)"),
|
||||
@@ -263,7 +263,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)>
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer ▸ Other",
|
||||
"Layer > Other",
|
||||
vec![
|
||||
("sourceRectAtTime(t = time, includeExtents = false)", "sourceRectAtTime(t = time, includeExtents = false)"),
|
||||
("sampleImage(point, radius = [.5, .5], postEffect = true, t = time)", "sampleImage(point, radius = [.5, .5], postEffect = true, t = time)"),
|
||||
@@ -307,7 +307,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)>
|
||||
],
|
||||
),
|
||||
(
|
||||
"Text ▸ Style",
|
||||
"Text > Style",
|
||||
vec![
|
||||
("text.sourceText.style", "text.sourceText.style"),
|
||||
("getStyleAt(charIndex, t = time)", "getStyleAt(charIndex, t = time)"),
|
||||
@@ -317,7 +317,7 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)>
|
||||
("setText(value)", "setText(value)"),
|
||||
],
|
||||
),
|
||||
("Text ▸ Expression Selector", vec![("textIndex", "textIndex"), ("textTotal", "textTotal"), ("selectorValue", "selectorValue")]),
|
||||
("Text > Expression Selector", vec![("textIndex", "textIndex"), ("textTotal", "textTotal"), ("selectorValue", "selectorValue")]),
|
||||
("Key", vec![("value", "value"), ("time", "time"), ("index", "index")]),
|
||||
(
|
||||
"Marker Key",
|
||||
|
||||
@@ -34,10 +34,11 @@ pub enum PanelKind {
|
||||
History,
|
||||
Markers,
|
||||
MaskInterpolation,
|
||||
EssentialGraphics,
|
||||
}
|
||||
|
||||
impl PanelKind {
|
||||
pub const ALL: [PanelKind; 24] = [
|
||||
pub const ALL: [PanelKind; 25] = [
|
||||
PanelKind::Project,
|
||||
PanelKind::EffectControls,
|
||||
PanelKind::Composition,
|
||||
@@ -62,6 +63,7 @@ impl PanelKind {
|
||||
PanelKind::History,
|
||||
PanelKind::Markers,
|
||||
PanelKind::MaskInterpolation,
|
||||
PanelKind::EssentialGraphics,
|
||||
];
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
@@ -89,6 +91,7 @@ impl PanelKind {
|
||||
PanelKind::History => "History",
|
||||
PanelKind::Markers => "Markers",
|
||||
PanelKind::MaskInterpolation => "Mask Interpolation",
|
||||
PanelKind::EssentialGraphics => "Essential Graphics",
|
||||
}
|
||||
}
|
||||
pub fn id(self) -> String {
|
||||
|
||||
@@ -700,6 +700,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||
"file.runScript" => ("path", Ask::Open(&["jsonl", "json", "txt"])),
|
||||
"file.replaceFootage" => ("path", Ask::Import),
|
||||
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
|
||||
"essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")),
|
||||
"essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])),
|
||||
"file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])),
|
||||
_ => return None,
|
||||
};
|
||||
if params.get(key).is_some()
|
||||
@@ -716,7 +719,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
|
||||
let paths = f(&[
|
||||
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac",
|
||||
"ogg", "opus", "svg", "gltf", "glb", "obj",
|
||||
"ogg", "opus", "svg", "gltf", "glb", "obj", "json", "csv", "tsv",
|
||||
]);
|
||||
match (paths.is_empty(), key) {
|
||||
(true, _) => None,
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
//! Essential Graphics panel (Window ▸ Essential Graphics).
|
||||
//!
|
||||
//! - **Primary** composition, **Solo Supported Properties** (the timeline lists only what can
|
||||
//! be exposed) and the template name.
|
||||
//! - The controls: drag a property name from the timeline onto the list (or Animation ▸ Add
|
||||
//! Property to Essential Graphics); each shows as a text / colour / slider / checkbox / point /
|
||||
//! angle / dropdown control editing the source property; rename by double-clicking the name;
|
||||
//! reorder with ▲▼, remove with ✕; groups and comments from Add Formatting; Media
|
||||
//! Replacement for the selected footage layer.
|
||||
//! - **Export Template…** writes an open `.ectemplate`.
|
||||
//! - With a precomp layer that has Essential Properties selected, its instance values: edit them
|
||||
//! (overrides), Revert or Push to Comp.
|
||||
//!
|
||||
//! Every control runs an engine `essential.*` / `prop.set` command and registers an automation
|
||||
//! id (`essential.primary`, `essential.solo`, `essential.name`, `essential.control.<id>.value`,
|
||||
//! `essential.instance.<id>.value`, `essential.export`…).
|
||||
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
use effectcraft_engine::project::essential::{self, ControlType, EgControl, EgKind};
|
||||
use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, ParamUi, Property};
|
||||
use egui::{Rect, RichText};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::EffectcraftApp;
|
||||
use crate::panels::DragPayload;
|
||||
|
||||
type Actions = Vec<(String, Value)>;
|
||||
|
||||
/// The comp the panel edits: the Primary comp, else the active one.
|
||||
fn primary(app: &EffectcraftApp) -> Option<ItemId> {
|
||||
let s = &app.session;
|
||||
s.state.essential_primary.filter(|c| s.project.comp(*c).is_some()).or(s.active_comp_id())
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(8.0)).layout(egui::Layout::top_down(egui::Align::Min)));
|
||||
let mut out = None;
|
||||
egui::ScrollArea::vertical().id_salt("eg-panel").auto_shrink([false, false]).show(&mut child, |ui| out = Some(body(app, ui)));
|
||||
let Some((actions, invoke)) = out else { return };
|
||||
for (id, p) in actions {
|
||||
if let Err(e) = app.session.execute(&id, p) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
for (id, p) in invoke {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, &id, p) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut actions: Actions = vec![];
|
||||
let mut invoke: Vec<(String, Value)> = vec![];
|
||||
let Some(cid) = primary(app) else {
|
||||
ui.label(RichText::new("Open a composition to build Essential Graphics").color(t.text_faint));
|
||||
return (actions, invoke);
|
||||
};
|
||||
let project = app.session.project.clone();
|
||||
let comp_name = project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||||
|
||||
// ---- Primary + Solo.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Primary:").color(t.text_dim));
|
||||
let r = egui::ComboBox::from_id_salt("eg-primary").selected_text(&comp_name).width(150.0).show_ui(ui, |ui| {
|
||||
for (id, _) in project.comps() {
|
||||
let name = project.item(*id).map(|i| i.name.clone()).unwrap_or_default();
|
||||
if ui.selectable_label(*id == cid, name).clicked() {
|
||||
actions.push(("essential.setPrimary".into(), json!({"comp": id.0})));
|
||||
}
|
||||
}
|
||||
});
|
||||
app.auto.add("essential.primary", r.response.rect, &comp_name);
|
||||
});
|
||||
let solo = app.session.state.essential_solo;
|
||||
let r = ui.add(egui::Button::new("Solo Supported Properties").selected(solo));
|
||||
app.auto.add("essential.solo", r.rect, "Solo Supported Properties");
|
||||
if r.clicked() {
|
||||
actions.push(("essential.soloSupported".into(), json!({"on": !solo})));
|
||||
}
|
||||
// ---- Name.
|
||||
let eg = project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default();
|
||||
let shown_name = if eg.name.is_empty() { comp_name.clone() } else { eg.name.clone() };
|
||||
let name_id = egui::Id::new("eg-name-buf");
|
||||
let mut name: String = ctx.data(|d| d.get_temp(name_id)).unwrap_or_else(|| shown_name.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text("Template name").desired_width(f32::INFINITY));
|
||||
app.auto.add("essential.name", r.rect, &shown_name);
|
||||
if r.has_focus() {
|
||||
ctx.data_mut(|d| d.insert_temp(name_id, name.clone()));
|
||||
} else {
|
||||
if r.lost_focus() && name.trim() != shown_name {
|
||||
actions.push(("essential.setName".into(), json!({"comp": cid.0, "name": name.trim()})));
|
||||
}
|
||||
ctx.data_mut(|d| d.remove::<String>(name_id));
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
// ---- Controls (drop target for timeline properties).
|
||||
let list = ui.scope(|ui| {
|
||||
ui.set_min_height(60.0);
|
||||
if eg.controls.is_empty() {
|
||||
ui.label(RichText::new("Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.").color(t.text_faint));
|
||||
}
|
||||
controls(app, ui, cid, &eg.controls, None, &mut actions);
|
||||
});
|
||||
let drop_rect = list.response.rect.expand2(egui::vec2(0.0, 4.0));
|
||||
app.auto.add("essential.dropZone", drop_rect, "Drag properties here");
|
||||
if let Some(payload) = egui::DragAndDrop::payload::<DragPayload>(&ctx)
|
||||
&& let DragPayload::Property { layer, prop } = *payload
|
||||
&& ui.rect_contains_pointer(drop_rect)
|
||||
{
|
||||
ui.painter().rect_stroke(drop_rect, 2.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
let comp = app.session.active_comp_id().map(|c| c.0);
|
||||
actions.push(("essential.addProperty".into(), json!({"comp": comp, "layer": layer, "prop": prop})));
|
||||
egui::DragAndDrop::clear_payload(&ctx);
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
// ---- Bottom buttons.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let r = ui.menu_button("Add Formatting", |ui| {
|
||||
if ui.button("Add Group").clicked() {
|
||||
actions.push(("essential.addGroup".into(), json!({"comp": cid.0})));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Add Comment").clicked() {
|
||||
actions.push(("essential.addComment".into(), json!({"comp": cid.0})));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
app.auto.add("essential.addFormatting", r.response.rect, "Add Formatting");
|
||||
let footage_layer = app.session.active_comp().and_then(|c| {
|
||||
app.session.state.selected_layers.iter().filter_map(|l| c.layer(*l)).find(|l| matches!(l.source, LayerSource::Footage { .. })).map(|l| l.id.0)
|
||||
});
|
||||
let r = ui.add_enabled(footage_layer.is_some(), egui::Button::new("Media Replacement"));
|
||||
app.auto.add("essential.addMedia", r.rect, "Add Media Replacement");
|
||||
if r.clicked()
|
||||
&& let Some(l) = footage_layer
|
||||
{
|
||||
actions.push(("essential.addMedia".into(), json!({"layer": l})));
|
||||
}
|
||||
let r = ui.add_enabled(!eg.controls.is_empty(), egui::Button::new("Export Template…"));
|
||||
app.auto.add("essential.export", r.rect, "Export Template");
|
||||
if r.clicked() {
|
||||
invoke.push(("essential.exportTemplate".into(), json!({"comp": cid.0})));
|
||||
}
|
||||
});
|
||||
|
||||
// ---- The selected instance's Essential Properties.
|
||||
instance(app, ui, &mut actions);
|
||||
(actions, invoke)
|
||||
}
|
||||
|
||||
/// One list of controls (top level or a group's children).
|
||||
fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[EgControl], group: Option<u64>, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
let n = list.len();
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
let row = ui.horizontal(|ui| {
|
||||
// Reorder / remove.
|
||||
for (label, auto, enabled, to) in [("▲", "up", i > 0, i.saturating_sub(1)), ("▼", "down", i + 1 < n, i + 1)] {
|
||||
let r = ui.add_enabled(enabled, egui::Button::new(RichText::new(label).size(9.0)).small().frame(false));
|
||||
app.auto.add(&format!("essential.control.{}.{auto}", c.id), r.rect, label);
|
||||
if r.clicked() {
|
||||
actions.push(("essential.move".into(), json!({"comp": cid.0, "control": c.id, "group": group, "index": to})));
|
||||
}
|
||||
}
|
||||
match &c.kind {
|
||||
EgKind::Group { .. } => {
|
||||
name_label(app, ui, cid, c, RichText::new(&c.name).strong(), actions);
|
||||
}
|
||||
EgKind::Comment { text } => {
|
||||
let shown = EgControl { name: text.clone(), ..c.clone() };
|
||||
name_label(app, ui, cid, &shown, RichText::new(text).italics().color(t.text_dim), actions);
|
||||
}
|
||||
EgKind::Property { .. } => {
|
||||
name_label(app, ui, cid, c, RichText::new(&c.name), actions);
|
||||
let src = essential::source_prop(&app.session.project, cid, c).map(|(l, p)| (l.id.0, l.layer_time(app.session.time()), p.clone()));
|
||||
match src {
|
||||
Some((layer, lt, p)) => {
|
||||
let v = p.value_at(lt);
|
||||
if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), &p, &v) {
|
||||
actions.push((
|
||||
"prop.set".into(),
|
||||
json!({"comp": cid.0, "layer": layer, "prop": p.uid, "value": nv, "merge": format!("eg-{}", c.id)}),
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label(RichText::new("(missing)").color(t.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
EgKind::Media { .. } => {
|
||||
name_label(app, ui, cid, c, RichText::new(&c.name), actions);
|
||||
let item = essential::source_media(&app.session.project, cid, c);
|
||||
let name = item.and_then(|i| app.session.project.item(i)).map(|i| i.name.clone()).unwrap_or_default();
|
||||
ui.label(RichText::new(format!("▣ {name}")).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
let r = ui.add(egui::Button::new(RichText::new("×").size(10.0)).small().frame(false)).on_hover_text("Remove");
|
||||
app.auto.add(&format!("essential.control.{}.remove", c.id), r.rect, "Remove");
|
||||
if r.clicked() {
|
||||
actions.push(("essential.remove".into(), json!({"comp": cid.0, "control": c.id})));
|
||||
}
|
||||
});
|
||||
app.auto.add(&format!("essential.control.{}.row", c.id), row.response.rect, &c.name);
|
||||
if let EgKind::Group { children } = &c.kind {
|
||||
ui.indent(("eg-group", c.id), |ui| controls(app, ui, cid, children, Some(c.id), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A control's name; double-click to rename (comments: edit the text).
|
||||
fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgControl, text: RichText, actions: &mut Actions) {
|
||||
let edit_id = egui::Id::new(("eg-rename", c.id));
|
||||
let editing: Option<String> = ui.ctx().data(|d| d.get_temp(edit_id));
|
||||
match editing {
|
||||
Some(mut buf) => {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(120.0));
|
||||
app.auto.add(&format!("essential.control.{}.rename", c.id), r.rect, &c.name);
|
||||
r.request_focus();
|
||||
if r.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
if !buf.trim().is_empty() && buf.trim() != c.name {
|
||||
actions.push(("essential.rename".into(), json!({"comp": cid.0, "control": c.id, "name": buf.trim()})));
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.remove::<String>(edit_id));
|
||||
} else {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(edit_id, buf));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let r = ui.add(egui::Label::new(text).sense(egui::Sense::click()).truncate());
|
||||
app.auto.add(&format!("essential.control.{}.name", c.id), r.rect, &c.name);
|
||||
if r.double_clicked() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(edit_id, c.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The editor for a control's value; returns the new value (JSON for `prop.set`).
|
||||
fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Property, v: &KV) -> Option<Value> {
|
||||
let ty = essential::control_type(p)?;
|
||||
let mut out = None;
|
||||
let rect;
|
||||
match ty {
|
||||
ControlType::Text => {
|
||||
let cur = match v {
|
||||
KV::Text(d) => d.text.clone(),
|
||||
KV::Str(s) => s.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let id = egui::Id::new(("eg-text", auto));
|
||||
let mut buf: String = ui.ctx().data(|d| d.get_temp(id)).unwrap_or_else(|| cur.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0));
|
||||
if r.has_focus() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id, buf));
|
||||
} else {
|
||||
if r.lost_focus() && buf != cur {
|
||||
out = Some(json!(buf));
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.remove::<String>(id));
|
||||
}
|
||||
rect = r.rect;
|
||||
}
|
||||
ControlType::Color => {
|
||||
let c = v.as_color();
|
||||
let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]);
|
||||
let r = egui::widgets::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::widgets::color_picker::Alpha::Opaque);
|
||||
if r.changed() {
|
||||
let u = rgba.to_rgba_unmultiplied();
|
||||
out = Some(json!([u[0], u[1], u[2], u[3]]));
|
||||
}
|
||||
rect = r.rect;
|
||||
}
|
||||
ControlType::Checkbox => {
|
||||
let mut on = v.as_bool();
|
||||
let r = ui.checkbox(&mut on, "");
|
||||
if r.changed() {
|
||||
out = Some(json!(on));
|
||||
}
|
||||
rect = r.rect;
|
||||
}
|
||||
ControlType::Dropdown => {
|
||||
let opts = match &p.ui {
|
||||
ParamUi::Popup { options } => options.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
let cur = v.as_enum() as usize;
|
||||
let r = egui::ComboBox::from_id_salt(("eg-dd", auto)).selected_text(opts.get(cur).cloned().unwrap_or_default()).show_ui(ui, |ui| {
|
||||
for (i, o) in opts.iter().enumerate() {
|
||||
if ui.selectable_label(i == cur, o).clicked() {
|
||||
out = Some(json!(i));
|
||||
}
|
||||
}
|
||||
});
|
||||
rect = r.response.rect;
|
||||
}
|
||||
ControlType::Point => {
|
||||
let mut a = v.as_vec2();
|
||||
let r1 = ui.add(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1));
|
||||
let r2 = ui.add(egui::DragValue::new(&mut a[1]).speed(1.0).max_decimals(1));
|
||||
if r1.changed() || r2.changed() {
|
||||
out = Some(json!(a));
|
||||
}
|
||||
rect = r1.rect.union(r2.rect);
|
||||
}
|
||||
ControlType::Angle => {
|
||||
let mut a = v.as_f64();
|
||||
let r = ui.add(egui::DragValue::new(&mut a).speed(1.0).suffix("°").max_decimals(1));
|
||||
if r.changed() {
|
||||
out = Some(json!(a));
|
||||
}
|
||||
rect = r.rect;
|
||||
}
|
||||
ControlType::Slider | ControlType::Media => {
|
||||
let mut x = v.as_f64();
|
||||
let r = match p.ui {
|
||||
ParamUi::Slider { slider_min, slider_max, .. } => ui.add(egui::Slider::new(&mut x, slider_min..=slider_max).max_decimals(2)),
|
||||
_ => ui.add(egui::DragValue::new(&mut x).speed(0.5).max_decimals(2)),
|
||||
};
|
||||
if r.changed() {
|
||||
out = Some(json!(x));
|
||||
}
|
||||
rect = r.rect;
|
||||
}
|
||||
}
|
||||
app.auto.add(auto, rect, &p.name);
|
||||
out
|
||||
}
|
||||
|
||||
/// Essential Properties of the selected precomp layer: per-instance values, overrides marked ●,
|
||||
/// Revert and Push to Comp.
|
||||
fn instance(app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
let Some(comp) = app.session.active_comp().cloned() else { return };
|
||||
let Some(layer) = app.session.state.selected_layers.iter().filter_map(|l| comp.layer(*l)).find(|l| essential::group(l).is_some()).cloned() else { return };
|
||||
let LayerSource::Comp { item: src } = layer.source else { return };
|
||||
let Some(eg) = app.session.project.comp(src).and_then(|c| c.essential.clone()) else { return };
|
||||
let over = essential::overridden(&layer);
|
||||
let lt = layer.layer_time(app.session.time());
|
||||
ui.separator();
|
||||
ui.label(RichText::new(format!("Essential Properties — {}", layer.name)).strong());
|
||||
let group = essential::group(&layer).cloned().unwrap_or_else(|| effectcraft_engine::project::PropGroup::new(0, "", ""));
|
||||
for c in eg.flat() {
|
||||
let m = essential::match_id(c.id);
|
||||
let mut pr = None;
|
||||
group.walk("", &mut |_, x| {
|
||||
if x.match_id == m {
|
||||
pr = Some(x.clone());
|
||||
}
|
||||
});
|
||||
let Some(p) = pr else { continue };
|
||||
let is_over = over.contains(&p.uid);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(if is_over { "●" } else { "○" }).color(if is_over { t.accent } else { t.text_faint })).on_hover_text(if is_over {
|
||||
"Overridden in this instance"
|
||||
} else {
|
||||
"Follows the source composition"
|
||||
});
|
||||
ui.label(&c.name);
|
||||
let auto = format!("essential.instance.{}.value", c.id);
|
||||
match c.kind {
|
||||
EgKind::Media { .. } => {
|
||||
let cur = ItemId(p.value.as_f64() as u64);
|
||||
let cur_name = app.session.project.item(cur).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let items: Vec<(ItemId, String)> = app
|
||||
.session
|
||||
.project
|
||||
.items
|
||||
.values()
|
||||
.filter(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_video))
|
||||
.map(|i| (i.id, i.name.clone()))
|
||||
.collect();
|
||||
let r = egui::ComboBox::from_id_salt(("eg-media", layer.id.0, c.id)).selected_text(cur_name).show_ui(ui, |ui| {
|
||||
for (id, name) in &items {
|
||||
if ui.selectable_label(*id == cur, name).clicked() {
|
||||
actions.push(("essential.set".into(), json!({"layer": layer.id.0, "control": c.id, "item": id.0})));
|
||||
}
|
||||
}
|
||||
});
|
||||
app.auto.add(&auto, r.response.rect, &c.name);
|
||||
}
|
||||
_ => {
|
||||
let v = p.value_at(lt);
|
||||
if let Some(nv) = value_widget(app, ui, &auto, &p, &v) {
|
||||
actions.push((
|
||||
"essential.set".into(),
|
||||
json!({"layer": layer.id.0, "control": c.id, "value": nv, "merge": format!("egi-{}-{}", layer.id.0, c.id)}),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_over {
|
||||
let r = ui.small_button("Revert");
|
||||
app.auto.add(&format!("essential.instance.{}.revert", c.id), r.rect, "Revert");
|
||||
if r.clicked() {
|
||||
actions.push(("essential.revert".into(), json!({"layer": layer.id.0, "control": c.id})));
|
||||
}
|
||||
let r = ui.small_button("Push");
|
||||
app.auto.add(&format!("essential.instance.{}.push", c.id), r.rect, "Push to Comp");
|
||||
if r.clicked() {
|
||||
actions.push(("essential.pushToComp".into(), json!({"layer": layer.id.0, "control": c.id})));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let any = !over.is_empty();
|
||||
let r = ui.add_enabled(any, egui::Button::new("Push All to Comp"));
|
||||
app.auto.add("essential.instance.pushAll", r.rect, "Push Override Values to Source");
|
||||
if r.clicked() {
|
||||
actions.push(("essential.pushToComp".into(), json!({"layer": layer.id.0})));
|
||||
}
|
||||
let r = ui.add_enabled(any, egui::Button::new("Revert All"));
|
||||
app.auto.add("essential.instance.revertAll", r.rect, "Revert All");
|
||||
if r.clicked() {
|
||||
actions.push(("essential.revert".into(), json!({"layer": layer.id.0})));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! The expression error bar: a strip at the bottom of the Composition viewer listing failing
|
||||
//! expressions of the comp at the current time (`expr.errors`), with ◀ ▶ to step through them.
|
||||
//! Clicking the message selects the layer and reveals the property in the timeline.
|
||||
//! Automation ids: `viewer.exprErrors`, `viewer.exprErrors.prev`, `viewer.exprErrors.next`.
|
||||
|
||||
use effectcraft_engine::commands::expr_tools::{self, ExprError};
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::EffectcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Height of the bar.
|
||||
pub const HEIGHT: f32 = 22.0;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Cache {
|
||||
key: (u64, u64, i64),
|
||||
errors: Vec<ExprError>,
|
||||
/// Shown error (index).
|
||||
current: usize,
|
||||
}
|
||||
|
||||
fn cache_id() -> egui::Id {
|
||||
egui::Id::new("expr-error-bar")
|
||||
}
|
||||
|
||||
/// The comp's failing expressions (recomputed when the project or the time changes; kept while
|
||||
/// playing).
|
||||
fn errors(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> Cache {
|
||||
let t = app.session.time_of(cid);
|
||||
let key = (app.session.revision, cid.0, t.0);
|
||||
let mut c: Cache = ctx.data(|d| d.get_temp(cache_id())).unwrap_or_default();
|
||||
let stale = c.key != key && !(app.playback.playing && c.key.1 == cid.0);
|
||||
if stale {
|
||||
c.errors = expr_tools::errors(&app.session, cid, t);
|
||||
c.key = key;
|
||||
c.current = c.current.min(c.errors.len().saturating_sub(1));
|
||||
ctx.data_mut(|d| d.insert_temp(cache_id(), c.clone()));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Height the bar needs for `cid` (0 when every expression evaluates).
|
||||
pub fn height(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> f32 {
|
||||
if app.session.expr.is_none() || errors(app, ctx, cid).errors.is_empty() { 0.0 } else { HEIGHT }
|
||||
}
|
||||
|
||||
pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut c = errors(app, &ctx, cid);
|
||||
if c.errors.is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
p.rect_filled(rect, 0.0, Color32::from_rgb(0x4a, 0x3a, 0x12));
|
||||
let warn = Color32::from_rgb(0xf5, 0xb0, 0x30);
|
||||
p.text(pos2(rect.min.x + 10.0, rect.center().y), Align2::LEFT_CENTER, "⚠", Tokens::ui(13.0), warn);
|
||||
let n = c.errors.len();
|
||||
let i = c.current.min(n - 1);
|
||||
// ◀ i/n ▶ on the right.
|
||||
let next = Rect::from_center_size(pos2(rect.max.x - 14.0, rect.center().y), vec2(16.0, 16.0));
|
||||
let count = Rect::from_center_size(pos2(rect.max.x - 44.0, rect.center().y), vec2(40.0, 16.0));
|
||||
let prev = Rect::from_center_size(pos2(rect.max.x - 74.0, rect.center().y), vec2(16.0, 16.0));
|
||||
for (r, label, auto, d) in [(prev, "◀", "viewer.exprErrors.prev", n - 1), (next, "▶", "viewer.exprErrors.next", 1)] {
|
||||
let resp = ui.interact(r, egui::Id::new(("expr-bar", auto)), Sense::click());
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if resp.hovered() { Color32::WHITE } else { t.text });
|
||||
app.auto.add(auto, r, label);
|
||||
if resp.clicked() {
|
||||
c.current = (i + d) % n;
|
||||
ctx.data_mut(|dd| dd.insert_temp(cache_id(), c.clone()));
|
||||
}
|
||||
}
|
||||
p.text(count.center(), Align2::CENTER_CENTER, format!("{}/{n}", i + 1), Tokens::ui(11.0), t.text);
|
||||
let comp_name = app.session.project.item(cid).map(|it| it.name.clone()).unwrap_or_default();
|
||||
let e = c.errors[i].clone();
|
||||
let msg_rect = Rect::from_min_max(pos2(rect.min.x + 26.0, rect.min.y), pos2(prev.min.x - 6.0, rect.max.y));
|
||||
let resp = ui.interact(msg_rect, egui::Id::new("expr-bar-msg"), Sense::click()).on_hover_text("Click to reveal the expression in the timeline");
|
||||
p.with_clip_rect(msg_rect).text(
|
||||
pos2(msg_rect.min.x, rect.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
e.describe(&comp_name),
|
||||
Tokens::ui(11.5),
|
||||
if resp.hovered() { Color32::WHITE } else { t.text },
|
||||
);
|
||||
app.auto.add("viewer.exprErrors", msg_rect, &e.describe(&comp_name));
|
||||
if resp.clicked() {
|
||||
let _ = app.session.execute("layer.select", json!({"comp": cid.0, "layers": [e.layer]}));
|
||||
let _ = app.session.execute("prop.select", json!({"comp": cid.0, "layer": e.layer, "prop": e.prop, "selectKeys": false}));
|
||||
let _ = crate::menus::frontend(app, &ctx, "timeline.revealProps", json!({"props": [{"layer": e.layer, "prop": e.prop}]}));
|
||||
app.ui.timeline.expr_closed.remove(&e.prop);
|
||||
}
|
||||
}
|
||||
@@ -218,27 +218,48 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
),
|
||||
],
|
||||
),
|
||||
"file.interpretFootage" if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile"]) => {
|
||||
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match &it.kind {
|
||||
effectcraft_engine::project::ItemKind::Footage(f) => Some(f.clone()),
|
||||
"file.interpretFootage" | "file.interpretProxy"
|
||||
if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight"]) =>
|
||||
{
|
||||
let proxy = id == "file.interpretProxy";
|
||||
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
|
||||
(_, Some(px)) if proxy => Some(px.footage.clone()),
|
||||
(effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let Some(f) = f else { return false };
|
||||
let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
|
||||
(
|
||||
"Interpret Footage".into(),
|
||||
if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() },
|
||||
vec![
|
||||
Field::num("frameRate", "Frame rate", f.frame_rate.as_f64()),
|
||||
// Main Options ▸ Alpha.
|
||||
Field::choice(
|
||||
"alpha",
|
||||
"Alpha",
|
||||
&[("Straight", json!("straight")), ("Premultiplied", json!("premultiplied")), ("Ignore", json!("ignore"))],
|
||||
&[
|
||||
("Interpret Straight - Unmatted", json!("straight")),
|
||||
("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
|
||||
("Ignore", json!("ignore")),
|
||||
("Guess", json!("guess")),
|
||||
],
|
||||
f.alpha as usize,
|
||||
),
|
||||
Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
|
||||
Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
|
||||
// Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
|
||||
Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
|
||||
Field::choice(
|
||||
"fields",
|
||||
"Separate Fields",
|
||||
&[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
|
||||
f.fields as usize,
|
||||
),
|
||||
Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
|
||||
Field::num("loop", "Loop (times)", f.loop_count as f64),
|
||||
Field::num("pixelAspect", "Pixel aspect ratio", f.pixel_aspect),
|
||||
// Color.
|
||||
Field::choice(
|
||||
"colorProfile",
|
||||
"Color profile",
|
||||
"Assign Profile",
|
||||
&[
|
||||
("Embedded / sRGB", json!("auto")),
|
||||
("sRGB IEC61966-2.1", json!("srgb")),
|
||||
@@ -248,6 +269,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
],
|
||||
f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
|
||||
),
|
||||
Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ pub mod dialogs;
|
||||
pub mod dialogs_3d;
|
||||
pub mod effect_controls;
|
||||
pub mod effects_presets;
|
||||
pub mod essential;
|
||||
pub mod expr_bar;
|
||||
pub mod forms;
|
||||
pub mod fx_widgets;
|
||||
pub mod graph;
|
||||
@@ -47,6 +49,8 @@ pub enum DragPayload {
|
||||
Item(u64),
|
||||
/// An effect id from Effects & Presets.
|
||||
Effect(String),
|
||||
/// A property dragged from the timeline (to the Essential Graphics panel).
|
||||
Property { layer: u64, prop: u64 },
|
||||
}
|
||||
|
||||
/// The current time formatted per project settings (timecode with `;` for drop-frame, or frames).
|
||||
@@ -90,6 +94,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
|
||||
PanelKind::Layer => layer_panel::show(app, ui, rect),
|
||||
PanelKind::Paint => paint_panels::paint(app, ui, rect),
|
||||
PanelKind::Brushes => paint_panels::brushes(app, ui, rect),
|
||||
PanelKind::EssentialGraphics => essential::show(app, ui, rect),
|
||||
other => misc::placeholder(app, ui, rect, other),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +322,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add(&format!("project.item.{}.twirl", id.0), tw, &it.name);
|
||||
}
|
||||
icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(&it), t.text_dim);
|
||||
// Proxy indicator: filled = the proxy is used, hollow = set but off. Click to switch.
|
||||
if let Some(px) = it.proxy.as_ref() {
|
||||
let pr = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(9.0, 9.0));
|
||||
if px.enabled {
|
||||
lp.rect_filled(pr, 1.0, t.text);
|
||||
} else {
|
||||
lp.rect_stroke(pr, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
|
||||
}
|
||||
let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::new(("pproxy", id.0)), Sense::click()).on_hover_text(format!(
|
||||
"Proxy: {} ({})",
|
||||
px.footage.path,
|
||||
if px.enabled { "in use" } else { "off" }
|
||||
));
|
||||
app.auto.add(&format!("project.item.{}.proxy", id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
|
||||
if presp.clicked() {
|
||||
actions.push(("file.useProxy".into(), json!({"item": id.0})));
|
||||
}
|
||||
}
|
||||
let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0, r.max.y)).intersect(list);
|
||||
let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0));
|
||||
// Inline edits (rename / comment).
|
||||
|
||||
@@ -77,6 +77,10 @@ fn settings_menu(it: &RenderQueueItem) -> Vec<(String, Value)> {
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(s.motion_blur, "Motion Blur: On for Checked Layers"), json!({"motionBlur": !s.motion_blur})));
|
||||
v.push((mark(s.skip_existing, "Skip Existing Files"), json!({"skipExisting": !s.skip_existing})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (u, k) in effectcraft_engine::project::render_queue::ProxyUse::ALL.iter().zip(["current", "all", "comp", "none"]) {
|
||||
v.push((mark(s.proxy_use == *u, &format!("Proxy Use: {}", u.label())), json!({"proxyUse": k})));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -234,6 +234,19 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
|
||||
continue;
|
||||
}
|
||||
rows.push(Row { layer: l.id, depth: 0, kind: RowKind::Layer });
|
||||
// Essential Graphics ▸ Solo Supported Properties: every property it can expose.
|
||||
if app.session.state.essential_solo {
|
||||
let mut v = vec![];
|
||||
for (i, c) in l.props.children.iter().enumerate() {
|
||||
if c.match_id() == effectcraft_engine::project::essential::GROUP {
|
||||
continue;
|
||||
}
|
||||
let g = PropGroup { children: vec![l.props.children[i].clone()], ..PropGroup::new(0, "", "") };
|
||||
collect_props(&g, &mut v, &|p| prop_visible(p, l) && effectcraft_engine::project::essential::control_type(p).is_some());
|
||||
}
|
||||
rows.extend(v.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } }));
|
||||
continue;
|
||||
}
|
||||
if !tl.open_layers.contains(&l.id.0) {
|
||||
continue;
|
||||
}
|
||||
@@ -1143,6 +1156,40 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30));
|
||||
let _ = ui.interact(wr, egui::Id::new(("expr-err", uid)), Sense::hover()).on_hover_text(e);
|
||||
}
|
||||
// ⓕ: the Expression Language menu inserts at the cursor.
|
||||
let lang_r = Rect::from_center_size(pos2(cw.switches + 70.0, cy), vec2(16.0, 16.0));
|
||||
let lang = ui.interact(lang_r, egui::Id::new(("expr-lang", uid)), Sense::click()).on_hover_text("Expression Language Menu");
|
||||
lp.circle_stroke(lang_r.center(), 6.5, Stroke::new(1.0, if lang.hovered() { t.text } else { t.text_dim }));
|
||||
lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu");
|
||||
let mut picked: Option<&'static str> = None;
|
||||
egui::Popup::menu(&lang).show(|ui| {
|
||||
for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
|
||||
ui.menu_button(cat, |ui| {
|
||||
for (label, text) in items {
|
||||
if ui.button(label).clicked() {
|
||||
picked = Some(text);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if let Some(text) = picked {
|
||||
let edit_id = egui::Id::new(("expr-edit", uid));
|
||||
let buf_id = egui::Id::new(("expr-buf", uid));
|
||||
let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
|
||||
let at = egui::text_edit::TextEditState::load(&ctx, edit_id)
|
||||
.and_then(|st| st.cursor.char_range())
|
||||
.map(|r| r.primary.index.0)
|
||||
.unwrap_or(cur.chars().count())
|
||||
.min(cur.chars().count());
|
||||
let byte = cur.char_indices().nth(at).map(|(b, _)| b).unwrap_or(cur.len());
|
||||
let mut next = cur.clone();
|
||||
next.insert_str(byte, text);
|
||||
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": next})));
|
||||
ctx.data_mut(|d| d.remove::<String>(buf_id));
|
||||
}
|
||||
// The editor in the time-graph area.
|
||||
ui.set_clip_rect(right_clip);
|
||||
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a));
|
||||
@@ -1253,8 +1300,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
Tokens::ui(12.0),
|
||||
if prop.has_expression() { Color32::from_rgb(0xe8, 0x7c, 0x5c) } else { t.text },
|
||||
);
|
||||
let name_resp =
|
||||
ui.interact(Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y)), egui::Id::new(("pname", uid)), Sense::click());
|
||||
let name_resp = ui.interact(
|
||||
Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y)),
|
||||
egui::Id::new(("pname", uid)),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
if name_resp.drag_started() {
|
||||
// Drop on the Essential Graphics panel to expose the property.
|
||||
egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid });
|
||||
}
|
||||
if name_resp.dragged() {
|
||||
egui::Tooltip::always_open(ctx.clone(), ui.layer_id(), egui::Id::new("prop-drag-tip"), egui::PopupAnchor::Pointer)
|
||||
.show(|ui| ui.label(&prop.name));
|
||||
}
|
||||
if name_resp.clicked() {
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)})));
|
||||
}
|
||||
@@ -1494,6 +1552,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
|
||||
// Drop targets: footage/comps from the Project panel, effects from Effects & Presets.
|
||||
if let Some(payload) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx)
|
||||
&& !matches!(*payload, crate::panels::DragPayload::Property { .. })
|
||||
&& ui.rect_contains_pointer(rows_rect)
|
||||
{
|
||||
p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||||
@@ -1507,6 +1566,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]})));
|
||||
}
|
||||
}
|
||||
crate::panels::DragPayload::Property { .. } => {}
|
||||
}
|
||||
egui::DragAndDrop::clear_payload(&ctx);
|
||||
}
|
||||
|
||||
@@ -347,7 +347,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Bottom control bar.
|
||||
let bar_h = 30.0;
|
||||
let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - bar_h), rect.max);
|
||||
let full = Rect::from_min_max(pos2(rect.min.x, nav.max.y), pos2(rect.max.x, bar.min.y));
|
||||
// The expression error bar sits above the control bar while expressions fail.
|
||||
let err_h = super::expr_bar::height(app, &ctx, cid);
|
||||
let err_bar = Rect::from_min_max(pos2(rect.min.x, bar.min.y - err_h), pos2(rect.max.x, bar.min.y));
|
||||
let full = Rect::from_min_max(pos2(rect.min.x, nav.max.y), pos2(rect.max.x, err_bar.min.y));
|
||||
let pasteboard = app.ui.viewer.pasteboard.map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(t.pasteboard);
|
||||
// View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which
|
||||
// keeps the overlays and the interaction.
|
||||
@@ -1158,6 +1161,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
vt::draw_snap(&ctx, &painter, &map, &ectx);
|
||||
vt::rulers(app, ui, &map, outer, area);
|
||||
vt::bottom_bar(app, ui, bar, zoom, fit, time, &comp);
|
||||
if err_h > 0.0 {
|
||||
super::expr_bar::draw(app, ui, err_bar, cid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parent-space position change per comp pixel of drag (x and y) for a layer: 2D layers map
|
||||
|
||||
Reference in New Issue
Block a user