Files
effectcraft/crates/ui-egui/src/panels/forms.rs
T
Brandon ThomasandClaude Opus 5.5 94e1c86d89 IO-2/SHP-5/PRV-2: PSD import, SVG import + vector/text conversions, persistent disk cache
- effectcraft-psd (L1): PSD/PSB reader from Adobe's published file format spec (layers, groups,
  masks, blend modes, text engine data, lfx2 effects, fill/adjustment layers, vector masks;
  1/8/16/32-bit, RGB/CMYK/Gray/Lab/Indexed, raw/RLE/ZIP) and a minimal writer for fixtures.
- File > Import: importAs footage|composition|compositionLayerSizes, layer (Choose Layer);
  groups -> precomps (pass-through collapses), text -> editable text, styles -> Layer Styles,
  adjustments -> adjustment layers with effects; UI asks the Import Kind for Photoshop files.
- effectcraft-svg (L2): SVG parser (shapes, paths, transforms, use, CSS, gradients) and
  rasteriser; .svg imports as footage, continuously rasterised when the switch is on.
- layer.create: shapesFromVector, shapesFromText, masksFromText, editableText (stub removed).
- Disk cache (render::disk_cache): LZ4 content-keyed frames and layer buffers, LRU size limit
  from prefs, atomic writes, restart persistence; edit.purge clears it, cache.diskStats query;
  viewer frames read/write it and the timeline shows disk-only frames in blue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:10:48 -04:00

