Merge branch 'main' into worktree-agent-a15cecfec57d5290d

# Conflicts:
#	crates/effects/src/ocio.rs
#	crates/engine/src/commands/stubs.rs
#	docs/parity.md
This commit is contained in:
Brandon Thomas
2026-10-03 02:26:19 -04:00
151 changed files with 14085 additions and 2190 deletions
+34 -5
View File
@@ -1,8 +1,9 @@
//! Real-time audio preview.
//!
//! The frontend owns no audio API: the native app supplies an [`AudioDevice`] (cpal) through
//! [`crate::Hooks::audio_device`]. During preview playback an [`AudioPlayback`] runs a feeder
//! thread that mixes the composition ahead of the device with the same mixdown export uses
//! [`crate::Hooks::audio_device`], the web app one on Web Audio. During preview playback an
//! [`AudioPlayback`] runs a feeder thread (wasm32: [`AudioPlayback::pump`] every UI frame) that
//! mixes the composition ahead of the device with the same mixdown export uses
//! (`effectcraft_render::audio::mix_comp`: Audio switch, solo, Audio Levels, audio effects) and
//! pushes it into an [`AudioFeed`]; the device callback pulls from the feed.
//!
@@ -35,6 +36,9 @@ pub trait AudioDevice: Send {
fn latency_frames(&self) -> u64 {
0
}
/// Called every UI frame while playing: devices without an audio thread of their own (Web
/// Audio) move queued samples to the output here.
fn pump(&mut self) {}
}
/// Opens the default output device (`None` when there is none).
@@ -160,10 +164,18 @@ pub struct AudioPlayback {
device: Box<dyn AudioDevice>,
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
/// wasm32 (no threads): what the feeder thread would own, run by [`AudioPlayback::pump`].
feeder: Option<(RenderSource, ItemId, i64)>,
pub rate: u32,
pub span: PlaySpan,
}
/// Audio kept queued ahead of the device: ~200 ms (a feeder thread tops it up continuously);
/// wasm32 tops it up once per UI frame, so it keeps ~350 ms to ride out slow frames.
fn queue_target(rate: u32) -> usize {
if cfg!(target_arch = "wasm32") { rate as usize * 7 / 20 } else { rate as usize / 5 }
}
impl AudioPlayback {
/// Start mixing `comp` over `span` into `device`.
pub fn start(
@@ -185,13 +197,18 @@ impl AudioPlayback {
for _ in 0..prime {
cursor = feed_block(&src, comp, &feed, &span, cursor, rate);
}
if cfg!(target_arch = "wasm32") {
let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, feeder: Some((src, comp, cursor)), rate, span };
p.device.start(feed)?;
p.pump();
return Ok(p);
}
let thread = {
let (feed, stop) = (feed.clone(), stop.clone());
std::thread::Builder::new()
.name("ec-audio-feed".into())
.spawn(move || {
// Keep ~200 ms queued ahead of the device.
let target = rate as usize / 5;
let target = queue_target(rate);
while !stop.load(Ordering::Relaxed) {
if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) {
cursor = feed_block(&src, comp, &feed, &span, cursor, rate);
@@ -203,7 +220,19 @@ impl AudioPlayback {
.map_err(|e| e.to_string())?
};
device.start(feed.clone())?;
Ok(AudioPlayback { feed, device, stop, thread: Some(thread), rate, span })
Ok(AudioPlayback { feed, device, stop, thread: Some(thread), feeder: None, rate, span })
}
/// Every UI frame while playing: on wasm32 mix ahead into the feed (what the feeder thread
/// does elsewhere), then let the device take what it needs.
pub fn pump(&mut self) {
if let Some((src, comp, cursor)) = &mut self.feeder {
let target = queue_target(self.rate);
while self.feed.queued_frames() < target && !self.feed.finished.load(Ordering::Relaxed) {
*cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate);
}
}
self.device.pump();
}
/// The comp sample index audible now.
+10 -3
View File
@@ -124,9 +124,11 @@ pub struct RenderSource {
pub layer_cache: Arc<LayerCache>,
/// GPU compositor on the viewer's device (Mercury GPU Acceleration), if available.
pub gpu: Option<Gpu>,
/// Keep GPU frames on the GPU for the viewer (native egui-wgpu textures). Off by default:
/// Show Channel / exposure / region-of-interest drawing read the CPU frame texture, so GPU
/// frames are read back (the compositing still runs on the GPU).
/// Keep GPU frames on the GPU for the viewer (native egui-wgpu textures). Off on the
/// desktop: Show Channel / exposure / region-of-interest drawing read the CPU frame texture,
/// so GPU frames are read back (the compositing still runs on the GPU). On in the browser
/// while the viewer draws plainly, because there frames can't be read back synchronously
/// (`EffectcraftApp::gpu_display`).
pub gpu_display: bool,
/// The persistent disk cache, when enabled.
pub disk: Option<Arc<DiskCache>>,
@@ -214,6 +216,11 @@ impl Frames {
}
}
/// The egui context (to request repaints from callbacks).
pub fn context(&self) -> Option<egui::Context> {
self.ctx.clone()
}
pub fn get(&self, k: &FrameKey) -> Option<FrameImage> {
self.cache.lock().ok()?.map.get(k).cloned()
}
+55 -8
View File
@@ -71,6 +71,8 @@ pub enum Dialog {
Marker,
/// Layer ▸ Pre-compose.
Precompose,
/// Layer ▸ Layer Styles ▸ Layer Style Options…
LayerStyles,
}
/// Host hooks provided by the native app (file pickers etc.).
@@ -138,6 +140,8 @@ pub struct EffectcraftApp {
/// Last displayed image (Info panel colour, eyedroppers, histograms). GPU frames fill it on
/// demand ([`EffectcraftApp::viewer_pixels`]).
pub(crate) viewer_image: Option<Arc<egui::ColorImage>>,
/// wasm32: an asynchronous readback of a GPU viewer frame (key asked for, result).
viewer_readback: (Option<FrameKey>, Arc<std::sync::Mutex<Option<(FrameKey, Arc<egui::ColorImage>)>>>),
/// egui-wgpu's device, once the first frame arrives (desktop and WebGPU).
pub(crate) wgpu: Option<eframe::egui_wgpu::RenderState>,
/// The GPU compositor on that device (None: no usable adapter → CPU only).
@@ -198,6 +202,7 @@ impl EffectcraftApp {
viewer_native: None,
viewer_shown: None,
viewer_image: None,
viewer_readback: Default::default(),
wgpu: None,
gpu: None,
gpu_checked: false,
@@ -323,11 +328,23 @@ impl EffectcraftApp {
expr: self.session.expr.clone(),
layer_cache: self.session.layer_cache.clone(),
gpu: self.gpu.clone(),
gpu_display: false,
gpu_display: self.gpu_display(),
disk: self.session.disk_cache.clone(),
}
}
/// Keep GPU-composited viewer frames on the GPU (drawn from their texture). The browser can't
/// read frames back (its thread can't wait for the GPU), so there the viewer shows GPU frames
/// directly whenever it draws them plainly (RGB, no exposure, no region of interest); the
/// desktop reads them back (see [`frames::RenderSource::gpu_display`]).
fn gpu_display(&self) -> bool {
let v = &self.session.state.viewer;
cfg!(target_arch = "wasm32")
&& v.channel == effectcraft_engine::viewer::Channel::Rgb
&& v.exposure == 0.0
&& self.session.state.region_of_interest.is_none()
}
/// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs
/// compute shaders (WebGL2, software GL) everything stays on the CPU.
pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) {
@@ -359,14 +376,38 @@ impl EffectcraftApp {
/// The viewer's current pixels as 8-bit premultiplied RGBA, reading a GPU frame back the
/// first time something asks for it.
///
/// The browser can't wait for the GPU: there the first call starts an asynchronous readback
/// and returns `None`; a later call (the next frame) has the pixels.
pub fn viewer_pixels(&mut self) -> Option<Arc<egui::ColorImage>> {
if self.viewer_image.is_none()
&& let (Some((_, _, f)), Some(g)) = (&self.viewer_native, &self.gpu)
&& self.viewer_shown.as_ref().map(|s| s.1) == self.viewer_native.as_ref().map(|n| n.1)
&& let Some(bytes) = g.read_display(f)
{
let to_image = |bytes: Vec<u8>, w: u32, h: u32| {
let px = bytes.chunks_exact(4).map(|c| egui::Color32::from_rgba_premultiplied(c[0], c[1], c[2], c[3])).collect();
self.viewer_image = Some(Arc::new(egui::ColorImage::new([f.width as usize, f.height as usize], px)));
Arc::new(egui::ColorImage::new([w as usize, h as usize], px))
};
if self.viewer_image.is_none()
&& let (Some((_, key, f)), Some(g)) = (&self.viewer_native, &self.gpu)
&& self.viewer_shown.as_ref().map(|s| s.1) == Some(*key)
{
if cfg!(target_arch = "wasm32") {
let ready = self.viewer_readback.1.lock().ok().and_then(|mut r| r.take_if(|r| r.0 == *key));
if let Some((_, img)) = ready {
self.viewer_image = Some(img);
} else if self.viewer_readback.0 != Some(*key) {
self.viewer_readback.0 = Some(*key);
let (slot, k, (w, h)) = (self.viewer_readback.1.clone(), *key, (f.width, f.height));
let ctx = self.frames.context();
g.read_display_async(f, move |bytes| {
if let (Some(b), Ok(mut s)) = (bytes, slot.lock()) {
*s = Some((k, to_image(b, w, h)));
}
if let Some(c) = ctx {
c.request_repaint();
}
});
}
} else if let Some(bytes) = g.read_display(f) {
self.viewer_image = Some(to_image(bytes, f.width, f.height));
}
}
self.viewer_image.clone()
}
@@ -539,7 +580,8 @@ impl EffectcraftApp {
queued += 1;
}
}
if let Some(a) = &self.audio {
if let Some(a) = &mut self.audio {
a.pump();
self.meter.update(a.feed.take_peaks(), now);
} else if self.meter.active() {
self.meter.update([0.0; 2], now);
@@ -745,6 +787,11 @@ impl EffectcraftApp {
self.session.poll_mask_track();
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
// Analyses running in a worker (the web app).
if !self.session.offloaded.is_empty() {
self.session.poll_offload();
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// Warp Stabilizer: finish analyses and start queued (re-)analyses in the background.
if self.session.warp_job.is_some() || !self.session.warp_pending.is_empty() {
self.session.poll_warp(true);
+8
View File
@@ -153,6 +153,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if crate::panels::dialogs_3d::route(app, id, &params)? {
return Ok(Value::Null);
}
// Layer ▸ Layer Styles ▸ <style> from the menu adds the style and opens the dialog on it.
if crate::panels::layer_styles_dialog::route(app, id, &params)? {
return Ok(Value::Null);
}
// Legacy per-panel / per-workspace ids (`window.panel.Project`, `window.workspace.default`).
if let Some(rest) = id.strip_prefix("window.panel.") {
return frontend(app, ctx, "window.panel", json!({"panel": rest}));
@@ -452,6 +456,10 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
app.dialog = Some(crate::Dialog::About);
Value::Null
}
"layer.style.options" => {
crate::panels::layer_styles_dialog::open(app, &p)?;
Value::Null
}
"app.settings" => {
let page = p.get("page").and_then(Value::as_str).unwrap_or("general");
crate::panels::settings::open(app, effectcraft_engine::prefs::page_id(page).unwrap_or("general"));
+6 -1
View File
@@ -85,6 +85,7 @@ pub struct CompDraft {
pub shutter_angle: f64,
pub shutter_phase: f64,
pub samples: u32,
pub adaptive_limit: u32,
pub advanced_3d: bool,
pub tab: Tab,
/// Timecode text being edited (start, duration).
@@ -108,6 +109,7 @@ impl Default for CompDraft {
shutter_angle: 180.0,
shutter_phase: -90.0,
samples: 16,
adaptive_limit: 128,
advanced_3d: false,
tab: Tab::Basic,
start_tc: None,
@@ -294,6 +296,9 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
ui.label("Samples Per Frame:");
ui.add(egui::DragValue::new(&mut d.samples).range(2..=64));
ui.end_row();
ui.label("Adaptive Sample Limit:");
ui.add(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256));
ui.end_row();
});
}
Tab::Renderer => {
@@ -325,7 +330,7 @@ pub fn params(d: &CompDraft) -> serde_json::Value {
json!({
"name": d.name, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps,
"duration": d.duration, "startTime": d.start, "background": [d.bg[0], d.bg[1], d.bg[2]], "anchor": d.anchor,
"shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples,
"shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples, "adaptiveSampleLimit": d.adaptive_limit,
"renderer": if d.advanced_3d { "advanced3D" } else { "classic3D" },
})
}
+4
View File
@@ -42,6 +42,8 @@ pub struct DialogState {
pub marker: super::markers_ui::MarkerDraft,
/// Pre-compose dialog.
pub precompose: super::precomp::PrecomposeDraft,
/// Layer Style dialog.
pub layer_style: super::layer_styles_dialog::LayerStyleState,
}
pub fn open_new_comp(app: &mut EffectcraftApp) {
@@ -66,6 +68,7 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> {
shutter_angle: c.shutter_angle,
shutter_phase: c.shutter_phase,
samples: c.motion_blur_samples,
adaptive_limit: c.motion_blur_adaptive_limit,
advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D,
..Default::default()
};
@@ -123,6 +126,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::TrackOptions => super::tracker::options_dialog(app, ctx, &t),
Dialog::Marker => super::markers_ui::dialog(app, ctx, &t),
Dialog::Precompose => super::precomp::dialog(app, ctx, &t),
Dialog::LayerStyles => super::layer_styles_dialog::show(app, ctx, &t),
Dialog::TrackTarget => super::tracker::target_dialog(app, ctx, &t),
Dialog::TrackApply => super::tracker::apply_dialog(app, ctx, &t),
}
+4 -3
View File
@@ -698,8 +698,9 @@ fn levels_ids(effect: &str, ch: usize) -> [String; 5] {
if effect == "ec.color.levels" {
return ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].map(String::from);
}
// Levels (Individual Controls) nests each channel's controls in its twirl-down group.
let pre = fw::LEVELS_CHANNELS[ch.min(4)].0;
["InBlack", "InWhite", "Gamma", "OutBlack", "OutWhite"].map(|s| format!("{pre}{s}"))
["InBlack", "InWhite", "Gamma", "OutBlack", "OutWhite"].map(|s| format!("{pre}/{pre}{s}"))
}
/// Levels: (channel popup,) histogram, input black/gamma/white triangles, output bar with
@@ -741,10 +742,10 @@ fn levels_editor(
}
app.auto.add(&format!("effectControls.effect.{euid}.levels.histogram"), hr, "Histogram");
let ids = levels_ids(effect, ch);
let val = |id: &str| g.get(id).map(|pr| ectx.value(layer, pr).as_f64()).unwrap_or(0.0);
let val = |id: &str| g.prop(id).map(|pr| ectx.value(layer, pr).as_f64()).unwrap_or(0.0);
let (ib, iw, gm, ob, ow) = (val(&ids[0]), val(&ids[1]), val(&ids[2]), val(&ids[3]), val(&ids[4]));
let mut set = |ui: &egui::Ui, id: &str, v: f64, started: bool| {
if let Some(pr) = g.get(id) {
if let Some(pr) = g.prop(id) {
let key = gesture_key(ui, egui::Id::new(("ec-lv", euid, id)), started);
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": v, "merge": key})));
}
+48 -10
View File
@@ -187,21 +187,59 @@ pub(crate) fn transform_box(
}
}
/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time or another key's
/// time within 6 points.
/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time, another key's
/// time, a layer's in or out point, a comp or layer marker or the work area within 6 points.
pub(crate) fn snap_time(app: &EffectcraftApp, tm: TMap, t: f64, others: &[f64]) -> f64 {
if !app.ui.timeline.graph_snap {
return t;
}
let cti = app.session.time().seconds();
let mut cands = vec![app.session.time().seconds()];
cands.extend_from_slice(others);
if let Some(c) = app.session.active_comp() {
cands.extend(comp_snap_times(c));
}
snap_px(tm, t, &cands, 6.0)
}
/// Snap targets of a comp besides keys: work area, markers, and every layer's in / out points
/// and markers (comp seconds).
pub fn comp_snap_times(c: &Comp) -> Vec<f64> {
let mut v = vec![c.work_area.0.seconds(), c.work_area.1.seconds()];
v.extend(c.markers.iter().map(|m| m.time.seconds()));
for l in &c.layers {
v.push(l.in_point.seconds());
v.push(l.out_point.seconds());
v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds()));
}
v
}
/// The candidate nearest `t` within `tol` screen points, else `t`.
pub(crate) fn snap_px(tm: TMap, t: f64, cands: &[f64], tol: f32) -> f64 {
let x = tm.x(t);
std::iter::once(cti)
.chain(others.iter().copied())
.map(|c| (c, (tm.x(c) - x).abs()))
.filter(|(_, d)| *d < 6.0)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(c, _)| c)
.unwrap_or(t)
cands.iter().copied().map(|c| (c, (tm.x(c) - x).abs())).filter(|(_, d)| *d < tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(c, _)| c).unwrap_or(t)
}
#[cfg(test)]
mod snap_tests {
use super::*;
#[test]
fn graph_snap_targets_markers_and_layer_ends() {
use effectcraft_engine::Session;
let mut s = Session::default();
s.execute("comp.new", json!({"name": "S", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 10, "height": 10})).unwrap();
s.execute("comp.addMarker", json!({"time": 1.5})).unwrap();
let c = s.active_comp().unwrap();
let cands = comp_snap_times(c);
assert!(cands.contains(&1.5), "{cands:?}");
assert!(cands.iter().any(|t| (*t - c.layers[0].out_point.seconds()).abs() < 1e-9));
let tm = TMap { x0: 0.0, start: 0.0, pps: 100.0 };
// 1.53 s is 3 points from the marker: snaps; 1.6 s (10 points) doesn't.
assert_eq!(snap_px(tm, 1.53, &cands, 6.0), 1.5);
assert_eq!(snap_px(tm, 1.6, &[1.5], 6.0), 1.6);
}
}
// ---------------------------------------------------------------- timeline Alt-drag scaling
@@ -0,0 +1,226 @@
//! Layer ▸ Layer Styles ▸ Layer Style Options… (and choosing a style from that menu): a Layer
//! Style dialog with the style list on the left (a check box per style, Blending Options first)
//! and the selected style's controls on the right. Every control edits the ordinary
//! `layerStyles/<style>/<property>` property, so the viewer previews live; Cancel rolls the
//! edits made in the dialog back, OK keeps them (one undo step per control drag).
use effectcraft_engine::keyframe::Value as KV;
use effectcraft_engine::project::styles::{BLENDING, STYLES};
use effectcraft_engine::project::{Layer, LayerId, ParamUi, Property};
use egui::{Align2, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
/// Dialog state: layer, selected list entry and the undo depth when it opened.
#[derive(Clone, Debug, Default)]
pub struct LayerStyleState {
pub layer: u64,
pub selected: String,
pub undo_mark: usize,
}
/// Open the dialog on the first selected (or given) layer, showing `style` (a style id;
/// Blending Options when none).
pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> {
let comp = app.session.active_comp().ok_or("no composition is open")?;
let layer = params
.get("layer")
.and_then(Value::as_u64)
.or_else(|| app.session.state.selected_layers.first().map(|l| l.0))
.filter(|l| comp.layer(LayerId(*l)).is_some())
.ok_or("select a layer first")?;
let style = params.get("style").and_then(Value::as_str).unwrap_or(BLENDING).to_string();
if style != BLENDING && !STYLES.iter().any(|s| s.0 == style) {
return Err(format!("unknown layer style `{style}`"));
}
app.dialog_state.layer_style = LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len() };
app.dialog = Some(Dialog::LayerStyles);
Ok(())
}
/// Menu routing: choosing one of the Layer ▸ Layer Styles ▸ <style> items (no parameters) adds
/// the style as usual and opens the dialog on it.
pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
let Some(style) = id.strip_prefix("layer.style.").filter(|s| STYLES.iter().any(|x| x.0 == *s)) else { return Ok(false) };
if !params.as_object().is_none_or(|m| m.is_empty()) {
return Ok(false);
}
let mark = app.session.history.undo.len();
app.session.execute(id, json!({})).map_err(|e| e.to_string())?;
open(app, &json!({"style": style}))?;
// Cancel also removes the style just added.
app.dialog_state.layer_style.undo_mark = mark;
Ok(true)
}
fn layer(app: &EffectcraftApp) -> Option<Layer> {
app.session.active_comp()?.layer(LayerId(app.dialog_state.layer_style.layer)).cloned()
}
/// One control row for a property. Returns the new value when it changed.
fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
match (v, &p.ui) {
(KV::Bool(b), _) => {
let mut on = *b;
ui.checkbox(&mut on, "").changed().then(|| json!(on))
}
(KV::Enum(i), ParamUi::Popup { options }) => {
let mut sel = *i as usize;
egui::ComboBox::from_id_salt(("ls-popup", p.uid)).selected_text(options.get(sel).cloned().unwrap_or_default()).show_ui(ui, |ui| {
for (k, o) in options.iter().enumerate() {
ui.selectable_value(&mut sel, k, o);
}
});
(sel != *i as usize).then(|| json!(sel))
}
(KV::Scalar(x), ui_kind) => {
let mut y = *x;
let r = match ui_kind {
ParamUi::Slider { slider_min, slider_max, decimals, .. } => {
ui.add(egui::Slider::new(&mut y, *slider_min..=*slider_max).clamping(egui::SliderClamping::Never).max_decimals(*decimals as usize))
}
ParamUi::Percent => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("%")),
ParamUi::Angle => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("°")),
ParamUi::Pixels => ui.add(egui::DragValue::new(&mut y).speed(0.25).suffix(" px")),
_ => ui.add(egui::DragValue::new(&mut y).speed(0.1)),
};
(r.changed() && y != *x).then(|| json!(y))
}
(KV::Color(c), _) => {
let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32);
let changed = egui::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::color_picker::Alpha::Opaque).changed();
changed.then(|| json!([rgba.r(), rgba.g(), rgba.b()]))
}
(KV::Gradient(_), _) => {
ui.label(egui::RichText::new("Edit in the Timeline").weak());
None
}
_ => {
ui.label(egui::RichText::new(format!("{v:?}").chars().take(24).collect::<String>()).weak());
None
}
}
}
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let Some(l) = layer(app) else {
app.dialog = None;
return;
};
let lid = l.id.0;
let styles = l.layer_styles().cloned();
let sel = app.dialog_state.layer_style.selected.clone();
let time = app.session.time();
let lt = l.layer_time(time);
let mut actions: Vec<(String, Value)> = Vec::new();
let mut close: Option<bool> = None;
super::dialogs::modal(ctx, &format!("Layer Style — {}", l.name), vec2(720.0, 520.0), t, |ui| {
ui.horizontal_top(|ui| {
// Style list.
ui.vertical(|ui| {
ui.set_width(200.0);
let mut entries = vec![(BLENDING, "Blending Options")];
entries.extend(STYLES.iter().copied());
for (id, name) in entries {
ui.horizontal(|ui| {
if id != BLENDING {
let g = styles.as_ref().and_then(|s| s.sub(id));
let mut on = g.is_some_and(|g| g.enabled);
if ui.checkbox(&mut on, "").changed() {
if g.is_none() {
actions.push(("layer.style.add".into(), json!({"layers": [lid], "style": id})));
} else {
actions.push(("layer.style.toggle".into(), json!({"layer": lid, "style": id, "value": on})));
}
}
} else {
ui.add_space(24.0);
}
let r = ui.selectable_label(sel == id, name);
if r.clicked() {
app.dialog_state.layer_style.selected = id.to_string();
}
let rect = r.rect;
app.auto.add(&format!("dialog.layerStyle.list.{id}"), rect, name);
});
}
});
ui.separator();
// Controls of the selected style.
ui.vertical(|ui| {
let name = if sel == BLENDING { "Blending Options" } else { STYLES.iter().find(|s| s.0 == sel).map(|s| s.1).unwrap_or("") };
let head = ui.label(egui::RichText::new(name).font(Tokens::semibold(13.0)));
app.auto.add("dialog.layerStyle.page", head.rect, &sel);
ui.add_space(6.0);
let Some(g) = styles.as_ref().and_then(|s| s.sub(&sel)) else {
ui.label(egui::RichText::new("Not applied: tick it in the list to add it.").weak());
return;
};
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
egui::Grid::new("ls-grid").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
g.walk("", &mut |_, p| {
if matches!(p.ui, ParamUi::Hidden) {
return;
}
let lbl = ui.label(&p.name);
let v = p.value_at(lt);
if let Some(nv) = control(ui, p, &v) {
actions.push((
"prop.set".into(),
json!({"layer": lid, "prop": p.uid, "value": nv, "merge": format!("layer-style-dialog-{}", p.uid)}),
));
}
let path = g.match_path_of(p.uid).unwrap_or_default();
app.auto.add(&format!("dialog.layerStyle.prop.{sel}/{path}"), lbl.rect, &p.name);
ui.end_row();
});
});
});
});
});
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = ui.button("OK");
app.auto.add("dialog.layerStyle.ok", ok.rect, "OK");
if ok.clicked() {
close = Some(true);
}
let cancel = ui.button("Cancel");
app.auto.add("dialog.layerStyle.cancel", cancel.rect, "Cancel");
if cancel.clicked() {
close = Some(false);
}
let _ = Align2::LEFT_CENTER;
});
});
for (id, p) in actions {
if let Err(e) = app.session.execute(&id, p) {
app.ui.status = e.to_string();
}
}
match close {
Some(true) => finish(app, true),
Some(false) => finish(app, false),
None => {}
}
}
/// Show another entry of the list (`blendingOptions` or a style id).
pub fn select(app: &mut EffectcraftApp, style: &str) {
app.dialog_state.layer_style.selected = style.to_string();
}
/// Close the dialog: OK keeps the edits, Cancel undoes everything done since it opened.
pub fn finish(app: &mut EffectcraftApp, ok: bool) {
if !ok {
let mark = app.dialog_state.layer_style.undo_mark;
while app.session.history.undo.len() > mark {
if !app.session.undo() {
break;
}
}
}
app.dialog = None;
}
+1
View File
@@ -18,6 +18,7 @@ pub mod home;
pub mod info;
pub mod key_dialogs;
pub mod layer_panel;
pub mod layer_styles_dialog;
pub mod markers_ui;
pub mod misc;
pub mod paint_panels;
+40 -2
View File
@@ -210,16 +210,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let name_w = (rect.width() * 0.40).clamp(120.0, 260.0);
let label_x = rect.min.x + name_w + 14.0;
let mut cols: Vec<(&'static str, &'static str, f32, f32)> = vec![("name", "Name", rect.min.x + 26.0, name_w - 26.0), ("label", "", label_x - 8.0, 18.0)];
let mut x = label_x + 16.0;
// Optional columns wider than the panel scroll horizontally under the frozen Name and Label
// columns (Shift+wheel / trackpad over the list, or the scroll bar under it).
let opt_x0 = label_x + 16.0;
let natural_end = opt_x0 + app.ui.project_columns.iter().filter_map(|k| COLUMNS.iter().find(|c| c.0 == k)).map(|c| c.2 + 4.0).sum::<f32>();
let overflow = (natural_end - rect.max.x + 4.0).max(0.0);
app.ui.project_hscroll = app.ui.project_hscroll.clamp(0.0, overflow);
let hscroll = app.ui.project_hscroll;
let mut x = opt_x0 - hscroll;
for key in app.ui.project_columns.clone() {
if let Some((k, l, w)) = COLUMNS.iter().find(|c| c.0 == key) {
cols.push((k, l, x, *w));
x += w + 4.0;
}
}
let opt_clip = |r: Rect| Rect::from_min_max(pos2(r.min.x.max(opt_x0 - 4.0), r.min.y), r.max);
let hp = p.with_clip_rect(hdr);
for (key, label, x, w) in &cols {
let hr = Rect::from_min_size(pos2(x - 4.0, hdr.min.y), vec2(*w, hdr.height()));
let hr = if matches!(*key, "name" | "label") { hr } else { opt_clip(hr) };
if hr.width() <= 0.0 {
continue;
}
let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click());
hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim });
if *key == "label" {
@@ -252,6 +264,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let footer_h = 28.0;
let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h));
let lp = p.with_clip_rect(list);
if overflow > 0.0 && ui.rect_contains_pointer(list) {
let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
let d = if dx.abs() > 0.0 {
dx
} else if shift {
dy
} else {
0.0
};
app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow);
}
if overflow > 0.0 {
let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
let tw = (track.width() * track.width() / (track.width() + overflow)).max(16.0);
let thumb = Rect::from_min_size(pos2(track.min.x + (track.width() - tw) * (hscroll / overflow), track.min.y), vec2(tw, track.height()));
let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("proj-hscroll"), Sense::drag());
p.rect_filled(track, 2.0, t.field_bg);
p.rect_filled(thumb, 2.0, if sresp.hovered() || sresp.dragged() { t.text_dim } else { t.text_faint });
app.auto.add("project.hscroll", track, &format!("{hscroll}/{overflow}"));
if sresp.dragged() {
app.ui.project_hscroll = (hscroll + sresp.drag_delta().x * overflow / (track.width() - tw).max(1.0)).clamp(0.0, overflow);
}
}
let mut y = list.min.y;
let query = app.ui.project_search.to_lowercase();
let mut rows: Vec<(ItemId, usize)> = vec![];
@@ -390,7 +425,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// Optional columns.
for (key, _, cx, w) in cols.iter().skip(2) {
let cell = Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list);
let cell = opt_clip(Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list));
if cell.width() <= 0.0 {
continue;
}
let cp = lp.with_clip_rect(cell);
let txt = match *key {
"type" => it.type_name().to_string(),
+2 -2
View File
@@ -87,8 +87,8 @@ pub fn draw_overlay(
let bc = c32(o.boundary_color, 255);
let oc = c32(o.overlay_color, (o.overlay_opacity / 100.0 * 255.0).clamp(0.0, 255.0) as u8);
// View Search Region: the band around the boundary (yellow).
let search = roto_group(layer).filter(|g| g.prop("rotoBrushMatte/viewSearchRegion").is_some_and(|p| p.value.as_bool())).map(|g| {
let r = g.prop("rotoBrushMatte/searchRadius").map(|p| p.value.as_f64()).unwrap_or(15.0) * scale;
let search = roto_group(layer).filter(|g| g.prop("rotoBrushPropagation/viewSearchRegion").is_some_and(|p| p.value.as_bool())).map(|g| {
let r = g.prop("rotoBrushPropagation/searchRadius").map(|p| p.value.as_f64()).unwrap_or(15.0) * scale;
let bin: Vec<u8> = alpha.iter().map(|a| (*a >= 0.5) as u8).collect();
let sd = effectcraft_engine::track::roto::matting::signed_distance(&bin, w, h);
(sd, r as f32)
+16
View File
@@ -232,6 +232,22 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add("character.ligatures", lr, "Ligatures");
p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Ligatures", Tokens::ui(11.5), t.text_dim);
// Vertical type: Tate-Chu-Yoko and Standard Vertical Roman Alignment (Character panel menu).
if doc.vertical {
y += 22.0;
for (i, (key, label, on)) in
[("tateChuYoko", "Tate-Chu-Yoko", doc.tate_chu_yoko), ("verticalRomanUpright", "Standard Vertical Roman", doc.vertical_roman_upright)]
.into_iter()
.enumerate()
{
let cr = Rect::from_min_size(pos2(x0 + i as f32 * 120.0, y), vec2(14.0, 14.0));
if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-vert", key))).clicked() && enabled {
actions.push(json!({key: !on}));
}
app.auto.add(&format!("character.{key}"), cr, label);
p.text(pos2(cr.max.x + 6.0, y + 7.0), Align2::LEFT_CENTER, label, Tokens::ui(11.0), t.text_dim);
}
}
match &target {
None => {
p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint);
+110 -8
View File
@@ -727,7 +727,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let footer_h = 24.0;
let vis = Vis::of(&app.ui.timeline);
let left_w = (vis.fixed() + 190.0).clamp(420.0, (rect.width() * 0.72).max(420.0));
let cw = cols(rect.min.x, left_w, vis);
// Columns wider than the pane scroll horizontally (Shift+wheel / trackpad over the outline,
// or the scroll bar above the footer); the name column keeps its minimum width.
let natural_w = vis.fixed() + 190.0;
let overflow = (natural_w - left_w).max(0.0);
app.ui.timeline.outline_scroll = app.ui.timeline.outline_scroll.clamp(0.0, overflow);
let oscroll = app.ui.timeline.outline_scroll;
let cw = cols(rect.min.x - oscroll, left_w + overflow, vis);
let graph_x0 = rect.min.x + left_w + 1.0;
let graph_x1 = rect.max.x - 10.0;
ctx.data_mut(|d| d.insert_temp(egui::Id::new("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0)));
@@ -936,8 +942,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.rect_filled(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)), 0.0, t.panel_bg);
p.line_segment([pos2(rect.min.x, ch.max.y), pos2(rect.max.x, ch.max.y)], Stroke::new(1.0, t.separator));
let hy = ch.center().y;
let hic = |icon: Icon, x: f32| icons::paint(&p, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim);
let htext = |x: f32, s: &str| p.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim);
// Column headers scroll with the outline and never spill into the time ruler.
let hp = p.with_clip_rect(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)));
let hic = |icon: Icon, x: f32| icons::paint(&hp, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim);
let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim);
if vis.av {
hic(Icon::Eye, cw.av + 10.0);
hic(Icon::Speaker, cw.av + 28.0);
@@ -948,7 +956,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
htext(cw.keys + 6.0, "Keys");
}
if vis.label {
icons::paint(&p, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim);
icons::paint(&hp, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim);
}
if vis.num {
htext(cw.num + 6.0, "#");
@@ -971,7 +979,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
htext(cw.trkmat + 4.0, "Track Matte");
}
if vis.parent {
icons::paint(&p, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim);
icons::paint(&hp, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim);
htext(cw.parent + 20.0, "Parent & Link");
}
for (on, x, s) in [(vis.in_, cw.in_, "In"), (vis.out, cw.out, "Out"), (vis.duration, cw.duration, "Duration"), (vis.stretch, cw.stretch, "Stretch")] {
@@ -1044,8 +1052,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if zoom && dy.abs() > 0.0 {
zoom_at(app, &comp, tm, (dy as f64 / 200.0).exp(), ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0));
} else {
app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll);
if dx.abs() > 0.0 && app.ui.timeline.pps.is_some() {
let over_outline = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.x < graph_x0);
let shift = ui.input(|i| i.modifiers.shift);
if over_outline && overflow > 0.0 && (dx.abs() > 0.0 || (shift && dy.abs() > 0.0)) {
let d = if dx.abs() > 0.0 { dx } else { dy };
app.ui.timeline.outline_scroll = (app.ui.timeline.outline_scroll - d).clamp(0.0, overflow);
} else {
app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll);
}
if dx.abs() > 0.0 && app.ui.timeline.pps.is_some() && !over_outline {
app.ui.timeline.start = (app.ui.timeline.start - dx as f64 / pps).max(0.0);
}
}
@@ -1431,6 +1446,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
// Mask mode + inverted inline.
if let Some(g) = layer.props.find_group(*uid)
&& let GroupKind::Mask { mode, inverted, .. } = g.kind
@@ -1705,7 +1721,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ks = sel_keys.contains(&kref);
let icon = k.icon();
let c = pos2(x, cy);
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, if ks { t.keyframe_selected } else { t.keyframe }, Color32::from_black_alpha(200));
// Labelled keys take their label colour (brighter when selected).
let kcol = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) {
Some(l) => {
let c = t.label(*l);
if ks { c } else { c.gamma_multiply(0.75) }
}
None if ks => t.keyframe_selected,
None => t.keyframe,
};
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol, Color32::from_black_alpha(200));
let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag());
app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
@@ -1742,6 +1767,23 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
("Select All Keyframes", "keys.selectAll", json!({})),
("Delete", "keys.delete", json!({})),
] {
if lbl == "Select All Keyframes" {
// Keyframe colour labels.
ui.menu_button("Label", |ui| {
for (i, l) in effectcraft_engine::color::Label::ALL.iter().enumerate() {
if ui.add(egui::Button::new(l.name()).selected(k.label as usize == i)).clicked() {
if !ks {
actions.push((
"keys.select".into(),
json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}),
));
}
actions.push(("keys.setLabel".into(), json!({"label": i})));
ui.close();
}
}
});
}
if lbl == "-" {
ui.separator();
continue;
@@ -1876,6 +1918,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let footer = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
p.rect_filled(footer, 0.0, t.panel_bg);
p.line_segment([footer.left_top(), footer.right_top()], Stroke::new(1.0, t.separator));
if overflow > 0.0 {
// Outline scroll bar along the top of the footer, under the columns.
let track = Rect::from_min_max(pos2(rect.min.x + 2.0, footer.min.y + 1.0), pos2(graph_x0 - 3.0, footer.min.y + 5.0));
let frac = left_w / natural_w;
let tw = (track.width() * frac).max(20.0);
let tx = track.min.x + (track.width() - tw) * (oscroll / overflow);
let thumb = Rect::from_min_size(pos2(tx, track.min.y), vec2(tw, track.height()));
p.rect_filled(track, 2.0, t.field_bg);
let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("tl-outline-scroll"), Sense::drag());
p.rect_filled(thumb, 2.0, if sresp.dragged() || sresp.hovered() { t.text_dim } else { t.text_faint });
app.auto.add("timeline.outlineScroll", track, &format!("{oscroll}/{overflow}"));
if sresp.dragged() {
let k = overflow / (track.width() - tw).max(1.0);
app.ui.timeline.outline_scroll = (oscroll + sresp.drag_delta().x * k).clamp(0.0, overflow);
}
}
let tgl = Rect::from_min_size(pos2(footer.min.x + 10.0, footer.min.y + 3.0), vec2(150.0, 18.0));
let tresp = ui.interact(tgl, egui::Id::new("tl-toggle-modes"), Sense::click());
p.text(tgl.left_center(), Align2::LEFT_CENTER, "Toggle Switches / Modes", Tokens::ui(11.0), if tresp.hovered() { t.text } else { t.text_dim });
@@ -2087,6 +2145,37 @@ fn walk_groups(g: &PropGroup, out: &mut Vec<u64>) {
}
}
/// A stroke's Dashes "+" / "−" buttons (add or remove a Dash/Gap pair).
#[allow(clippy::too_many_arguments)]
fn dash_buttons(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
uid: u64,
x: f32,
cy: f32,
actions: &mut Vec<(String, serde_json::Value)>,
) {
if !matches!(layer.source, LayerSource::Shape) || layer.props.find_group(uid).is_none_or(|g| g.match_id != "dashes") {
return;
}
let Some(stroke) = layer.props.parent_of(uid).map(|g| g.uid) else { return };
let t = app.tokens;
for (i, (sym, id, tip)) in
[("+", "shape.dashes.add", "Add a Dash or Gap"), ("\u{2212}", "shape.dashes.remove", "Remove a Dash or Gap")].into_iter().enumerate()
{
let r = Rect::from_center_size(pos2(x + 12.0 + i as f32 * 20.0, cy), vec2(16.0, 16.0));
let resp = ui.interact(r, egui::Id::new(("tl-dash", uid, i)), Sense::click()).on_hover_text(tip);
p.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
p.text(r.center(), Align2::CENTER_CENTER, sym, Tokens::ui(12.0), if resp.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.group.{uid}.{}", if i == 0 { "addDash" } else { "removeDash" }), r, tip);
if resp.clicked() {
actions.push((id.into(), json!({"layer": layer.id.0, "prop": stroke})));
}
}
}
/// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus).
#[allow(clippy::too_many_arguments)]
fn text_anim_popups(
@@ -2135,6 +2224,19 @@ fn text_anim_popups(
opts.push(l.to_string());
acts.push(if *k == "-" { (String::new(), json!(null)) } else { ("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": k})) });
}
// Variable Font Axes of the layer's font.
if let Some(effectcraft_engine::keyframe::Value::Text(doc)) = layer.props.prop("text/sourceText").map(|p| p.value.clone()) {
let face = effectcraft_engine::text::resolve(&doc.font, &doc.style).face;
let axes = effectcraft_engine::text::variable::font_axes(face);
if !axes.is_empty() {
opts.push("-".into());
acts.push((String::new(), json!(null)));
}
for a in axes {
opts.push(format!("Variable Font Axes: {}", a.name));
acts.push(("text.animatorFontAxes".into(), json!({"layer": layer.id.0, "axis": a.tag})));
}
}
} else {
for (kind, l) in [("range", "Selector: Range"), ("wiggly", "Selector: Wiggly"), ("expression", "Selector: Expression")] {
opts.push(l.into());
+62 -8
View File
@@ -156,6 +156,32 @@ thread_local! {
static VIEW_CAM: std::cell::Cell<Option<CameraState>> = const { std::cell::Cell::new(None) };
}
/// Region of interest handles on screen: corners (0–3, clockwise from top-left) then edge
/// midpoints (4 top, 5 right, 6 bottom, 7 left).
fn roi_handles(r: Rect) -> [Pos2; 8] {
[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.center_top(), r.right_center(), r.center_bottom(), r.left_center()]
}
/// A drag starting on a region of interest handle: the ROI redrawn from the opposite corner (or
/// edge, with the other axis kept).
fn roi_drag(app: &EffectcraftApp, map: &ViewerMap, comp_rect: Rect, zoom: f32, press: Pos2) -> Option<ov::Drag> {
let [rx, ry, rw, rh] = app.session.state.region_of_interest?;
let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom));
let i = roi_handles(r).iter().position(|h| h.distance(press) < 6.0)?;
let (x0, y0, x1, y1) = (rx, ry, rx + rw, ry + rh);
let _ = map;
Some(match i {
0 => ov::Drag::Roi { start: [x1, y1], keep: [None, None] },
1 => ov::Drag::Roi { start: [x0, y1], keep: [None, None] },
2 => ov::Drag::Roi { start: [x0, y0], keep: [None, None] },
3 => ov::Drag::Roi { start: [x1, y0], keep: [None, None] },
4 => ov::Drag::Roi { start: [x1, y1], keep: [Some(x0), None] },
5 => ov::Drag::Roi { start: [x0, y0], keep: [None, Some(y1)] },
6 => ov::Drag::Roi { start: [x0, y0], keep: [Some(x1), None] },
_ => ov::Drag::Roi { start: [x1, y0], keep: [None, Some(y1)] },
})
}
/// The camera the viewer shows (view camera or the comp's active camera).
fn cam_state(ctx: &EvalCtx) -> CameraState {
VIEW_CAM.with(|c| c.get()).unwrap_or_else(|| three_d::active_camera(ctx))
@@ -532,11 +558,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if let Some([rx, ry, rw, rh]) = app.session.state.region_of_interest {
let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] {
painter.rect_filled(Rect::from_center_size(c, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
// Resize handles: corners and edge midpoints (drag with any tool).
for (i, c) in roi_handles(r).iter().enumerate() {
painter.rect_filled(Rect::from_center_size(*c, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
app.auto.add(&format!("viewer.regionOfInterest.handle.{i}"), Rect::from_center_size(*c, vec2(8.0, 8.0)), "Resize the region of interest");
}
app.auto.add("viewer.regionOfInterest", r, "Region of interest");
}
// 3D Reference Axes (grid and guide options): the world axes as seen by the view's camera.
if app.session.prefs.three_d.show_reference_axes && comp.layers.iter().any(|l| l.is_3d()) {
let cam = cam_state(&ectx);
let o = pos2(area.min.x + 40.0, area.max.y - 40.0);
for (axis, col, name) in [
(effectcraft_engine::geom::vec3(1.0, 0.0, 0.0), Color32::from_rgb(0xe0, 0x40, 0x40), "X"),
(effectcraft_engine::geom::vec3(0.0, 1.0, 0.0), Color32::from_rgb(0x50, 0xd0, 0x50), "Y"),
(effectcraft_engine::geom::vec3(0.0, 0.0, 1.0), Color32::from_rgb(0x50, 0x80, 0xf0), "Z"),
] {
let v = cam.view.apply_vec(axis);
let tip = o + vec2(v.x as f32, v.y as f32) * 26.0;
painter.line_segment([o, tip], Stroke::new(2.0, col));
painter.text(tip + (tip - o).normalized() * 7.0, Align2::CENTER_CENTER, name, Tokens::ui(10.0), col);
}
app.auto.add("viewer.referenceAxes", Rect::from_center_size(o, vec2(64.0, 64.0)), "3D Reference Axes");
}
// Overlays + interaction.
let selected = app.session.state.selected_layers.clone();
@@ -767,7 +811,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let g = match tool {
Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }),
// Region of Interest armed: the drag draws it.
_ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press) })),
_ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press), keep: [None, None] })),
// A region of interest handle: resize it from the opposite corner / edge.
_ if roi_drag(app, &map, comp_rect, zoom, press).is_some() => roi_drag(app, &map, comp_rect, zoom, press).map(Gesture::Overlay),
Tool::Selection if ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).is_some() => {
ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).map(Gesture::Overlay)
}
@@ -806,7 +852,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
layer: l,
start_anchor: ectx.v3(layer, tr, "anchor", [0.0; 3]),
start_pos: ectx.v3(layer, tr, "position", [0.0; 3]),
start: cpt,
// From where the button went down (the drag starts a few points later).
start: map.to_comp(press),
inv: l2c.inverse().unwrap_or(Mat3::IDENTITY),
l2p,
})
@@ -979,6 +1026,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge}));
}
Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p } => {
// Pan Behind snaps the anchor point (to layer features, guides and the grid).
let anchor_c = inv.inverse().map(|m| m.apply(gv2(start_anchor[0], start_anchor[1]))).unwrap_or(gv2(start[0], start[1]));
let want = [anchor_c.x + cpt[0] - start[0], anchor_c.y + cpt[1] - start[1]];
let sc = vt::snap(app, &ctx, &ectx, &map, &[layer], &[want], mods);
let cpt = [cpt[0] + sc[0], cpt[1] + sc[1]];
let a = inv.apply(gv2(start[0], start[1]));
let b = inv.apply(gv2(cpt[0], cpt[1]));
let dl = gv2(b.x - a.x, b.y - a.y);
@@ -1023,8 +1075,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let pos_v = if vertical { cpt[0] } else { cpt[1] }.round();
let _ = app.session.execute("view.moveGuide", json!({"index": index, "position": pos_v, "merge": merge}));
}
ov::Drag::Roi { start } => {
let r = Rect::from_two_pos(map.to_screen(start), pos);
ov::Drag::Roi { start, keep } => {
let e = [keep[0].unwrap_or(cpt[0]), keep[1].unwrap_or(cpt[1])];
let r = Rect::from_two_pos(map.to_screen(start), map.to_screen(e));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
}
d => {
@@ -1080,8 +1133,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let end = map.to_comp(pos);
create_shape(app, tool, start, end, mods.shift);
}
Gesture::Overlay(ov::Drag::Roi { start }) => {
let end = map.to_comp(pos);
Gesture::Overlay(ov::Drag::Roi { start, keep }) => {
let c = map.to_comp(pos);
let end = [keep[0].unwrap_or(c[0]), keep[1].unwrap_or(c[1])];
let (x0, y0) = (start[0].min(end[0]).max(0.0), start[1].min(end[1]).max(0.0));
let (x1, y1) = (start[0].max(end[0]).min(comp.width as f64), start[1].max(end[1]).min(comp.height as f64));
if x1 - x0 >= 2.0 && y1 - y0 >= 2.0 {
+80 -17
View File
@@ -41,6 +41,8 @@ pub(crate) struct PathInfo {
pub m: Mat3,
pub sp: ShapePath,
pub is_mask: bool,
/// Mask feather points' radius handles on screen (feather point index, handle position).
pub feather: Vec<(usize, Pos2)>,
}
fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] {
@@ -93,7 +95,7 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option<PathIn
let (m, _) = super::viewer::l2c(ectx, l);
let is_mask = matches!(g.kind, GroupKind::Mask { .. });
let m = if is_mask { m } else { m * effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY) };
Some(PathInfo { layer, uid, m, sp, is_mask })
Some(PathInfo { layer, uid, m, sp, is_mask, feather: Vec::new() })
}
fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
@@ -178,10 +180,55 @@ pub(crate) fn draw_paths(
{
painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6)));
}
paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask });
let feather = if is_mask { draw_feather_points(painter, map, &sp, &pm, mc) } else { Vec::new() };
for (i, h) in &feather {
app.auto.add(&format!("viewer.mask.{uid}.feather.{i}"), Rect::from_center_size(*h, vec2(8.0, 8.0)), &name);
}
paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask, feather });
}
}
/// Mask feather points: a hollow dot on the path and a radius handle along the normal (outwards for
/// outer feather, inwards for inner), joined by a line. Returns the handles' screen positions.
fn draw_feather_points(painter: &egui::Painter, map: &ViewerMap, sp: &ShapePath, pm: &Mat3, col: Color32) -> Vec<(usize, Pos2)> {
let mut out = Vec::new();
let scr = |p: [f64; 2]| {
let c = pm.apply(gv2(p[0], p[1]));
map.to_screen([c.x, c.y])
};
let inside = |q: [f64; 2]| {
// Even-odd test against the vertex polygon (good enough to orient the handle).
let v = &sp.vertices;
let mut c = false;
for i in 0..v.len() {
let (a, b) = (v[i], v[(i + 1) % v.len()]);
if (a[1] > q[1]) != (b[1] > q[1]) && q[0] < (b[0] - a[0]) * (q[1] - a[1]) / (b[1] - a[1]) + a[0] {
c = !c;
}
}
c
};
for (i, f) in sp.feather.iter().enumerate() {
let Some(c) = segment(sp, f.segment) else { continue };
let at = cubic(c[0], c[1], c[2], c[3], f.t);
let (t0, t1) = ((f.t - 1e-3).max(0.0), (f.t + 1e-3).min(1.0));
let (a, b) = (cubic(c[0], c[1], c[2], c[3], t0), cubic(c[0], c[1], c[2], c[3], t1));
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = dx.hypot(dy).max(1e-9);
let mut n = [-dy / len, dx / len];
if inside([at[0] + n[0] * 2.0, at[1] + n[1] * 2.0]) {
n = [-n[0], -n[1]];
}
let h = [at[0] + n[0] * f.radius, at[1] + n[1] * f.radius];
let (ps, hs) = (scr(at), scr(h));
painter.line_segment([ps, hs], Stroke::new(1.0, col));
painter.circle_stroke(ps, 3.5, Stroke::new(1.0, col));
painter.circle_filled(hs, 3.0, col);
out.push((i, hs));
}
out
}
// ---------------------------------------------------------------- motion paths
/// A keyframe dot or spatial tangent handle on a motion path.
@@ -382,12 +429,12 @@ pub(crate) enum Drag {
KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 },
/// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`.
Ft { layer: LayerId, path: u64, mode: u8, anchor: [f64; 2], last: [f64; 2], inv: Mat3, axes: [bool; 2] },
/// Mask Feather tool: drag away from the path to feather the mask.
Feather { layer: LayerId, prop: u64, start: [f64; 2], press: Pos2 },
/// Mask Feather tool: drag a feather point's radius handle (comp → layer matrix `inv`).
Feather { layer: LayerId, mask: u64, index: usize, inv: Mat3 },
/// A guide dragged in the viewer.
Guide { index: usize, vertical: bool },
/// Drawing the region of interest.
Roi { start: [f64; 2] },
/// Drawing the region of interest from `start`; `keep` pins the end's x / y (edge handles).
Roi { start: [f64; 2], keep: [Option<f64>; 2] },
}
/// Start a motion-path drag at `press` (keys before tangents), selecting the key.
@@ -436,13 +483,26 @@ pub(crate) fn begin_ft_drag(f: &FtBox, press: Pos2, map: &ViewerMap) -> Option<D
None
}
/// Mask Feather tool: start on a mask path.
pub(crate) fn begin_feather(app: &EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option<Drag> {
let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::<Vec<_>>(), map, press, 10.0)?;
let l = ectx.comp.layer(layer)?;
let pr = l.masks()?.find_group(uid)?.get("feather")?;
let _ = app;
Some(Drag::Feather { layer, prop: pr.uid, start: ectx.value(l, pr).as_vec2(), press })
/// Mask Feather tool: press on a feather point's handle to drag it, or on a mask path to add a
/// feather point there and drag its radius out.
pub(crate) fn begin_feather(app: &mut EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option<Drag> {
let _ = ectx;
for p in paths.iter().filter(|p| p.is_mask) {
if let Some((i, _)) = p.feather.iter().find(|(_, h)| h.distance(press) < 7.0) {
return Some(Drag::Feather { layer: p.layer, mask: p.uid, index: *i, inv: p.m.inverse()? });
}
}
let masks: Vec<PathInfo> = paths.iter().filter(|p| p.is_mask).cloned().collect();
let (layer, uid, seg, t) = segment_at(&masks, map, press, 10.0)?;
let inv = masks.iter().find(|p| p.layer == layer && p.uid == uid)?.m.inverse()?;
let r = app.session.execute("mask.featherPoint.add", json!({"layer": layer.0, "mask": uid, "segment": seg, "t": t, "radius": 0}));
match r {
Ok(v) => Some(Drag::Feather { layer, mask: uid, index: v["index"].as_u64()? as usize, inv }),
Err(e) => {
app.ui.status = e.to_string();
None
}
}
}
/// Advance an overlay drag to the pointer (`cpt` comp, `pos` screen). `snap` corrects a comp
@@ -513,10 +573,13 @@ pub(crate) fn update(
}
Some(Drag::Ft { layer, path, mode, anchor, last: cur, inv, axes })
}
Drag::Feather { layer, prop, start, press } => {
let amt = (pos.distance(press) / zoom.max(1e-3)) as f64;
let v = json!([(start[0] + amt).max(0.0), (start[1] + amt).max(0.0)]);
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "prop": prop, "value": v, "merge": merge}));
Drag::Feather { layer, mask, index, inv } => {
let p = inv.apply(gv2(cpt[0], cpt[1]));
let _ = (pos, zoom);
let q = json!({"layer": layer.0, "mask": mask, "index": index, "toward": [p.x, p.y], "merge": merge});
if let Err(e) = app.session.execute("mask.featherPoint.set", q) {
app.ui.status = e.to_string();
}
None
}
Drag::Guide { index, .. } => {
+11 -1
View File
@@ -54,6 +54,8 @@ struct Edited {
/// Layer → comp.
m: Mat3,
inv: Mat3,
/// Per caret position: static layout → animated / on-path character placement.
carets: Vec<Mat3>,
}
impl Edited {
@@ -111,7 +113,8 @@ fn edited(app: &EffectcraftApp, ectx: &EvalCtx, lid: LayerId) -> Option<Edited>
let lay = layout_doc(&doc);
let m = super::viewer::l2c(ectx, &layer).0;
let inv = m.inverse()?;
Some(Edited { layer, doc, lay, m, inv })
let carets = effectcraft_engine::render::text::caret_maps(ectx, &layer, lay.chars);
Some(Edited { layer, doc, lay, m, inv, carets })
}
/// The topmost text layer whose text is under screen point `s`.
@@ -494,7 +497,14 @@ fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, ma
}
}
// Caret (blinking) and IME composition.
// The caret follows the animated layout and text on a path.
let (a, b) = e.lay.caret(caret);
let cm = e.carets.get(caret).copied().unwrap_or(Mat3::IDENTITY);
let anim = |p: Point| {
let q = cm.apply(gv2(p.x, p.y));
Point::new(q.x, q.y)
};
let (a, b) = (anim(a), anim(b));
let (sa, sb) = (to_s(a), to_s(b));
let now = ui.input(|i| i.time);
let preedit: Option<String> = ui.data(|d| d.get_temp(egui::Id::new("viewer-text-preedit")));
+33 -13
View File
@@ -370,19 +370,35 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, "Snapshot", Tokens::ui(11.0), SNAP_COLOR);
return;
}
let Some((tex, k)) = app.viewer_tex.clone() else { return };
if k.comp != cid.0 {
return;
}
let zoom = comp_rect.width() as f64 / ectx.comp.width.max(1) as f64;
let rect = match texture_roi(ctx) {
Some([x, y, w, h]) => Rect::from_min_size(comp_rect.min + vec2((x * zoom) as f32, (y * zoom) as f32), vec2((w * zoom) as f32, (h * zoom) as f32)),
None => comp_rect,
// A GPU frame (the web viewer): drawn straight from its texture; Show Channel / exposure
// read it back first (asynchronously in the browser: from the next frame on).
let gpu = matches!((&app.viewer_shown, &app.viewer_native), (Some(s), Some(n)) if s.0 == n.0 && s.1 == n.1 && n.1.comp == cid.0);
let rect = if gpu {
let id = app.viewer_shown.map(|s| s.0).expect("shown");
if plain || app.viewer_pixels().is_none() {
if !plain {
ctx.request_repaint();
}
painter.image(id, comp_rect, uv, Color32::WHITE);
return;
}
comp_rect
} else {
let Some((tex, k)) = app.viewer_tex.clone() else { return };
if k.comp != cid.0 {
return;
}
let zoom = comp_rect.width() as f64 / ectx.comp.width.max(1) as f64;
let rect = match texture_roi(ctx) {
Some([x, y, w, h]) => Rect::from_min_size(comp_rect.min + vec2((x * zoom) as f32, (y * zoom) as f32), vec2((w * zoom) as f32, (h * zoom) as f32)),
None => comp_rect,
};
if plain {
painter.image(tex.id(), rect, uv, Color32::WHITE);
return;
}
rect
};
if plain {
painter.image(tex.id(), rect, uv, Color32::WHITE);
return;
}
let Some(src) = app.viewer_image.clone() else { return };
let key = {
use std::hash::{Hash, Hasher};
@@ -578,6 +594,8 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
("Grid".to_string(), v.grid),
("Guides".to_string(), v.guides),
("Rulers".to_string(), v.rulers),
("-".to_string(), false),
("3D Reference Axes".to_string(), app.session.prefs.three_d.show_reference_axes),
];
if let Some(i) = popup(app, ui, "vw-grid-pop", gr, &items, "gridItem") {
let v = &mut app.ui.viewer;
@@ -586,7 +604,9 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
1 => v.proportional_grid = !v.proportional_grid,
2 => v.grid = !v.grid,
3 => v.guides = !v.guides,
_ => v.rulers = !v.rulers,
4 => v.rulers = !v.rulers,
6 => app.session.prefs.three_d.show_reference_axes = !app.session.prefs.three_d.show_reference_axes,
_ => {}
}
}
// Show Channel and Color Management Settings.
+8
View File
@@ -340,6 +340,9 @@ pub struct TimelineState {
pub scroll_y: f32,
/// Left column area width.
pub columns_w: f32,
/// Horizontal scroll of the outline columns (pixels) when they are wider than the pane.
#[serde(default)]
pub outline_scroll: f32,
pub show_modes: bool,
pub graph_editor: bool,
pub search: String,
@@ -403,6 +406,7 @@ impl Default for TimelineState {
pps: None,
scroll_y: 0.0,
columns_w: 560.0,
outline_scroll: 0.0,
show_modes: false,
graph_editor: false,
search: String::new(),
@@ -545,6 +549,9 @@ pub struct UiState {
/// `duration`, `path`, `comment`); toggled from the column header's context menu.
#[serde(default = "default_project_columns")]
pub project_columns: Vec<String>,
/// Horizontal scroll of the Project panel's optional columns (Name and Label stay put).
#[serde(default)]
pub project_hscroll: f32,
/// Effect Controls twirl state (group uids that are collapsed).
pub fx_closed: BTreeSet<u64>,
/// Slider params whose slider row is twirled open (AE hides sliders by default).
@@ -631,6 +638,7 @@ impl Default for UiState {
project_open_folders: BTreeSet::new(),
project_sort: default_project_sort(),
project_sort_desc: false,
project_hscroll: 0.0,
project_columns: default_project_columns(),
fx_closed: BTreeSet::new(),
fx_slider_open: BTreeSet::new(),