mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 21:48:20 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, ¶ms)? {
|
||||
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, ¶ms)? {
|
||||
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"));
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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})));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user