466 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Menu dialogs: a generic parameter form (numeric Transform dialogs, Mask Feather/Opacity/
//! Expansion, Auto-Orient, Go to Time, Add Guide, Sequence Layers, Interpret Footage, Project
//! Settings, placeholders…), View Options and simple message boxes (Settings: `settings`;
//! Keyboard Shortcuts: `shortcut_editor`). Every form runs an engine command with the collected parameters, so whatever a
//! dialog does an agent can do with one `engine.execute`.
use egui::{Color32, vec2};
use serde_json::{Map, Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
#[derive(Clone, Debug)]
pub enum FieldKind {
Number {
value: f64,
speed: f64,
},
Text(String),
Bool(bool),
/// Options as (label, value); `sel` is the chosen index.
Choice {
options: Vec<(String, Value)>,
sel: usize,
},
}
/// One form field. `key` may index into an array param (`value[1]`).
#[derive(Clone, Debug)]
pub struct Field {
pub key: String,
pub label: String,
pub kind: FieldKind,
}
impl Field {
pub fn num(key: &str, label: &str, value: f64) -> Field {
Field { key: key.into(), label: label.into(), kind: FieldKind::Number { value, speed: 1.0 } }
}
pub fn text(key: &str, label: &str, value: &str) -> Field {
Field { key: key.into(), label: label.into(), kind: FieldKind::Text(value.into()) }
}
pub fn bool(key: &str, label: &str, value: bool) -> Field {
Field { key: key.into(), label: label.into(), kind: FieldKind::Bool(value) }
}
pub fn choice(key: &str, label: &str, options: &[(&str, Value)], sel: usize) -> Field {
Field {
key: key.into(),
label: label.into(),
kind: FieldKind::Choice { options: options.iter().map(|(l, v)| (l.to_string(), v.clone())).collect(), sel },
}
}
}
#[derive(Clone, Debug, Default)]
pub struct Form {
pub title: String,
pub command: String,
pub base: Value,
pub fields: Vec<Field>,
}
impl Form {
/// The command parameters: `base` plus every field.
pub fn params(&self) -> Value {
let mut m: Map<String, Value> = self.base.as_object().cloned().unwrap_or_default();
for f in &self.fields {
let v = match &f.kind {
FieldKind::Number { value, .. } => json!(value),
FieldKind::Text(s) => json!(s),
FieldKind::Bool(b) => json!(b),
FieldKind::Choice { options, sel } => options.get(*sel).map(|o| o.1.clone()).unwrap_or(Value::Null),
};
match f.key.split_once('[') {
Some((name, idx)) => {
let i: usize = idx.trim_end_matches(']').parse().unwrap_or(0);
let arr = m.entry(name.to_string()).or_insert_with(|| json!([]));
if let Value::Array(a) = arr {
while a.len() <= i {
a.push(json!(0));
}
a[i] = v;
}
}
None => {
m.insert(f.key.clone(), v);
}
}
}
Value::Object(m)
}
}
/// Open a form dialog.
pub fn form(app: &mut EffectcraftApp, title: &str, command: &str, base: Value, fields: Vec<Field>) {
app.dialog_state.form = Form { title: title.into(), command: command.into(), base, fields };
app.dialog = Some(Dialog::Form);
}
/// Open a message box.
pub fn info(app: &mut EffectcraftApp, title: &str, body: &str) {
app.dialog_state.info = (title.into(), body.into());
app.dialog = Some(Dialog::Info);
}
fn has(p: &Value, keys: &[&str]) -> bool {
keys.iter().any(|k| p.get(*k).is_some())
}
/// If `id` is a dialog command invoked without its parameters, open its form and return true.
/// The import includes a Photoshop document.
fn is_layered(p: &Value) -> bool {
let psd = |v: &Value| v.as_str().is_some_and(|s| matches!(s.rsplit('.').next().map(str::to_ascii_lowercase).as_deref(), Some("psd" | "psb")));
match p.get("paths").or(p.get("path")) {
Some(Value::Array(a)) => a.iter().any(psd),
Some(v) => psd(v),
None => false,
}
}
pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
let s = &app.session;
let comp = s.active_comp();
let layer = comp.and_then(|c| s.state.selected_layers.first().and_then(|l| c.layer(*l)));
let t = s.time();
let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t);
let base = p.clone();
let (title, fields): (String, Vec<Field>) = match id {
// Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes).
"file.import" if !has(p, &["importAs"]) && is_layered(p) => (
"Import Photoshop File".into(),
vec![Field::choice(
"importAs",
"Import Kind",
&[("Footage", json!("footage")), ("Composition", json!("composition")), ("Composition - Retain Layer Sizes", json!("compositionLayerSizes"))],
1,
)],
),
"layer.setTransform" if !has(p, &["value"]) => {
let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
let v = cur.map(|c| c.as_vec3()).unwrap_or([0.0; 3]);
let three = layer.is_some_and(|l| l.is_3d());
let title = match prop {
"anchor" => "Anchor Point",
"position" => "Position",
"scale" => "Scale",
"orientation" => "Orientation",
"rotation" => "Rotation",
_ => "Opacity",
};
let fields = match prop {
"rotation" | "opacity" => vec![Field::num("value", if prop == "rotation" { "Degrees" } else { "Opacity (%)" }, v[0])],
"orientation" => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
_ if three => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
_ => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
};
(title.into(), fields)
}
"path.freeTransform" if !has(p, &["scale", "rotation", "offset"]) => (
"Free Transform Points".into(),
vec![
Field::num("scale[0]", "Scale X (%)", 100.0),
Field::num("scale[1]", "Scale Y (%)", 100.0),
Field::num("rotation", "Rotation (degrees)", 0.0),
Field::num("offset[0]", "Move X", 0.0),
Field::num("offset[1]", "Move Y", 0.0),
],
),
"layer.mask.set" if !has(p, &["value"]) => {
let field = p.get("field").and_then(Value::as_str).unwrap_or("feather");
let first = layer.and_then(|l| l.masks()).and_then(|m| m.groups().next()).and_then(|g| g.get(field)).map(|pr| pr.value_at(lt).as_f64());
let (title, label, d) = match field {
"feather" => ("Mask Feather", "Feather (pixels)", 0.0),
"opacity" => ("Mask Opacity", "Opacity (%)", 100.0),
_ => ("Mask Expansion", "Expansion (pixels)", 0.0),
};
(title.into(), vec![Field::num("value", label, first.unwrap_or(d))])
}
"layer.mask.shape" if !has(p, &["rect"]) => {
let (w, h) = layer.map(|l| effectcraft_engine::render::source_size(&s.project, l)).unwrap_or((100, 100));
(
"Mask Shape".into(),
vec![
Field::num("rect[0]", "Left", 0.0),
Field::num("rect[1]", "Top", 0.0),
Field::num("rect[2]", "Width", w as f64),
Field::num("rect[3]", "Height", h as f64),
Field::choice("shape", "Shape", &[("Rectangle", json!("rect")), ("Ellipse", json!("ellipse"))], 0),
],
)
}
"layer.autoOrient" if !has(p, &["mode"]) => {
let cur = layer.map(|l| l.auto_orient as usize).unwrap_or(0);
(
"Auto-Orientation".into(),
vec![Field::choice(
"mode",
"Mode",
&[
("Off", json!("off")),
("Orient Along Path", json!("alongPath")),
("Orient Towards Camera", json!("towardsCamera")),
("Orient Towards Point of Interest", json!("towardsPointOfInterest")),
],
cur.min(3),
)],
)
}
"time.set" if !has(p, &["time", "frame", "timecode"]) => {
let frame = comp.map(|c| c.frame_rate.frame_at(t)).unwrap_or(0);
("Go to Time".into(), vec![Field::num("frame", "Frame", frame as f64)])
}
"view.addGuide" if !has(p, &["position"]) => {
let (w, _) = comp.map(|c| (c.width, c.height)).unwrap_or((1920, 1080));
(
"Add Guide".into(),
vec![
Field::choice("orientation", "Orientation", &[("Vertical", json!("vertical")), ("Horizontal", json!("horizontal"))], 0),
Field::num("position", "Position (pixels)", w as f64 / 2.0),
],
)
}
"layer.sequence" if !has(p, &["overlap"]) => (
"Sequence Layers".into(),
vec![
Field::bool("overlap", "Overlap", false),
Field::num("duration", "Duration (seconds)", 1.0),
Field::choice(
"transition",
"Transition",
&[
("Off", json!("off")),
("Dissolve Front Layer", json!("dissolveFront")),
("Cross Dissolve Front and Back Layers", json!("crossDissolve")),
],
0,
),
],
),
"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()),
_ => None,
});
let Some(f) = f else { return false };
(
"Interpret Footage".into(),
vec![
Field::num("frameRate", "Frame rate", f.frame_rate.as_f64()),
Field::choice(
"alpha",
"Alpha",
&[("Straight", json!("straight")), ("Premultiplied", json!("premultiplied")), ("Ignore", json!("ignore"))],
f.alpha as usize,
),
Field::num("loop", "Loop (times)", f.loop_count as f64),
Field::num("pixelAspect", "Pixel aspect ratio", f.pixel_aspect),
Field::choice(
"colorProfile",
"Color profile",
&[
("Embedded / sRGB", json!("auto")),
("sRGB IEC61966-2.1", json!("srgb")),
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
],
f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
),
],
)
}
"file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => {
let st = &s.project.settings;
let gpu_label = match &s.accel {
Some(a) => format!("Mercury GPU Acceleration ({})", a.name()),
None => "Mercury GPU Acceleration (no GPU: software)".to_string(),
};
let depth = match st.bit_depth.label() {
l if l.starts_with("16") => 1,
l if l.starts_with("32") => 2,
_ => 0,
};
(
"Project Settings".into(),
vec![
Field::choice(
"bitDepth",
"Depth",
&[("8 bits per channel", json!(8)), ("16 bits per channel", json!(16)), ("32 bits per channel (float)", json!(32))],
depth,
),
Field::choice(
"timeDisplay",
"Time display style",
&[("Timecode", json!("timecode")), ("Frames", json!("frames"))],
usize::from(matches!(st.time_display, effectcraft_engine::project::TimeDisplayStyle::Frames)),
),
Field::choice(
"workingSpace",
"Working space",
&[
("None", json!("none")),
("sRGB IEC61966-2.1", json!("srgb")),
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
],
st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
),
Field::bool("linearize", "Linearize working space", st.linearize),
Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear),
// Video Rendering and Effects ▸ Use.
Field::choice(
"renderer",
"Video rendering and effects",
&[(gpu_label.as_str(), json!("gpu")), ("Mercury Software Only", json!("software"))],
usize::from(!st.gpu_acceleration),
),
],
)
}
"file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => (
"New Placeholder".into(),
vec![
Field::text("name", "Name", "Placeholder"),
Field::num("width", "Width", 1920.0),
Field::num("height", "Height", 1080.0),
Field::num("frameRate", "Frame rate", 29.97),
Field::num("duration", "Duration (seconds)", 30.0),
],
),
"file.importSolid" | "file.replaceWithSolid" if p.as_object().is_none_or(|m| m.is_empty()) => {
let (w, h) = comp.map(|c| (c.width, c.height)).unwrap_or((1920, 1080));
(
"Solid Settings".into(),
vec![
Field::text("name", "Name", "Solid"),
Field::text("color", "Color (#rrggbb)", "#808080"),
Field::num("width", "Width", w as f64),
Field::num("height", "Height", h as f64),
],
)
}
_ => return false,
};
form(app, &title, id, base, fields);
true
}
pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut f = app.dialog_state.form.clone();
let (mut ok, mut close) = (false, false);
let h = 120.0 + f.fields.len() as f32 * 30.0;
super::dialogs::modal(ctx, &f.title.clone(), vec2(440.0, h), t, |ui| {
egui::Grid::new("form-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| {
for fl in &mut f.fields {
ui.label(&fl.label);
match &mut fl.kind {
FieldKind::Number { value, speed } => {
ui.add(egui::DragValue::new(value).speed(*speed).max_decimals(3));
}
FieldKind::Text(s) => {
ui.add(egui::TextEdit::singleline(s).desired_width(220.0));
}
FieldKind::Bool(b) => {
ui.checkbox(b, "");
}
FieldKind::Choice { options, sel } => {
let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default();
egui::ComboBox::from_id_salt(("form", fl.key.as_str())).selected_text(cur).width(240.0).show_ui(ui, |ui| {
for (i, (l, _)) in options.iter().enumerate() {
ui.selectable_value(sel, i, l);
}
});
}
}
ui.end_row();
}
});
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
ok = true;
}
if ui.button("Cancel").clicked() {
close = true;
}
});
});
let enter = !ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter));
app.dialog_state.form = f.clone();
if ok || enter {
app.dialog = None;
let params = f.params();
if let Err(e) = app.session.execute(&f.command, params) {
app.ui.status = e.to_string();
}
return;
}
if close {
app.dialog = None;
}
}
pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let (title, body) = app.dialog_state.info.clone();
let mut close = false;
super::dialogs::modal(ctx, &title, vec2(440.0, 160.0), t, |ui| {
ui.label(body);
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
close = true;
}
});
});
if close {
app.dialog = None;
}
}
pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut close = false;
super::dialogs::modal(ctx, "View Options", vec2(360.0, 330.0), t, |ui| {
let v = &mut app.ui.viewer;
ui.checkbox(&mut v.show_layer_controls, "Layer controls");
ui.checkbox(&mut v.show_masks, "Masks");
ui.checkbox(&mut v.rulers, "Rulers");
ui.checkbox(&mut v.guides, "Guides");
ui.checkbox(&mut v.grid, "Grid");
ui.checkbox(&mut v.safe_margins, "Title/action safe");
ui.checkbox(&mut v.transparency_grid, "Transparency grid");
ui.add_space(12.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
close = true;
}
});
});
if close {
app.dialog = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn form_params_build_arrays_and_choices() {
let f = Form {
title: "T".into(),
command: "layer.setTransform".into(),
base: json!({"prop": "position"}),
fields: vec![
Field::num("value[0]", "X", 10.0),
Field::num("value[1]", "Y", 20.0),
Field::choice("mode", "M", &[("A", json!("a")), ("B", json!("b"))], 1),
],
};
assert_eq!(f.params(), json!({"prop": "position", "value": [10.0, 20.0], "mode": "b"}));
}
}