mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Merge main into M13.5 long-tail polish
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
|
||||
|
||||
@@ -35,10 +35,11 @@ pub enum PanelKind {
|
||||
Markers,
|
||||
MaskInterpolation,
|
||||
ScriptConsole,
|
||||
EssentialGraphics,
|
||||
}
|
||||
|
||||
impl PanelKind {
|
||||
pub const ALL: [PanelKind; 25] = [
|
||||
pub const ALL: [PanelKind; 26] = [
|
||||
PanelKind::Project,
|
||||
PanelKind::EffectControls,
|
||||
PanelKind::Composition,
|
||||
@@ -64,6 +65,7 @@ impl PanelKind {
|
||||
PanelKind::Markers,
|
||||
PanelKind::MaskInterpolation,
|
||||
PanelKind::ScriptConsole,
|
||||
PanelKind::EssentialGraphics,
|
||||
];
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
@@ -92,6 +94,7 @@ impl PanelKind {
|
||||
PanelKind::Markers => "Markers",
|
||||
PanelKind::MaskInterpolation => "Mask Interpolation",
|
||||
PanelKind::ScriptConsole => "Script Console",
|
||||
PanelKind::EssentialGraphics => "Essential Graphics",
|
||||
}
|
||||
}
|
||||
pub fn id(self) -> String {
|
||||
|
||||
+103
-18
@@ -7,6 +7,11 @@
|
||||
//! wasm32 has no threads: jobs wait in the same priority queue and [`Frames::pump`] renders them
|
||||
//! on the UI thread between egui frames, within a time budget.
|
||||
//!
|
||||
//! With the disk cache on (Settings ▸ Media & Disk Cache), every CPU-rendered frame is also
|
||||
//! written to disk under a content key (the comp's content hash, frame, scale, view and render
|
||||
//! options), and a job first looks there: frames survive restarts and project reopenings. The
|
||||
//! timeline shows disk-only frames as a blue cache bar.
|
||||
//!
|
||||
//! With Mercury GPU Acceleration (a [`Gpu`] on egui-wgpu's device and the project's renderer set
|
||||
//! to the GPU) frames stay on the GPU: the compositor writes an RGBA8 texture that the viewer
|
||||
//! registers with egui-wgpu and draws directly; pixels are read back only when something needs
|
||||
@@ -16,6 +21,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_engine::project::{ItemId, Project};
|
||||
use effectcraft_engine::render::disk_cache::{self, DiskCache};
|
||||
use effectcraft_engine::render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_gpu::{DisplayFrame, Gpu};
|
||||
@@ -118,10 +124,43 @@ 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>>,
|
||||
}
|
||||
|
||||
/// Content keys of (project revision, comp), computed once per revision.
|
||||
type ContentKeys = Arc<Mutex<HashMap<(u64, u64), u128>>>;
|
||||
|
||||
fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) -> u128 {
|
||||
if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp)).copied()) {
|
||||
return k;
|
||||
}
|
||||
let k = disk_cache::comp_content_key(project, ItemId(comp));
|
||||
if let Ok(mut m) = keys.lock() {
|
||||
if m.len() > 256 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert((revision, comp), k);
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
fn opts_hash(o: &RenderOpts) -> u64 {
|
||||
let mut h = disk_cache::Hash128::default();
|
||||
h.write(format!("{o:?}").as_bytes());
|
||||
h.finish() as u64
|
||||
}
|
||||
|
||||
/// Disk key of a viewer frame (render options included: region of interest, draft…).
|
||||
fn frame_disk_key(keys: &ContentKeys, project: &Project, key: &FrameKey, opts_hash: u64) -> u128 {
|
||||
let content = content_key(keys, project, key.revision, key.comp);
|
||||
disk_cache::frame_key(content, key.frame, key.scale, key.view ^ opts_hash)
|
||||
}
|
||||
|
||||
pub struct Frames {
|
||||
@@ -135,6 +174,7 @@ pub struct Frames {
|
||||
ctx: Option<egui::Context>,
|
||||
/// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout.
|
||||
pub last_ms: Arc<Mutex<f64>>,
|
||||
content_keys: ContentKeys,
|
||||
}
|
||||
|
||||
impl Default for Frames {
|
||||
@@ -149,6 +189,7 @@ impl Default for Frames {
|
||||
pool: rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("ec-frame-{i}")).build().expect("frame pool"),
|
||||
ctx: None,
|
||||
last_ms: Arc::new(Mutex::new(0.0)),
|
||||
content_keys: Arc::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,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()
|
||||
}
|
||||
@@ -195,6 +241,21 @@ impl Frames {
|
||||
v
|
||||
}
|
||||
|
||||
/// Frames of (revision, comp, scale) in the disk cache but not in RAM — the blue cache bar.
|
||||
/// `opts` are the viewer's render options (part of the disk key).
|
||||
pub fn disk_frames(&self, src: &RenderSource, revision: u64, comp: u64, scale: u32, view: u64, frames: i64, opts: &RenderOpts) -> Vec<i64> {
|
||||
let Some(dc) = &src.disk else { return vec![] };
|
||||
let ram: HashSet<i64> = self.cached_frames(revision, comp, scale).into_iter().collect();
|
||||
let oh = opts_hash(opts);
|
||||
(0..frames.clamp(0, 100_000))
|
||||
.filter(|f| !ram.contains(f))
|
||||
.filter(|f| {
|
||||
let key = FrameKey { revision, comp, frame: *f, scale, view };
|
||||
dc.contains(disk_cache::Kind::Frame, frame_disk_key(&self.content_keys, &src.project, &key, oh))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Change the RAM preview cache budget (Settings ▸ Memory & CPU), evicting the oldest
|
||||
/// frames when it shrinks.
|
||||
pub fn set_budget(&self, bytes: usize) {
|
||||
@@ -278,7 +339,14 @@ impl Frames {
|
||||
}
|
||||
|
||||
fn worker(&self) -> Worker {
|
||||
Worker { queue: self.queue.clone(), cache: self.cache.clone(), inflight: self.inflight.clone(), ctx: self.ctx.clone(), last: self.last_ms.clone() }
|
||||
Worker {
|
||||
queue: self.queue.clone(),
|
||||
cache: self.cache.clone(),
|
||||
inflight: self.inflight.clone(),
|
||||
ctx: self.ctx.clone(),
|
||||
last: self.last_ms.clone(),
|
||||
content_keys: self.content_keys.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render queued frames on this thread, most important first, until the queue is empty or
|
||||
@@ -306,12 +374,13 @@ struct Worker {
|
||||
inflight: Arc<Mutex<HashSet<FrameKey>>>,
|
||||
ctx: Option<egui::Context>,
|
||||
last: Arc<Mutex<f64>>,
|
||||
content_keys: ContentKeys,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
/// Render the queued job that matters most right now. `false` when the queue was empty.
|
||||
fn run_next(&self) -> bool {
|
||||
let Worker { queue, cache, inflight, ctx, last } = self;
|
||||
let Worker { queue, cache, inflight, ctx, last, content_keys } = self;
|
||||
let job = {
|
||||
let Ok(mut q) = queue.lock() else { return false };
|
||||
// Urgent first (newest), then prefetch in request order.
|
||||
@@ -325,19 +394,18 @@ impl Worker {
|
||||
job
|
||||
};
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts);
|
||||
r.expr = job.src.expr.as_deref();
|
||||
r.cache = Some(&job.src.layer_cache);
|
||||
r.accel = job.src.gpu.as_ref().map(|g| g as &dyn effectcraft_engine::render::Accelerator);
|
||||
// The GPU leaves the frame in a texture for the viewer; otherwise (Software Only, no
|
||||
// adapter, or a frame the GPU cannot finish here) the CPU renders it.
|
||||
let gpu_frame = match (&job.src.gpu, r.active_accel()) {
|
||||
(Some(g), Some(_)) if job.src.gpu_display => g.render_display(&r, job.comp, job.t),
|
||||
_ => None,
|
||||
};
|
||||
let ci = match gpu_frame {
|
||||
Some(f) => FrameImage::Gpu(Arc::new(f)),
|
||||
None => FrameImage::Cpu(Arc::new(to_color_image(&r.comp_frame(job.comp, job.t)))),
|
||||
let disk = job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts))));
|
||||
let from_disk = disk.as_ref().and_then(|(dc, k)| dc.get_frame(*k));
|
||||
let ci = match from_disk {
|
||||
Some(f) => FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))),
|
||||
None => {
|
||||
let ci = self.render_job(&job);
|
||||
if let (Some((dc, k)), FrameImage::Cpu(img)) = (&disk, &ci) {
|
||||
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_array()).collect();
|
||||
dc.put_frame(*k, img.size[0] as u32, img.size[1] as u32, &rgba);
|
||||
}
|
||||
ci
|
||||
}
|
||||
};
|
||||
if job.urgent
|
||||
&& let Ok(mut l) = last.lock()
|
||||
@@ -361,6 +429,23 @@ impl Worker {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn render_job(&self, job: &Job) -> FrameImage {
|
||||
let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts);
|
||||
r.expr = job.src.expr.as_deref();
|
||||
r.cache = Some(&job.src.layer_cache);
|
||||
r.accel = job.src.gpu.as_ref().map(|g| g as &dyn effectcraft_engine::render::Accelerator);
|
||||
// The GPU leaves the frame in a texture for the viewer; otherwise (Software Only, no
|
||||
// adapter, or a frame the GPU cannot finish here) the CPU renders it.
|
||||
let gpu_frame = match (&job.src.gpu, r.active_accel()) {
|
||||
(Some(g), Some(_)) if job.src.gpu_display => g.render_display(&r, job.comp, job.t),
|
||||
_ => None,
|
||||
};
|
||||
match gpu_frame {
|
||||
Some(f) => FrameImage::Gpu(Arc::new(f)),
|
||||
None => FrameImage::Cpu(Arc::new(to_color_image(&r.comp_frame(job.comp, job.t)))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Job {
|
||||
|
||||
+71
-14
@@ -140,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).
|
||||
@@ -200,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,
|
||||
@@ -325,10 +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) {
|
||||
@@ -360,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()
|
||||
}
|
||||
@@ -423,13 +463,11 @@ impl EffectcraftApp {
|
||||
self.request_frame_with(comp, frame, scale, true);
|
||||
}
|
||||
|
||||
fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) {
|
||||
let Some(c) = self.session.project.comp(comp) else { return };
|
||||
let key = self.frame_key(comp, frame, scale);
|
||||
let t = c.frame_rate.tick_of(frame);
|
||||
/// Render options of viewer frames of `comp` at `scale`.
|
||||
pub fn frame_opts(&self, comp: ItemId, scale: f64) -> RenderOpts {
|
||||
let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting);
|
||||
let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp));
|
||||
let opts = RenderOpts {
|
||||
RenderOpts {
|
||||
scale,
|
||||
motion_blur: true,
|
||||
guides: true,
|
||||
@@ -437,7 +475,15 @@ impl EffectcraftApp {
|
||||
view: self.session.view_camera(comp),
|
||||
roi,
|
||||
backend: effectcraft_engine::render::Backend::Auto,
|
||||
};
|
||||
proxy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) {
|
||||
let Some(c) = self.session.project.comp(comp) else { return };
|
||||
let key = self.frame_key(comp, frame, scale);
|
||||
let t = c.frame_rate.tick_of(frame);
|
||||
let opts = self.frame_opts(comp, scale);
|
||||
if urgent {
|
||||
self.frames.request_urgent(&self.render_source(), key, comp, t, opts);
|
||||
} else {
|
||||
@@ -534,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);
|
||||
@@ -740,12 +787,22 @@ 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);
|
||||
self.session.poll_roto(true);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
// 3D Camera Tracker: likewise (tracking, then solving).
|
||||
if self.session.camera_job.is_some() || !self.session.camera_pending.is_empty() {
|
||||
self.session.poll_camera(true);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
self.apply_prefs(&ctx);
|
||||
self.handle_events(&ctx);
|
||||
self.tick_autosave(&ctx);
|
||||
|
||||
@@ -794,6 +794,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||
"file.runScript" => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
|
||||
"file.replaceFootage" => ("path", Ask::Import),
|
||||
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
|
||||
"essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")),
|
||||
"essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])),
|
||||
"file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])),
|
||||
_ => return None,
|
||||
};
|
||||
if params.get(key).is_some()
|
||||
@@ -810,7 +813,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
|
||||
let paths = f(&[
|
||||
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac",
|
||||
"ogg", "opus", "svg", "gltf", "glb", "obj",
|
||||
"ogg", "opus", "svg", "psd", "psb", "gltf", "glb", "obj", "json", "csv", "tsv",
|
||||
]);
|
||||
match (paths.is_empty(), key) {
|
||||
(true, _) => None,
|
||||
@@ -833,6 +836,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||
};
|
||||
let Some(v) = picked else { return Some(Ok(Value::Null)) };
|
||||
p.insert(key.to_string(), v);
|
||||
// Photoshop files ask how to import them first.
|
||||
if id == "file.import" && crate::panels::dialogs::open_form(app, id, &Value::Object(p.clone())) {
|
||||
return Some(Ok(json!({"dialog": id})));
|
||||
}
|
||||
// Save (untitled) becomes Save As.
|
||||
let id = if id == "file.save" { "file.saveAs" } else { id };
|
||||
let r = app.session.execute(id, Value::Object(p)).map_err(|e| e.to_string());
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
//! 3D Camera Tracker in the Composition viewer and Effect Controls:
|
||||
//!
|
||||
//! - while the effect is selected, the solved 3D track points are drawn as coloured targets
|
||||
//! sized by depth (or the 2D source tracks with Show Track Points ▸ 2D Source);
|
||||
//! - hovering between three points shows the target plane they span (a disc in perspective);
|
||||
//! click a point (Shift adds / toggles) or drag a marquee to select points, click inside a
|
||||
//! triangle to select its three points; the selection's target is drawn;
|
||||
//! - right-click: Set Ground Plane and Origin, Create Text / Solid / Null / Shadow Catcher …
|
||||
//! and Camera, Create Multiple …, Delete Selected Points; Delete removes the selection;
|
||||
//! - the "Analyzing in background (step 1 of 2)" / "Solving camera" banner;
|
||||
//! - the Effect Controls block with Analyze / Cancel / Create Camera, Method Used and Average
|
||||
//! Error.
|
||||
//!
|
||||
//! Everything is dispatched to the `camera.*` commands.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::effects::camera_tracker as ct;
|
||||
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
|
||||
use effectcraft_engine::keyframe::Value;
|
||||
use effectcraft_engine::project::{Comp, Layer, LayerId, PropGroup, Uid};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::track::camtrack::{CameraSolve, CameraTracks, Target, point_color};
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use super::viewer::ViewerMap;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
/// The tracker whose points the viewer shows.
|
||||
struct Active {
|
||||
layer: LayerId,
|
||||
uid: Uid,
|
||||
solve: Option<Arc<CameraSolve>>,
|
||||
tracks: Option<Arc<CameraTracks>>,
|
||||
show_3d: bool,
|
||||
point_size: f64,
|
||||
target_size: f64,
|
||||
/// Layer pixels → comp pixels.
|
||||
l2c: Mat3,
|
||||
frame: usize,
|
||||
}
|
||||
|
||||
/// A drawn point: (id, screen position, screen radius).
|
||||
type Hit = (u32, Pos2, f32);
|
||||
|
||||
fn tracker_group(l: &Layer, uid: Uid) -> Option<&PropGroup> {
|
||||
l.props.find_group(uid).filter(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == ct::ID))
|
||||
}
|
||||
|
||||
/// The selected 3D Camera Tracker effect on an active layer (After Effects shows the points
|
||||
/// while the effect is selected).
|
||||
fn active(s: &Session, comp: &Comp, ectx: &EvalCtx, l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3) -> Option<Active> {
|
||||
let time = ectx.time;
|
||||
for (lid, uid) in &s.state.selected_props {
|
||||
let Some(l) = comp.layer(*lid).filter(|l| l.is_active_at(time)) else { continue };
|
||||
let Some(g) = tracker_group(l, *uid) else { continue };
|
||||
let params = effectcraft_engine::camera_track::static_params(g);
|
||||
let val = |id: &str| effectcraft_engine::effects::warp_stab::param(¶ms, id).cloned();
|
||||
let solve = ct::solve(¶ms);
|
||||
let tracks = ct::tracks(¶ms);
|
||||
let lt = l.layer_time(time).seconds();
|
||||
let frame = solve.as_ref().and_then(|sv| sv.frame_at(lt)).or_else(|| {
|
||||
let t = tracks.as_ref()?;
|
||||
(t.frame_duration > 0.0).then(|| ((lt - t.start) / t.frame_duration).round().clamp(0.0, t.frames.saturating_sub(1) as f64) as usize)
|
||||
});
|
||||
return Some(Active {
|
||||
layer: *lid,
|
||||
uid: *uid,
|
||||
solve,
|
||||
tracks,
|
||||
show_3d: val("showTrackPoints").map(|v| v.as_enum()).unwrap_or(1) == 1,
|
||||
point_size: val("trackPointSize").map(|v| v.as_f64()).unwrap_or(100.0),
|
||||
target_size: val("targetSize").map(|v| v.as_f64()).unwrap_or(100.0),
|
||||
l2c: l2c(ectx, l),
|
||||
frame: frame.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn to_screen(a: &Active, map: &ViewerMap, q: [f64; 2]) -> Pos2 {
|
||||
let c = a.l2c.apply(gv2(q[0], q[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
}
|
||||
|
||||
fn col(id: u32) -> Color32 {
|
||||
let c = point_color(id);
|
||||
Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)
|
||||
}
|
||||
|
||||
/// Comp-pixel scale of the layer → comp mapping.
|
||||
fn l2c_scale(m: &Mat3) -> f32 {
|
||||
m.0[0][0].hypot(m.0[1][0]) as f32
|
||||
}
|
||||
|
||||
/// Draw the target disc (concentric rings in perspective) of `t` on frame `k`.
|
||||
fn draw_target(p: &egui::Painter, a: &Active, map: &ViewerMap, sv: &CameraSolve, t: &Target) {
|
||||
let Some(cam) = sv.frames.get(a.frame) else { return };
|
||||
let r = t.size * 0.5 * a.target_size / 100.0;
|
||||
for (i, frac) in [1.0, 0.66, 0.33].into_iter().enumerate() {
|
||||
let ring = ct::target_circle(t.center, t.normal, r * frac, 48);
|
||||
let pts: Vec<Pos2> = ring.iter().filter_map(|x| cam.project(sv.size, *x)).map(|q| to_screen(a, map, q)).collect();
|
||||
if pts.len() < 3 {
|
||||
return;
|
||||
}
|
||||
let fill = if i % 2 == 0 { Color32::from_rgba_unmultiplied(0xd0, 0x30, 0x30, 70) } else { Color32::from_rgba_unmultiplied(0xff, 0xff, 0xff, 50) };
|
||||
p.add(egui::Shape::convex_polygon(pts.clone(), fill, Stroke::new(1.0, Color32::from_rgb(0xe0, 0x40, 0x40))));
|
||||
}
|
||||
if let Some(c) = cam.project(sv.size, t.center) {
|
||||
let c = to_screen(a, map, c);
|
||||
p.circle_filled(c, 3.0, Color32::from_rgb(0xff, 0xd0, 0x40));
|
||||
}
|
||||
}
|
||||
|
||||
/// The three drawn points forming a triangle around `hp` (nearest first).
|
||||
fn triangle_at(hits: &[Hit], hp: Pos2) -> Option<[u32; 3]> {
|
||||
let mut near: Vec<&Hit> = hits.iter().collect();
|
||||
near.sort_by(|x, y| x.1.distance_sq(hp).total_cmp(&y.1.distance_sq(hp)));
|
||||
let near: Vec<&Hit> = near.into_iter().take(8).collect();
|
||||
let inside = |a: Pos2, b: Pos2, c: Pos2| {
|
||||
let s = |p: Pos2, q: Pos2| (q.x - p.x) * (hp.y - p.y) - (q.y - p.y) * (hp.x - p.x);
|
||||
let (d1, d2, d3) = (s(a, b), s(b, c), s(c, a));
|
||||
!((d1 < 0.0 || d2 < 0.0 || d3 < 0.0) && (d1 > 0.0 || d2 > 0.0 || d3 > 0.0))
|
||||
};
|
||||
for i in 0..near.len() {
|
||||
for j in i + 1..near.len() {
|
||||
for k in j + 1..near.len() {
|
||||
let (a, b, c) = (near[i], near[j], near[k]);
|
||||
// Skip slivers.
|
||||
let area = ((b.1 - a.1).x * (c.1 - a.1).y - (b.1 - a.1).y * (c.1 - a.1).x).abs();
|
||||
if area > 30.0 && inside(a.1, b.1, c.1) {
|
||||
return Some([a.0, b.0, c.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn run(app: &mut EffectcraftApp, ctx: &egui::Context, cmd: &str, p: serde_json::Value) {
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, cmd, p) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewer overlay and its interaction (call after the viewer's own gestures so it takes
|
||||
/// the clicks while a 3D Camera Tracker is selected).
|
||||
pub fn viewer_hook(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
map: &ViewerMap,
|
||||
ectx: &EvalCtx,
|
||||
l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3,
|
||||
) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let comp = ectx.comp;
|
||||
banner(app, painter, map, ectx, l2c);
|
||||
if !app.ui.viewer.show_layer_controls {
|
||||
return;
|
||||
}
|
||||
let Some(a) = active(&app.session, comp, ectx, l2c) else { return };
|
||||
let selected = app.session.state.camera_points.clone();
|
||||
let lscale = l2c_scale(&a.l2c) * map.zoom;
|
||||
let mut hits: Vec<Hit> = vec![];
|
||||
match (&a.solve, a.show_3d) {
|
||||
(Some(sv), true) => {
|
||||
let pts = ct::projected(sv, a.frame);
|
||||
let med = ct::median_depth(&pts);
|
||||
for (id, q, z) in pts {
|
||||
let sp = to_screen(&a, map, q);
|
||||
if !map.area.expand(20.0).contains(sp) {
|
||||
continue;
|
||||
}
|
||||
let r = (ct::target_radius(a.point_size, z, med) as f32 * lscale).clamp(2.0, 30.0);
|
||||
let c = col(id);
|
||||
let sel = selected.contains(&id);
|
||||
if sel {
|
||||
painter.circle_filled(sp, r, c);
|
||||
painter.circle_stroke(sp, r + 1.5, Stroke::new(1.5, Color32::WHITE));
|
||||
} else {
|
||||
painter.circle_stroke(sp, r, Stroke::new((r * 0.3).clamp(1.0, 3.0), c));
|
||||
painter.circle_filled(sp, (r * 0.25).max(1.0), c);
|
||||
}
|
||||
hits.push((id, sp, r));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 2D source tracks (also shown before the solve finishes).
|
||||
if let Some(tr) = &a.tracks {
|
||||
for t in &tr.tracks {
|
||||
let Some(q) = t.at(a.frame as u32) else { continue };
|
||||
let sp = to_screen(&a, map, q);
|
||||
let c = col(t.id);
|
||||
let r = (3.0 * a.point_size as f32 / 100.0).clamp(1.5, 12.0);
|
||||
painter.line_segment([sp - vec2(r, r), sp + vec2(r, r)], Stroke::new(1.2, c));
|
||||
painter.line_segment([sp - vec2(r, -r), sp + vec2(r, -r)], Stroke::new(1.2, c));
|
||||
hits.push((t.id, sp, r));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(sv) = a.solve.clone() else { return };
|
||||
if app.ui.tool != crate::state::Tool::Selection {
|
||||
return;
|
||||
}
|
||||
// Interaction over the image.
|
||||
let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), Sense::click_and_drag());
|
||||
app.auto.add("viewer.cameraTracker", map.area, "3D Camera Tracker points");
|
||||
for (id, sp, r) in hits.iter().take(400) {
|
||||
app.auto.add(&format!("viewer.cameraTracker.point.{id}"), Rect::from_center_size(*sp, vec2(r * 2.0 + 4.0, r * 2.0 + 4.0)), "track point");
|
||||
}
|
||||
let hover = resp.hover_pos();
|
||||
let point_at = |p: Pos2| hits.iter().filter(|h| h.1.distance(p) <= h.2 + 3.0).min_by(|x, y| x.1.distance(p).total_cmp(&y.1.distance(p))).map(|h| h.0);
|
||||
let tri = hover.filter(|p| point_at(*p).is_none()).and_then(|p| triangle_at(&hits, p));
|
||||
let mid = egui::Id::new("viewer-camera-tracker-marquee");
|
||||
let marquee: Option<Pos2> = ui.data(|d| d.get_temp(mid));
|
||||
// Hover target / the selection's target.
|
||||
if marquee.is_none() {
|
||||
if let Some(t3) = tri
|
||||
&& let Some(t) = effectcraft_engine::camera_track::target_for(&sv, &t3, a.frame)
|
||||
{
|
||||
let ps: Vec<Pos2> = t3.iter().filter_map(|i| hits.iter().find(|h| h.0 == *i)).map(|h| h.1).collect();
|
||||
if ps.len() == 3 {
|
||||
painter.add(egui::Shape::closed_line(ps, Stroke::new(1.0, Color32::from_white_alpha(200))));
|
||||
}
|
||||
draw_target(painter, &a, map, &sv, &t);
|
||||
} else if !selected.is_empty()
|
||||
&& let Some(t) = effectcraft_engine::camera_track::target_for(&sv, &selected, a.frame)
|
||||
{
|
||||
draw_target(painter, &a, map, &sv, &t);
|
||||
}
|
||||
}
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
if resp.drag_started()
|
||||
&& let Some(p) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
ui.data_mut(|d| d.insert_temp(mid, p));
|
||||
}
|
||||
if let (Some(start), Some(p)) = (marquee, resp.interact_pointer_pos()) {
|
||||
let r = Rect::from_two_pos(start, p);
|
||||
let t = app.tokens;
|
||||
painter.rect_filled(r, 0.0, t.accent.gamma_multiply(0.12));
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Middle);
|
||||
if resp.drag_stopped() {
|
||||
let ids: Vec<u32> = hits.iter().filter(|h| r.contains(h.1)).map(|h| h.0).collect();
|
||||
ui.data_mut(|d| d.remove::<Pos2>(mid));
|
||||
run(app, &ctx, "camera.selectPoints", json!({"points": ids, "add": mods.shift}));
|
||||
}
|
||||
} else if resp.drag_stopped() {
|
||||
ui.data_mut(|d| d.remove::<Pos2>(mid));
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
match (point_at(p), triangle_at(&hits, p)) {
|
||||
(Some(id), _) => run(app, &ctx, "camera.selectPoints", json!({"points": [id], "toggle": mods.shift})),
|
||||
(None, Some(t3)) => run(app, &ctx, "camera.selectPoints", json!({"points": t3, "add": mods.shift})),
|
||||
(None, None) => run(app, &ctx, "camera.selectPoints", json!({"points": []})),
|
||||
}
|
||||
}
|
||||
// Right-click: the points under the cursor (selection, else the hovered triangle).
|
||||
let cid = egui::Id::new("viewer-camera-tracker-menu-points");
|
||||
if resp.secondary_clicked() {
|
||||
let pts: Vec<u32> = if !selected.is_empty() {
|
||||
selected.clone()
|
||||
} else if let Some(p) = resp.interact_pointer_pos() {
|
||||
point_at(p).map(|i| vec![i]).or_else(|| triangle_at(&hits, p).map(|t| t.to_vec())).unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
ui.data_mut(|d| d.insert_temp(cid, pts));
|
||||
}
|
||||
let base = json!({"layer": a.layer.0, "effect": a.uid});
|
||||
resp.context_menu(|ui| {
|
||||
let pts: Vec<u32> = ui.data(|d| d.get_temp(cid)).unwrap_or_default();
|
||||
let with = |kind: Option<&str>, multiple: bool| {
|
||||
let mut q = base.clone();
|
||||
q["points"] = json!(pts);
|
||||
if let Some(k) = kind {
|
||||
q["kind"] = json!(k);
|
||||
}
|
||||
if multiple {
|
||||
q["multiple"] = json!(true);
|
||||
}
|
||||
q
|
||||
};
|
||||
let some = !pts.is_empty();
|
||||
let many = pts.len() > 1;
|
||||
let mut pick: Option<(&str, serde_json::Value)> = None;
|
||||
if ui.add_enabled(some, egui::Button::new("Set Ground Plane and Origin")).clicked() {
|
||||
pick = Some(("camera.setGroundPlane", with(None, false)));
|
||||
}
|
||||
ui.separator();
|
||||
for (label, kind) in [
|
||||
("Create Text and Camera", "text"),
|
||||
("Create Solid and Camera", "solid"),
|
||||
("Create Null and Camera", "null"),
|
||||
("Create Shadow Catcher, Camera and Light", "shadowCatcher"),
|
||||
] {
|
||||
if ui.add_enabled(some, egui::Button::new(label)).clicked() {
|
||||
pick = Some(("camera.createFromSolve", with(Some(kind), false)));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
for (label, kind) in
|
||||
[("Create Multiple Text Layers and Camera", "text"), ("Create Multiple Solids and Camera", "solid"), ("Create Multiple Nulls and Camera", "null")]
|
||||
{
|
||||
if ui.add_enabled(many, egui::Button::new(label)).clicked() {
|
||||
pick = Some(("camera.createFromSolve", with(Some(kind), true)));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.add_enabled(some, egui::Button::new("Delete Selected Points")).clicked() {
|
||||
pick = Some(("camera.deletePoints", with(None, false)));
|
||||
}
|
||||
if let Some((cmd, q)) = pick {
|
||||
ui.close();
|
||||
run(app, &ctx, cmd, q);
|
||||
}
|
||||
});
|
||||
if resp.hovered()
|
||||
&& !selected.is_empty()
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
|
||||
&& !ctx.egui_wants_keyboard_input()
|
||||
{
|
||||
run(app, &ctx, "camera.deletePoints", json!({"layer": a.layer.0, "effect": a.uid, "points": selected}));
|
||||
}
|
||||
}
|
||||
|
||||
/// "Analyzing in background (step 1 of 2)" / "Solving camera" over the analysed layer, and a
|
||||
/// warning when the solve failed (unless Hide Warning Banner).
|
||||
fn banner(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3) {
|
||||
let comp = ectx.comp;
|
||||
let time = ectx.time;
|
||||
let cid = ectx.comp_id;
|
||||
let draw = |app: &mut EffectcraftApp, layer: &Layer, text: String, color: Color32, id: &str| {
|
||||
let m = l2c(ectx, layer);
|
||||
let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
|
||||
let c = m.apply(gv2(w as f64 / 2.0, h as f64 / 2.0));
|
||||
let at = map.to_screen([c.x, c.y]);
|
||||
let galley = painter.layout_no_wrap(text.clone(), Tokens::medium(13.0), Color32::WHITE);
|
||||
let r = Rect::from_center_size(at, galley.size() + vec2(28.0, 14.0)).intersect(map.area);
|
||||
painter.rect_filled(r, 3.0, color);
|
||||
painter.galley(pos2(r.center().x - galley.size().x / 2.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
|
||||
app.auto.add(id, r, &text);
|
||||
};
|
||||
if let Some((cc, cl, _)) = app.session.camera_target()
|
||||
&& cc == cid
|
||||
&& let Some(pr) = app.session.camera_progress()
|
||||
&& let Some(layer) = comp.layer(cl)
|
||||
&& layer.is_active_at(time)
|
||||
{
|
||||
draw(app, layer, pr.banner(), Color32::from_rgba_unmultiplied(0x1d, 0x4f, 0x9c, 0xe0), "viewer.cameraBanner");
|
||||
return;
|
||||
}
|
||||
// A failed solve: tracks without a solve while nothing is running or queued.
|
||||
for l in comp.layers.iter().filter(|l| l.is_active_at(time)) {
|
||||
let Some(fx) = l.effects() else { continue };
|
||||
for g in fx.groups().filter(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == ct::ID)) {
|
||||
let params = effectcraft_engine::camera_track::static_params(g);
|
||||
let hide = effectcraft_engine::effects::warp_stab::param(¶ms, "advanced/hideWarningBanner").is_some_and(Value::as_bool);
|
||||
let solve_key = matches!(g.prop(ct::SOLVE_KEY).map(|p| &p.value), Some(Value::Str(s)) if !s.is_empty());
|
||||
let failed = solve_key && ct::tracks(¶ms).is_some() && ct::solve(¶ms).is_none();
|
||||
if failed && !hide && !app.session.camera_pending.iter().any(|(_, ll, u)| *ll == l.id && *u == g.uid) {
|
||||
draw(app, l, "Unable to solve camera".into(), Color32::from_rgba_unmultiplied(0x9c, 0x2a, 0x1d, 0xe0), "viewer.cameraWarning");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effect Controls: Analyze / Cancel, Create Camera and the solve report.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn editor(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
layer: &Layer,
|
||||
g: &PropGroup,
|
||||
r: Rect,
|
||||
actions: &mut Vec<(String, serde_json::Value)>,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let running = app.session.camera_target().is_some_and(|(_, l, u)| l == layer.id && u == g.uid) && app.session.is_camera_analyzing();
|
||||
let busy = app.session.is_camera_analyzing();
|
||||
let me = json!({"layer": layer.id.0, "effect": g.uid});
|
||||
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
|
||||
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
|
||||
let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0));
|
||||
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::new(("ct-analyze", g.uid))).clicked() && !busy {
|
||||
actions.push(("camera.analyze".into(), me.clone()));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze");
|
||||
if running {
|
||||
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::new(("ct-cancel", g.uid))).clicked() {
|
||||
actions.push(("camera.cancel".into(), json!({})));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel");
|
||||
}
|
||||
let params = effectcraft_engine::camera_track::static_params(g);
|
||||
let solve = ct::solve(¶ms);
|
||||
if solve.is_some() {
|
||||
if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::new(("ct-camera", g.uid))).clicked() {
|
||||
actions.push(("camera.create".into(), me.clone()));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera");
|
||||
}
|
||||
let status = match app.session.camera_progress() {
|
||||
Some(pr) if running => pr.banner(),
|
||||
_ => match &solve {
|
||||
Some(sv) => format!("Method Used: {} Average Error: {:.2} pixels", sv.method_used.label(), sv.average_error),
|
||||
None if ct::tracks(¶ms).is_some() && app.session.camera_pending.iter().any(|(_, l, u)| *l == layer.id && *u == g.uid) => {
|
||||
"Waiting to solve".into()
|
||||
}
|
||||
None if ct::tracks(¶ms).is_some() => "Unable to solve camera: try another Solve Method or Shot Type".into(),
|
||||
None if app.session.camera_pending.iter().any(|(_, l, u)| *l == layer.id && *u == g.uid) => "Waiting to analyze".into(),
|
||||
None => "Not analyzed: click Analyze".into(),
|
||||
},
|
||||
};
|
||||
p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim);
|
||||
if let Some(sv) = &solve {
|
||||
let f = sv.focal();
|
||||
p.text(
|
||||
pos2(r.min.x + 24.0, r.min.y + 56.0),
|
||||
Align2::LEFT_CENTER,
|
||||
format!("{} solved points · Horizontal Angle of View {:.1}°", sv.points.len(), sv.hfov(f)),
|
||||
Tokens::ui(11.5),
|
||||
t.text_faint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of [`editor`].
|
||||
pub const EDITOR_HEIGHT: f32 = 68.0;
|
||||
@@ -574,6 +574,7 @@ fn editor_height(effect: &str, width: f32) -> f32 {
|
||||
match effect {
|
||||
"ec.color.curves" => 34.0 + curves_size(width) + 26.0,
|
||||
effectcraft_engine::effects::warp_stab::ID => 50.0,
|
||||
effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::EDITOR_HEIGHT,
|
||||
effectcraft_engine::effects::roto::ID => 50.0,
|
||||
"ec.color.levels" | "ec.color.levelsic" => 34.0 + 80.0 + 58.0,
|
||||
"ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" => 24.0 + 80.0 + 12.0,
|
||||
@@ -823,7 +824,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let snap_project = app.session.project.clone();
|
||||
let snap_expr = app.session.expr.clone();
|
||||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref() };
|
||||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref(), footage: None };
|
||||
let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
|
||||
p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim);
|
||||
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
@@ -950,6 +951,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
match effect.as_str() {
|
||||
"ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions),
|
||||
effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, &bp, &layer, g, er, &mut actions),
|
||||
effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::editor(app, ui, &bp, &layer, g, er, &mut actions),
|
||||
effectcraft_engine::effects::roto::ID => roto_editor(app, ui, &bp, &layer, g, er, &mut actions),
|
||||
"ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions),
|
||||
_ => histogram_only(app, ui, &bp, g, er),
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
//! Essential Graphics panel (Window ▸ Essential Graphics).
|
||||
//!
|
||||
//! - **Primary** composition, **Solo Supported Properties** (the timeline lists only what can
|
||||
//! be exposed) and the template name.
|
||||
//! - The controls: drag a property name from the timeline onto the list (or Animation ▸ Add
|
||||
//! Property to Essential Graphics); each shows as a text / colour / slider / checkbox / point /
|
||||
//! angle / dropdown control editing the source property; rename by double-clicking the name;
|
||||
//! reorder with ▲▼, remove with ✕; groups and comments from Add Formatting; Media
|
||||
//! Replacement for the selected footage layer.
|
||||
//! - **Export Template…** writes an open `.ectemplate`.
|
||||
//! - With a precomp layer that has Essential Properties selected, its instance values: edit them
|
||||
//! (overrides), Revert or Push to Comp.
|
||||
//!
|
||||
//! Every control runs an engine `essential.*` / `prop.set` command and registers an automation
|
||||
//! id (`essential.primary`, `essential.solo`, `essential.name`, `essential.control.<id>.value`,
|
||||
//! `essential.instance.<id>.value`, `essential.export`…).
|
||||
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
use effectcraft_engine::project::essential::{self, ControlType, EgControl, EgKind};
|
||||
use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, ParamUi, Property};
|
||||
use egui::{Rect, RichText};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::EffectcraftApp;
|
||||
use crate::panels::DragPayload;
|
||||
|
||||
type Actions = Vec<(String, Value)>;
|
||||
|
||||
/// The comp the panel edits: the Primary comp, else the active one.
|
||||
fn primary(app: &EffectcraftApp) -> Option<ItemId> {
|
||||
let s = &app.session;
|
||||
s.state.essential_primary.filter(|c| s.project.comp(*c).is_some()).or(s.active_comp_id())
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(8.0)).layout(egui::Layout::top_down(egui::Align::Min)));
|
||||
let mut out = None;
|
||||
egui::ScrollArea::vertical().id_salt("eg-panel").auto_shrink([false, false]).show(&mut child, |ui| out = Some(body(app, ui)));
|
||||
let Some((actions, invoke)) = out else { return };
|
||||
for (id, p) in actions {
|
||||
if let Err(e) = app.session.execute(&id, p) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
for (id, p) in invoke {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, &id, p) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut actions: Actions = vec![];
|
||||
let mut invoke: Vec<(String, Value)> = vec![];
|
||||
let Some(cid) = primary(app) else {
|
||||
ui.label(RichText::new("Open a composition to build Essential Graphics").color(t.text_faint));
|
||||
return (actions, invoke);
|
||||
};
|
||||
let project = app.session.project.clone();
|
||||
let comp_name = project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||||
|
||||
// ---- Primary + Solo.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Primary:").color(t.text_dim));
|
||||
let r = egui::ComboBox::from_id_salt("eg-primary").selected_text(&comp_name).width(150.0).show_ui(ui, |ui| {
|
||||
for (id, _) in project.comps() {
|
||||
let name = project.item(*id).map(|i| i.name.clone()).unwrap_or_default();
|
||||
if ui.selectable_label(*id == cid, name).clicked() {
|
||||
actions.push(("essential.setPrimary".into(), json!({"comp": id.0})));
|
||||
}
|
||||
}
|
||||
});
|
||||
app.auto.add("essential.primary", r.response.rect, &comp_name);
|
||||
});
|
||||
let solo = app.session.state.essential_solo;
|
||||
let r = ui.add(egui::Button::new("Solo Supported Properties").selected(solo));
|
||||
app.auto.add("essential.solo", r.rect, "Solo Supported Properties");
|
||||
if r.clicked() {
|
||||
actions.push(("essential.soloSupported".into(), json!({"on": !solo})));
|
||||
}
|
||||
// ---- Name.
|
||||
let eg = project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default();
|
||||
let shown_name = if eg.name.is_empty() { comp_name.clone() } else { eg.name.clone() };
|
||||
let name_id = egui::Id::new("eg-name-buf");
|
||||
let mut name: String = ctx.data(|d| d.get_temp(name_id)).unwrap_or_else(|| shown_name.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text("Template name").desired_width(f32::INFINITY));
|
||||
app.auto.add("essential.name", r.rect, &shown_name);
|
||||
if r.has_focus() {
|
||||
ctx.data_mut(|d| d.insert_temp(name_id, name.clone()));
|
||||
} else {
|
||||
if r.lost_focus() && name.trim() != shown_name {
|
||||
actions.push(("essential.setName".into(), json!({"comp": cid.0, "name": name.trim()})));
|
||||
}
|
||||
ctx.data_mut(|d| d.remove::<String>(name_id));
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
// ---- Controls (drop target for timeline properties).
|
||||
let list = ui.scope(|ui| {
|
||||
ui.set_min_height(60.0);
|
||||
if eg.controls.is_empty() {
|
||||
ui.label(RichText::new("Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.").color(t.text_faint));
|
||||
}
|
||||
controls(app, ui, cid, &eg.controls, None, &mut actions);
|
||||
});
|
||||
let drop_rect = list.response.rect.expand2(egui::vec2(0.0, 4.0));
|
||||
app.auto.add("essential.dropZone", drop_rect, "Drag properties here");
|
||||
if let Some(payload) = egui::DragAndDrop::payload::<DragPayload>(&ctx)
|
||||
&& let DragPayload::Property { layer, prop } = *payload
|
||||
&& ui.rect_contains_pointer(drop_rect)
|
||||
{
|
||||
ui.painter().rect_stroke(drop_rect, 2.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
let comp = app.session.active_comp_id().map(|c| c.0);
|
||||
actions.push(("essential.addProperty".into(), json!({"comp": comp, "layer": layer, "prop": prop})));
|
||||
egui::DragAndDrop::clear_payload(&ctx);
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
// ---- Bottom buttons.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let r = ui.menu_button("Add Formatting", |ui| {
|
||||
if ui.button("Add Group").clicked() {
|
||||
actions.push(("essential.addGroup".into(), json!({"comp": cid.0})));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Add Comment").clicked() {
|
||||
actions.push(("essential.addComment".into(), json!({"comp": cid.0})));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
app.auto.add("essential.addFormatting", r.response.rect, "Add Formatting");
|
||||
let footage_layer = app.session.active_comp().and_then(|c| {
|
||||
app.session.state.selected_layers.iter().filter_map(|l| c.layer(*l)).find(|l| matches!(l.source, LayerSource::Footage { .. })).map(|l| l.id.0)
|
||||
});
|
||||
let r = ui.add_enabled(footage_layer.is_some(), egui::Button::new("Media Replacement"));
|
||||
app.auto.add("essential.addMedia", r.rect, "Add Media Replacement");
|
||||
if r.clicked()
|
||||
&& let Some(l) = footage_layer
|
||||
{
|
||||
actions.push(("essential.addMedia".into(), json!({"layer": l})));
|
||||
}
|
||||
let r = ui.add_enabled(!eg.controls.is_empty(), egui::Button::new("Export Template…"));
|
||||
app.auto.add("essential.export", r.rect, "Export Template");
|
||||
if r.clicked() {
|
||||
invoke.push(("essential.exportTemplate".into(), json!({"comp": cid.0})));
|
||||
}
|
||||
});
|
||||
|
||||
// ---- The selected instance's Essential Properties.
|
||||
instance(app, ui, &mut actions);
|
||||
(actions, invoke)
|
||||
}
|
||||
|
||||
/// One list of controls (top level or a group's children).
|
||||
fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[EgControl], group: Option<u64>, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
let n = list.len();
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
let row = ui.horizontal(|ui| {
|
||||
// Reorder / remove.
|
||||
for (label, auto, enabled, to) in [("▲", "up", i > 0, i.saturating_sub(1)), ("▼", "down", i + 1 < n, i + 1)] {
|
||||
let r = ui.add_enabled(enabled, egui::Button::new(RichText::new(label).size(9.0)).small().frame(false));
|
||||
app.auto.add(&format!("essential.control.{}.{auto}", c.id), r.rect, label);
|
||||
if r.clicked() {
|
||||
actions.push(("essential.move".into(), json!({"comp": cid.0, "control": c.id, "group": group, "index": to})));
|
||||
}
|
||||
}
|
||||
match &c.kind {
|
||||
EgKind::Group { .. } => {
|
||||
name_label(app, ui, cid, c, RichText::new(&c.name).strong(), actions);
|
||||
}
|
||||
EgKind::Comment { text } => {
|
||||
let shown = EgControl { name: text.clone(), ..c.clone() };
|
||||
name_label(app, ui, cid, &shown, RichText::new(text).italics().color(t.text_dim), actions);
|
||||
}
|
||||
EgKind::Property { .. } => {
|
||||
name_label(app, ui, cid, c, RichText::new(&c.name), actions);
|
||||
let src = essential::source_prop(&app.session.project, cid, c).map(|(l, p)| (l.id.0, l.layer_time(app.session.time()), p.clone()));
|
||||
match src {
|
||||
Some((layer, lt, p)) => {
|
||||
let v = p.value_at(lt);
|
||||
if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), &p, &v) {
|
||||
actions.push((
|
||||
"prop.set".into(),
|
||||
json!({"comp": cid.0, "layer": layer, "prop": p.uid, "value": nv, "merge": format!("eg-{}", c.id)}),
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label(RichText::new("(missing)").color(t.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
EgKind::Media { .. } => {
|
||||
name_label(app, ui, cid, c, RichText::new(&c.name), actions);
|
||||
let item = essential::source_media(&app.session.project, cid, c);
|
||||
let name = item.and_then(|i| app.session.project.item(i)).map(|i| i.name.clone()).unwrap_or_default();
|
||||
ui.label(RichText::new(format!("▣ {name}")).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
let r = ui.add(egui::Button::new(RichText::new("×").size(10.0)).small().frame(false)).on_hover_text("Remove");
|
||||
app.auto.add(&format!("essential.control.{}.remove", c.id), r.rect, "Remove");
|
||||
if r.clicked() {
|
||||
actions.push(("essential.remove".into(), json!({"comp": cid.0, "control": c.id})));
|
||||
}
|
||||
});
|
||||
app.auto.add(&format!("essential.control.{}.row", c.id), row.response.rect, &c.name);
|
||||
if let EgKind::Group { children } = &c.kind {
|
||||
ui.indent(("eg-group", c.id), |ui| controls(app, ui, cid, children, Some(c.id), actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A control's name; double-click to rename (comments: edit the text).
|
||||
fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgControl, text: RichText, actions: &mut Actions) {
|
||||
let edit_id = egui::Id::new(("eg-rename", c.id));
|
||||
let editing: Option<String> = ui.ctx().data(|d| d.get_temp(edit_id));
|
||||
match editing {
|
||||
Some(mut buf) => {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(120.0));
|
||||
app.auto.add(&format!("essential.control.{}.rename", c.id), r.rect, &c.name);
|
||||
r.request_focus();
|
||||
if r.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
if !buf.trim().is_empty() && buf.trim() != c.name {
|
||||
actions.push(("essential.rename".into(), json!({"comp": cid.0, "control": c.id, "name": buf.trim()})));
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.remove::<String>(edit_id));
|
||||
} else {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(edit_id, buf));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let r = ui.add(egui::Label::new(text).sense(egui::Sense::click()).truncate());
|
||||
app.auto.add(&format!("essential.control.{}.name", c.id), r.rect, &c.name);
|
||||
if r.double_clicked() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(edit_id, c.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The editor for a control's value; returns the new value (JSON for `prop.set`).
|
||||
fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Property, v: &KV) -> Option<Value> {
|
||||
let ty = essential::control_type(p)?;
|
||||
let mut out = None;
|
||||
let rect;
|
||||
match ty {
|
||||
ControlType::Text => {
|
||||
let cur = match v {
|
||||
KV::Text(d) => d.text.clone(),
|
||||
KV::Str(s) => s.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let id = egui::Id::new(("eg-text", auto));
|
||||
let mut buf: String = ui.ctx().data(|d| d.get_temp(id)).unwrap_or_else(|| cur.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0));
|
||||
if r.has_focus() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id, buf));
|
||||
} else {
|
||||
if r.lost_focus() && buf != cur {
|
||||
out = Some(json!(buf));
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.remove::<String>(id));
|
||||
}
|
||||
rect = r.rect;
|
||||
}
|
||||
ControlType::Color => {
|
||||
let c = v.as_color();
|
||||
let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]);
|
||||
let r = egui::widgets::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::widgets::color_picker::Alpha::Opaque);
|
||||
if r.changed() {
|
||||
let u = rgba.to_rgba_unmultiplied();
|
||||
out = Some(json!([u[0], u[1], u[2], u[3]]));
|
||||
}
|
||||
rect = r.rect;
|
||||
}
|
||||
ControlType::Checkbox => {
|
||||
let mut on = v.as_bool();
|
||||
let r = ui.checkbox(&mut on, "");
|
||||
if r.changed() {
|
||||
out = Some(json!(on));
|
||||
}
|
||||
rect = r.rect;
|
||||
}
|
||||
ControlType::Dropdown => {
|
||||
let opts = match &p.ui {
|
||||
ParamUi::Popup { options } => options.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
let cur = v.as_enum() as usize;
|
||||
let r = egui::ComboBox::from_id_salt(("eg-dd", auto)).selected_text(opts.get(cur).cloned().unwrap_or_default()).show_ui(ui, |ui| {
|
||||
for (i, o) in opts.iter().enumerate() {
|
||||
if ui.selectable_label(i == cur, o).clicked() {
|
||||
out = Some(json!(i));
|
||||
}
|
||||
}
|
||||
});
|
||||
rect = r.response.rect;
|
||||
}
|
||||
ControlType::Point => {
|
||||
let mut a = v.as_vec2();
|
||||
let r1 = ui.add(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1));
|
||||
let r2 = ui.add(egui::DragValue::new(&mut a[1]).speed(1.0).max_decimals(1));
|
||||
if r1.changed() || r2.changed() {
|
||||
out = Some(json!(a));
|
||||
}
|
||||
rect = r1.rect.union(r2.rect);
|
||||
}
|
||||
ControlType::Angle => {
|
||||
let mut a = v.as_f64();
|
||||
let r = ui.add(egui::DragValue::new(&mut a).speed(1.0).suffix("°").max_decimals(1));
|
||||
if r.changed() {
|
||||
out = Some(json!(a));
|
||||
}
|
||||
rect = r.rect;
|
||||
}
|
||||
ControlType::Slider | ControlType::Media => {
|
||||
let mut x = v.as_f64();
|
||||
let r = match p.ui {
|
||||
ParamUi::Slider { slider_min, slider_max, .. } => ui.add(egui::Slider::new(&mut x, slider_min..=slider_max).max_decimals(2)),
|
||||
_ => ui.add(egui::DragValue::new(&mut x).speed(0.5).max_decimals(2)),
|
||||
};
|
||||
if r.changed() {
|
||||
out = Some(json!(x));
|
||||
}
|
||||
rect = r.rect;
|
||||
}
|
||||
}
|
||||
app.auto.add(auto, rect, &p.name);
|
||||
out
|
||||
}
|
||||
|
||||
/// Essential Properties of the selected precomp layer: per-instance values, overrides marked ●,
|
||||
/// Revert and Push to Comp.
|
||||
fn instance(app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
let Some(comp) = app.session.active_comp().cloned() else { return };
|
||||
let Some(layer) = app.session.state.selected_layers.iter().filter_map(|l| comp.layer(*l)).find(|l| essential::group(l).is_some()).cloned() else { return };
|
||||
let LayerSource::Comp { item: src } = layer.source else { return };
|
||||
let Some(eg) = app.session.project.comp(src).and_then(|c| c.essential.clone()) else { return };
|
||||
let over = essential::overridden(&layer);
|
||||
let lt = layer.layer_time(app.session.time());
|
||||
ui.separator();
|
||||
ui.label(RichText::new(format!("Essential Properties — {}", layer.name)).strong());
|
||||
let group = essential::group(&layer).cloned().unwrap_or_else(|| effectcraft_engine::project::PropGroup::new(0, "", ""));
|
||||
for c in eg.flat() {
|
||||
let m = essential::match_id(c.id);
|
||||
let mut pr = None;
|
||||
group.walk("", &mut |_, x| {
|
||||
if x.match_id == m {
|
||||
pr = Some(x.clone());
|
||||
}
|
||||
});
|
||||
let Some(p) = pr else { continue };
|
||||
let is_over = over.contains(&p.uid);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(if is_over { "●" } else { "○" }).color(if is_over { t.accent } else { t.text_faint })).on_hover_text(if is_over {
|
||||
"Overridden in this instance"
|
||||
} else {
|
||||
"Follows the source composition"
|
||||
});
|
||||
ui.label(&c.name);
|
||||
let auto = format!("essential.instance.{}.value", c.id);
|
||||
match c.kind {
|
||||
EgKind::Media { .. } => {
|
||||
let cur = ItemId(p.value.as_f64() as u64);
|
||||
let cur_name = app.session.project.item(cur).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let items: Vec<(ItemId, String)> = app
|
||||
.session
|
||||
.project
|
||||
.items
|
||||
.values()
|
||||
.filter(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_video))
|
||||
.map(|i| (i.id, i.name.clone()))
|
||||
.collect();
|
||||
let r = egui::ComboBox::from_id_salt(("eg-media", layer.id.0, c.id)).selected_text(cur_name).show_ui(ui, |ui| {
|
||||
for (id, name) in &items {
|
||||
if ui.selectable_label(*id == cur, name).clicked() {
|
||||
actions.push(("essential.set".into(), json!({"layer": layer.id.0, "control": c.id, "item": id.0})));
|
||||
}
|
||||
}
|
||||
});
|
||||
app.auto.add(&auto, r.response.rect, &c.name);
|
||||
}
|
||||
_ => {
|
||||
let v = p.value_at(lt);
|
||||
if let Some(nv) = value_widget(app, ui, &auto, &p, &v) {
|
||||
actions.push((
|
||||
"essential.set".into(),
|
||||
json!({"layer": layer.id.0, "control": c.id, "value": nv, "merge": format!("egi-{}-{}", layer.id.0, c.id)}),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_over {
|
||||
let r = ui.small_button("Revert");
|
||||
app.auto.add(&format!("essential.instance.{}.revert", c.id), r.rect, "Revert");
|
||||
if r.clicked() {
|
||||
actions.push(("essential.revert".into(), json!({"layer": layer.id.0, "control": c.id})));
|
||||
}
|
||||
let r = ui.small_button("Push");
|
||||
app.auto.add(&format!("essential.instance.{}.push", c.id), r.rect, "Push to Comp");
|
||||
if r.clicked() {
|
||||
actions.push(("essential.pushToComp".into(), json!({"layer": layer.id.0, "control": c.id})));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let any = !over.is_empty();
|
||||
let r = ui.add_enabled(any, egui::Button::new("Push All to Comp"));
|
||||
app.auto.add("essential.instance.pushAll", r.rect, "Push Override Values to Source");
|
||||
if r.clicked() {
|
||||
actions.push(("essential.pushToComp".into(), json!({"layer": layer.id.0})));
|
||||
}
|
||||
let r = ui.add_enabled(any, egui::Button::new("Revert All"));
|
||||
app.auto.add("essential.instance.revertAll", r.rect, "Revert All");
|
||||
if r.clicked() {
|
||||
actions.push(("essential.revert".into(), json!({"layer": layer.id.0})));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! The expression error bar: a strip at the bottom of the Composition viewer listing failing
|
||||
//! expressions of the comp at the current time (`expr.errors`), with ◀ ▶ to step through them.
|
||||
//! Clicking the message selects the layer and reveals the property in the timeline.
|
||||
//! Automation ids: `viewer.exprErrors`, `viewer.exprErrors.prev`, `viewer.exprErrors.next`.
|
||||
|
||||
use effectcraft_engine::commands::expr_tools::{self, ExprError};
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::EffectcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Height of the bar.
|
||||
pub const HEIGHT: f32 = 22.0;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Cache {
|
||||
key: (u64, u64, i64),
|
||||
errors: Vec<ExprError>,
|
||||
/// Shown error (index).
|
||||
current: usize,
|
||||
}
|
||||
|
||||
fn cache_id() -> egui::Id {
|
||||
egui::Id::new("expr-error-bar")
|
||||
}
|
||||
|
||||
/// The comp's failing expressions (recomputed when the project or the time changes; kept while
|
||||
/// playing).
|
||||
fn errors(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> Cache {
|
||||
let t = app.session.time_of(cid);
|
||||
let key = (app.session.revision, cid.0, t.0);
|
||||
let mut c: Cache = ctx.data(|d| d.get_temp(cache_id())).unwrap_or_default();
|
||||
let stale = c.key != key && !(app.playback.playing && c.key.1 == cid.0);
|
||||
if stale {
|
||||
c.errors = expr_tools::errors(&app.session, cid, t);
|
||||
c.key = key;
|
||||
c.current = c.current.min(c.errors.len().saturating_sub(1));
|
||||
ctx.data_mut(|d| d.insert_temp(cache_id(), c.clone()));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Height the bar needs for `cid` (0 when every expression evaluates).
|
||||
pub fn height(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> f32 {
|
||||
if app.session.expr.is_none() || errors(app, ctx, cid).errors.is_empty() { 0.0 } else { HEIGHT }
|
||||
}
|
||||
|
||||
pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut c = errors(app, &ctx, cid);
|
||||
if c.errors.is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
p.rect_filled(rect, 0.0, Color32::from_rgb(0x4a, 0x3a, 0x12));
|
||||
let warn = Color32::from_rgb(0xf5, 0xb0, 0x30);
|
||||
p.text(pos2(rect.min.x + 10.0, rect.center().y), Align2::LEFT_CENTER, "⚠", Tokens::ui(13.0), warn);
|
||||
let n = c.errors.len();
|
||||
let i = c.current.min(n - 1);
|
||||
// ◀ i/n ▶ on the right.
|
||||
let next = Rect::from_center_size(pos2(rect.max.x - 14.0, rect.center().y), vec2(16.0, 16.0));
|
||||
let count = Rect::from_center_size(pos2(rect.max.x - 44.0, rect.center().y), vec2(40.0, 16.0));
|
||||
let prev = Rect::from_center_size(pos2(rect.max.x - 74.0, rect.center().y), vec2(16.0, 16.0));
|
||||
for (r, label, auto, d) in [(prev, "◀", "viewer.exprErrors.prev", n - 1), (next, "▶", "viewer.exprErrors.next", 1)] {
|
||||
let resp = ui.interact(r, egui::Id::new(("expr-bar", auto)), Sense::click());
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if resp.hovered() { Color32::WHITE } else { t.text });
|
||||
app.auto.add(auto, r, label);
|
||||
if resp.clicked() {
|
||||
c.current = (i + d) % n;
|
||||
ctx.data_mut(|dd| dd.insert_temp(cache_id(), c.clone()));
|
||||
}
|
||||
}
|
||||
p.text(count.center(), Align2::CENTER_CENTER, format!("{}/{n}", i + 1), Tokens::ui(11.0), t.text);
|
||||
let comp_name = app.session.project.item(cid).map(|it| it.name.clone()).unwrap_or_default();
|
||||
let e = c.errors[i].clone();
|
||||
let msg_rect = Rect::from_min_max(pos2(rect.min.x + 26.0, rect.min.y), pos2(prev.min.x - 6.0, rect.max.y));
|
||||
let resp = ui.interact(msg_rect, egui::Id::new("expr-bar-msg"), Sense::click()).on_hover_text("Click to reveal the expression in the timeline");
|
||||
p.with_clip_rect(msg_rect).text(
|
||||
pos2(msg_rect.min.x, rect.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
e.describe(&comp_name),
|
||||
Tokens::ui(11.5),
|
||||
if resp.hovered() { Color32::WHITE } else { t.text },
|
||||
);
|
||||
app.auto.add("viewer.exprErrors", msg_rect, &e.describe(&comp_name));
|
||||
if resp.clicked() {
|
||||
let _ = app.session.execute("layer.select", json!({"comp": cid.0, "layers": [e.layer]}));
|
||||
let _ = app.session.execute("prop.select", json!({"comp": cid.0, "layer": e.layer, "prop": e.prop, "selectKeys": false}));
|
||||
let _ = crate::menus::frontend(app, &ctx, "timeline.revealProps", json!({"props": [{"layer": e.layer, "prop": e.prop}]}));
|
||||
app.ui.timeline.expr_closed.remove(&e.prop);
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,16 @@ fn has(p: &Value, keys: &[&str]) -> bool {
|
||||
}
|
||||
|
||||
/// If `id` is a dialog command invoked without its parameters, open its form and return true.
|
||||
/// The import includes a Photoshop document.
|
||||
fn is_layered(p: &Value) -> bool {
|
||||
let psd = |v: &Value| v.as_str().is_some_and(|s| matches!(s.rsplit('.').next().map(str::to_ascii_lowercase).as_deref(), Some("psd" | "psb")));
|
||||
match p.get("paths").or(p.get("path")) {
|
||||
Some(Value::Array(a)) => a.iter().any(psd),
|
||||
Some(v) => psd(v),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
let s = &app.session;
|
||||
let comp = s.active_comp();
|
||||
@@ -116,6 +126,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t);
|
||||
let base = p.clone();
|
||||
let (title, fields): (String, Vec<Field>) = match id {
|
||||
// Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes).
|
||||
"file.import" if !has(p, &["importAs"]) && is_layered(p) => (
|
||||
"Import Photoshop File".into(),
|
||||
vec![Field::choice(
|
||||
"importAs",
|
||||
"Import Kind",
|
||||
&[("Footage", json!("footage")), ("Composition", json!("composition")), ("Composition - Retain Layer Sizes", json!("compositionLayerSizes"))],
|
||||
1,
|
||||
)],
|
||||
),
|
||||
"layer.setTransform" if !has(p, &["value"]) => {
|
||||
let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
|
||||
let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
|
||||
@@ -218,27 +238,48 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
),
|
||||
],
|
||||
),
|
||||
"file.interpretFootage" if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile"]) => {
|
||||
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match &it.kind {
|
||||
effectcraft_engine::project::ItemKind::Footage(f) => Some(f.clone()),
|
||||
"file.interpretFootage" | "file.interpretProxy"
|
||||
if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight"]) =>
|
||||
{
|
||||
let proxy = id == "file.interpretProxy";
|
||||
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
|
||||
(_, Some(px)) if proxy => Some(px.footage.clone()),
|
||||
(effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let Some(f) = f else { return false };
|
||||
let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
|
||||
(
|
||||
"Interpret Footage".into(),
|
||||
if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() },
|
||||
vec![
|
||||
Field::num("frameRate", "Frame rate", f.frame_rate.as_f64()),
|
||||
// Main Options ▸ Alpha.
|
||||
Field::choice(
|
||||
"alpha",
|
||||
"Alpha",
|
||||
&[("Straight", json!("straight")), ("Premultiplied", json!("premultiplied")), ("Ignore", json!("ignore"))],
|
||||
&[
|
||||
("Interpret Straight - Unmatted", json!("straight")),
|
||||
("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
|
||||
("Ignore", json!("ignore")),
|
||||
("Guess", json!("guess")),
|
||||
],
|
||||
f.alpha as usize,
|
||||
),
|
||||
Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
|
||||
Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
|
||||
// Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
|
||||
Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
|
||||
Field::choice(
|
||||
"fields",
|
||||
"Separate Fields",
|
||||
&[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
|
||||
f.fields as usize,
|
||||
),
|
||||
Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
|
||||
Field::num("loop", "Loop (times)", f.loop_count as f64),
|
||||
Field::num("pixelAspect", "Pixel aspect ratio", f.pixel_aspect),
|
||||
// Color.
|
||||
Field::choice(
|
||||
"colorProfile",
|
||||
"Color profile",
|
||||
"Assign Profile",
|
||||
&[
|
||||
("Embedded / sRGB", json!("auto")),
|
||||
("sRGB IEC61966-2.1", json!("srgb")),
|
||||
@@ -248,6 +289,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
],
|
||||
f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
|
||||
),
|
||||
Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let tex = match cached {
|
||||
Some(c) if c.key == key => Some(c),
|
||||
_ => {
|
||||
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref() };
|
||||
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref(), footage: None };
|
||||
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
|
||||
r.expr = app.session.expr.as_deref();
|
||||
r.cache = Some(&app.session.layer_cache);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
//! Panel bodies.
|
||||
|
||||
pub mod anim_tools;
|
||||
pub mod camera_tracker_ui;
|
||||
pub mod comp_settings;
|
||||
pub mod dialogs;
|
||||
pub mod dialogs_3d;
|
||||
pub mod effect_controls;
|
||||
pub mod effects_presets;
|
||||
pub mod essential;
|
||||
pub mod expr_bar;
|
||||
pub mod flowchart;
|
||||
pub mod forms;
|
||||
pub mod fx_widgets;
|
||||
@@ -52,6 +55,8 @@ pub enum DragPayload {
|
||||
Item(u64),
|
||||
/// An effect id from Effects & Presets.
|
||||
Effect(String),
|
||||
/// A property dragged from the timeline (to the Essential Graphics panel).
|
||||
Property { layer: u64, prop: u64 },
|
||||
}
|
||||
|
||||
/// The current time formatted per project settings (timecode with `;` for drop-frame, or frames).
|
||||
@@ -96,6 +101,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
|
||||
PanelKind::Layer => layer_panel::show(app, ui, rect),
|
||||
PanelKind::Paint => paint_panels::paint(app, ui, rect),
|
||||
PanelKind::Brushes => paint_panels::brushes(app, ui, rect),
|
||||
PanelKind::EssentialGraphics => essential::show(app, ui, rect),
|
||||
PanelKind::Flowchart => flowchart::show(app, ui, rect),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +357,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add(&format!("project.item.{}.twirl", id.0), tw, &it.name);
|
||||
}
|
||||
icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(&it), t.text_dim);
|
||||
// Proxy indicator: filled = the proxy is used, hollow = set but off. Click to switch.
|
||||
if let Some(px) = it.proxy.as_ref() {
|
||||
let pr = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(9.0, 9.0));
|
||||
if px.enabled {
|
||||
lp.rect_filled(pr, 1.0, t.text);
|
||||
} else {
|
||||
lp.rect_stroke(pr, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
|
||||
}
|
||||
let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::new(("pproxy", id.0)), Sense::click()).on_hover_text(format!(
|
||||
"Proxy: {} ({})",
|
||||
px.footage.path,
|
||||
if px.enabled { "in use" } else { "off" }
|
||||
));
|
||||
app.auto.add(&format!("project.item.{}.proxy", id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
|
||||
if presp.clicked() {
|
||||
actions.push(("file.useProxy".into(), json!({"item": id.0})));
|
||||
}
|
||||
}
|
||||
let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0, r.max.y)).intersect(list);
|
||||
let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0));
|
||||
// Inline edits (rename / comment).
|
||||
|
||||
@@ -69,7 +69,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let project = app.session.project.clone();
|
||||
let expr = app.session.expr.clone();
|
||||
let now = app.session.time();
|
||||
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref() };
|
||||
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None };
|
||||
let x0 = rect.min.x + PAD;
|
||||
let w = rect.width() - 2.0 * PAD;
|
||||
// Vertical scrolling: content height is known after drawing, so clamp with last frame's.
|
||||
|
||||
@@ -77,6 +77,10 @@ fn settings_menu(it: &RenderQueueItem) -> Vec<(String, Value)> {
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(s.motion_blur, "Motion Blur: On for Checked Layers"), json!({"motionBlur": !s.motion_blur})));
|
||||
v.push((mark(s.skip_existing, "Skip Existing Files"), json!({"skipExisting": !s.skip_existing})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (u, k) in effectcraft_engine::project::render_queue::ProxyUse::ALL.iter().zip(["current", "all", "comp", "none"]) {
|
||||
v.push((mark(s.proxy_use == *u, &format!("Proxy Use: {}", u.label())), json!({"proxyUse": k})));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -108,7 +112,7 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String,
|
||||
v.push((mark(o.bitrate_kbps == kbps, &format!("Bitrate: {} Mbps", kbps / 1000)), json!({"bitrate": kbps})));
|
||||
}
|
||||
}
|
||||
OutputFormat::JpegSequence => {
|
||||
OutputFormat::JpegSequence | OutputFormat::WebM => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for q in [60u8, 80, 90, 100] {
|
||||
v.push((mark(o.quality == q, &format!("Quality: {q}")), json!({"quality": q})));
|
||||
|
||||
@@ -68,7 +68,7 @@ pub fn draw_overlay(
|
||||
let ov = match cached {
|
||||
Some(c) if c.key == key => Some(c),
|
||||
_ => {
|
||||
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref() };
|
||||
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None };
|
||||
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
|
||||
r.expr = app.session.expr.as_deref();
|
||||
r.cache = Some(&app.session.layer_cache);
|
||||
|
||||
@@ -56,7 +56,7 @@ pub fn text_target(app: &EffectcraftApp) -> Option<TextTarget> {
|
||||
.iter()
|
||||
.filter_map(|id| comp.layer(*id))
|
||||
.find(|l| matches!(l.source, effectcraft_engine::project::LayerSource::Text))?;
|
||||
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref() };
|
||||
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref(), footage: None };
|
||||
effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None })
|
||||
}
|
||||
|
||||
@@ -469,7 +469,7 @@ pub fn align_layers(app: &mut EffectcraftApp, op: &str) {
|
||||
let time = app.session.time();
|
||||
let mut moves = vec![];
|
||||
{
|
||||
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref() };
|
||||
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref(), footage: None };
|
||||
for l in comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id)) {
|
||||
let Some(b) = effectcraft_engine::render::content_bounds(&ectx, l) else { continue };
|
||||
let (m, _) = ectx.layer_to_comp(l);
|
||||
|
||||
@@ -494,6 +494,19 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
|
||||
continue;
|
||||
}
|
||||
rows.push(Row { layer: l.id, depth: 0, kind: RowKind::Layer });
|
||||
// Essential Graphics ▸ Solo Supported Properties: every property it can expose.
|
||||
if app.session.state.essential_solo {
|
||||
let mut v = vec![];
|
||||
for (i, c) in l.props.children.iter().enumerate() {
|
||||
if c.match_id() == effectcraft_engine::project::essential::GROUP {
|
||||
continue;
|
||||
}
|
||||
let g = PropGroup { children: vec![l.props.children[i].clone()], ..PropGroup::new(0, "", "") };
|
||||
collect_props(&g, &mut v, &|p| prop_visible(p, l) && effectcraft_engine::project::essential::control_type(p).is_some());
|
||||
}
|
||||
rows.extend(v.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } }));
|
||||
continue;
|
||||
}
|
||||
if !tl.open_layers.contains(&l.id.0) {
|
||||
continue;
|
||||
}
|
||||
@@ -855,6 +868,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let Some((a, b)) = run {
|
||||
draw_run(a, b);
|
||||
}
|
||||
// Disk-cached frames not in RAM (blue), like After Effects.
|
||||
if app.session.disk_cache.is_some() {
|
||||
let frames = comp.frame_rate.frame_at(comp.duration);
|
||||
let opts = app.frame_opts(cid, scale_key as f64 / 1000.0);
|
||||
let disk = app.frames.disk_frames(&app.render_source(), app.session.revision, cid.0, scale_key, app.view_hash(cid), frames, &opts);
|
||||
let draw_blue = |a: i64, b: i64| {
|
||||
let x0 = tm.x(a as f64 * fd);
|
||||
let x1 = tm.x((b + 1) as f64 * fd);
|
||||
p.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_blue);
|
||||
};
|
||||
let mut run: Option<(i64, i64)> = None;
|
||||
for f in disk {
|
||||
run = match run {
|
||||
Some((a, b)) if f == b + 1 => Some((a, f)),
|
||||
Some((a, b)) => {
|
||||
draw_blue(a, b);
|
||||
Some((f, f))
|
||||
}
|
||||
None => Some((f, f)),
|
||||
};
|
||||
}
|
||||
if let Some((a, b)) = run {
|
||||
draw_blue(a, b);
|
||||
}
|
||||
}
|
||||
// Ticks + labels.
|
||||
// Label spacing in whole frames (AE: `00:15f`-style seconds:frames labels).
|
||||
let fps_i = fr.as_f64().round().max(1.0) as i64;
|
||||
@@ -1030,7 +1068,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(max_scroll);
|
||||
let snap_project = app.session.project.clone();
|
||||
let snap_expr = app.session.expr.clone();
|
||||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() };
|
||||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
|
||||
let lp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y))));
|
||||
let gp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)));
|
||||
let mut y = rows_rect.min.y - app.ui.timeline.scroll_y;
|
||||
@@ -1495,6 +1533,40 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30));
|
||||
let _ = ui.interact(wr, egui::Id::new(("expr-err", uid)), Sense::hover()).on_hover_text(e);
|
||||
}
|
||||
// ⓕ: the Expression Language menu inserts at the cursor.
|
||||
let lang_r = Rect::from_center_size(pos2(cw.switches + 70.0, cy), vec2(16.0, 16.0));
|
||||
let lang = ui.interact(lang_r, egui::Id::new(("expr-lang", uid)), Sense::click()).on_hover_text("Expression Language Menu");
|
||||
lp.circle_stroke(lang_r.center(), 6.5, Stroke::new(1.0, if lang.hovered() { t.text } else { t.text_dim }));
|
||||
lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu");
|
||||
let mut picked: Option<&'static str> = None;
|
||||
egui::Popup::menu(&lang).show(|ui| {
|
||||
for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
|
||||
ui.menu_button(cat, |ui| {
|
||||
for (label, text) in items {
|
||||
if ui.button(label).clicked() {
|
||||
picked = Some(text);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if let Some(text) = picked {
|
||||
let edit_id = egui::Id::new(("expr-edit", uid));
|
||||
let buf_id = egui::Id::new(("expr-buf", uid));
|
||||
let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
|
||||
let at = egui::text_edit::TextEditState::load(&ctx, edit_id)
|
||||
.and_then(|st| st.cursor.char_range())
|
||||
.map(|r| r.primary.index.0)
|
||||
.unwrap_or(cur.chars().count())
|
||||
.min(cur.chars().count());
|
||||
let byte = cur.char_indices().nth(at).map(|(b, _)| b).unwrap_or(cur.len());
|
||||
let mut next = cur.clone();
|
||||
next.insert_str(byte, text);
|
||||
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": next})));
|
||||
ctx.data_mut(|d| d.remove::<String>(buf_id));
|
||||
}
|
||||
// The editor in the time-graph area.
|
||||
ui.set_clip_rect(right_clip);
|
||||
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a));
|
||||
@@ -1606,8 +1678,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
Tokens::ui(12.0),
|
||||
if prop.has_expression() { Color32::from_rgb(0xe8, 0x7c, 0x5c) } else { t.text },
|
||||
);
|
||||
let name_resp =
|
||||
ui.interact(Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y)), egui::Id::new(("pname", uid)), Sense::click());
|
||||
let name_resp = ui.interact(
|
||||
Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y)),
|
||||
egui::Id::new(("pname", uid)),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
if name_resp.drag_started() {
|
||||
// Drop on the Essential Graphics panel to expose the property.
|
||||
egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid });
|
||||
}
|
||||
if name_resp.dragged() {
|
||||
egui::Tooltip::always_open(ctx.clone(), ui.layer_id(), egui::Id::new("prop-drag-tip"), egui::PopupAnchor::Pointer)
|
||||
.show(|ui| ui.label(&prop.name));
|
||||
}
|
||||
if name_resp.clicked() {
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)})));
|
||||
}
|
||||
@@ -1889,6 +1972,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
|
||||
// Drop targets: footage/comps from the Project panel, effects from Effects & Presets.
|
||||
if let Some(payload) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx)
|
||||
&& !matches!(*payload, crate::panels::DragPayload::Property { .. })
|
||||
&& ui.rect_contains_pointer(rows_rect)
|
||||
{
|
||||
p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||||
@@ -1902,6 +1986,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]})));
|
||||
}
|
||||
}
|
||||
crate::panels::DragPayload::Property { .. } => {}
|
||||
}
|
||||
egui::DragAndDrop::clear_payload(&ctx);
|
||||
}
|
||||
|
||||
@@ -130,11 +130,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let cur = current(app);
|
||||
let can_track = has_comp && source.is_some();
|
||||
|
||||
// Track Camera / Warp Stabilizer (not available) and Track Motion / Stabilize Motion.
|
||||
// Track Camera / Warp Stabilizer and Track Motion / Stabilize Motion.
|
||||
let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
|
||||
let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
|
||||
let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({}));
|
||||
button(app, ui, r1, "Track Camera", false, "tracker.trackCamera");
|
||||
let can_camera = app.session.is_enabled("track.camera") && !app.session.is_camera_analyzing();
|
||||
if button(app, ui, r1, "Track Camera", can_camera, "tracker.trackCamera") {
|
||||
run(app, &ctx, "track.camera", json!({}));
|
||||
}
|
||||
let can_warp = app.session.is_enabled("track.warpStabilizer") && !app.session.is_warp_analyzing();
|
||||
if button(app, ui, r2, "Warp Stabilizer", can_warp, "tracker.warpStabilizer") {
|
||||
run(app, &ctx, "track.warpStabilizer", json!({}));
|
||||
|
||||
@@ -373,7 +373,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Bottom control bar.
|
||||
let bar_h = 30.0;
|
||||
let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - bar_h), rect.max);
|
||||
let full = Rect::from_min_max(pos2(rect.min.x, nav.max.y), pos2(rect.max.x, bar.min.y));
|
||||
// The expression error bar sits above the control bar while expressions fail.
|
||||
let err_h = super::expr_bar::height(app, &ctx, cid);
|
||||
let err_bar = Rect::from_min_max(pos2(rect.min.x, bar.min.y - err_h), pos2(rect.max.x, bar.min.y));
|
||||
let full = Rect::from_min_max(pos2(rect.min.x, nav.max.y), pos2(rect.max.x, err_bar.min.y));
|
||||
let pasteboard = app.ui.viewer.pasteboard.map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(t.pasteboard);
|
||||
// View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which
|
||||
// keeps the overlays and the interaction.
|
||||
@@ -420,7 +423,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let snap_project = app.session.project.clone();
|
||||
let snap_expr = app.session.expr.clone();
|
||||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() };
|
||||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
|
||||
// The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region.
|
||||
vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx);
|
||||
painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
|
||||
@@ -938,6 +941,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
comp: app.session.project.comp(cid).unwrap_or(&comp),
|
||||
time,
|
||||
expr: app.session.expr.as_deref(),
|
||||
footage: None,
|
||||
};
|
||||
if let Some(m) = l2c(&e2, &l).0.inverse() {
|
||||
*i2 = m;
|
||||
@@ -1198,6 +1202,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.session.open_comp(*item);
|
||||
}
|
||||
}
|
||||
// 3D Camera Tracker points, targets and their menu (on top of the viewer's gestures while
|
||||
// the effect is selected), and its analysis banner.
|
||||
super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
|
||||
// Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
|
||||
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
|
||||
|
||||
@@ -1211,6 +1218,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
vt::draw_snap(&ctx, &painter, &map, &ectx);
|
||||
vt::rulers(app, ui, &map, outer, area);
|
||||
vt::bottom_bar(app, ui, bar, zoom, fit, time, &comp);
|
||||
if err_h > 0.0 {
|
||||
super::expr_bar::draw(app, ui, err_bar, cid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parent-space position change per comp pixel of drag (x and y) for a layer: 2D layers map
|
||||
@@ -1450,6 +1460,7 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
|
||||
comp: &comp,
|
||||
time: app.session.time(),
|
||||
expr: None,
|
||||
footage: None,
|
||||
};
|
||||
let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY);
|
||||
let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0));
|
||||
|
||||
@@ -361,19 +361,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};
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct Tokens {
|
||||
pub cti: Color32,
|
||||
pub work_area: Color32,
|
||||
pub cache_green: Color32,
|
||||
/// Timeline cache bar for frames held in the disk cache (not in RAM).
|
||||
pub cache_blue: Color32,
|
||||
pub keyframe: Color32,
|
||||
pub keyframe_selected: Color32,
|
||||
pub pasteboard: Color32,
|
||||
@@ -108,6 +110,7 @@ impl Tokens {
|
||||
cti: Color32::from_rgb(0x2d, 0x8c, 0xeb),
|
||||
work_area: Color32::from_rgb(0x4a, 0x4a, 0x4a),
|
||||
cache_green: Color32::from_rgb(0x3c, 0xa6, 0x4c),
|
||||
cache_blue: Color32::from_rgb(0x3a, 0x6f, 0xd8),
|
||||
keyframe: Color32::from_rgb(0xa8, 0xa8, 0xa8),
|
||||
keyframe_selected: Color32::from_rgb(0x3d, 0x8f, 0xf5),
|
||||
pasteboard: Color32::from_rgb(0x1a, 0x1a, 0x1a),
|
||||
|
||||
Reference in New Issue
Block a user