mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #643 from storytold/claude/community-pr-review-lwtrlr
Land community PRs, batch 3, with follow-ups
This commit is contained in:
44 files changed
+1527
-118
No files matched your search
@@ -10,11 +10,32 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{FromSample, SizedSample};
|
||||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput};
|
||||
|
||||
/// Device callback period: about 10 ms, clamped so Pulse/PipeWire/ALSA does not open a
|
||||
/// multi-hundred-millisecond default buffer that plays as silence at the start of preview (#554).
|
||||
fn preferred_buffer_frames(rate: u32) -> u32 {
|
||||
(rate / 100).clamp(256, 2048)
|
||||
}
|
||||
|
||||
/// Wait until the feeder has mixed a block, or until preview is stopped. Starting the stream
|
||||
/// before that fills the device with silence, so the first real samples are heard late (#554).
|
||||
fn wait_to_play(feed: &AudioFeed, stop: &mpsc::Receiver<()>) -> bool {
|
||||
loop {
|
||||
if feed.queued_frames() > 0 {
|
||||
return true;
|
||||
}
|
||||
match stop.recv_timeout(Duration::from_millis(2)) {
|
||||
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CpalOut {
|
||||
out: AudioOutput,
|
||||
rate: u32,
|
||||
@@ -131,21 +152,44 @@ impl AudioDevice for CpalOut {
|
||||
};
|
||||
let mut cfg: cpal::StreamConfig = sup.config();
|
||||
cfg.sample_rate = cpal::SampleRate(rate);
|
||||
let stream = match sup.sample_format() {
|
||||
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency, map),
|
||||
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency, map),
|
||||
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency, map),
|
||||
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency, map),
|
||||
// A short period only where the default one is long (ALSA/PulseAudio on Linux, #554),
|
||||
// and only within what the device reports it takes.
|
||||
if cfg!(target_os = "linux") {
|
||||
let want = preferred_buffer_frames(rate);
|
||||
cfg.buffer_size = match sup.buffer_size() {
|
||||
cpal::SupportedBufferSize::Range { min, max } if *min <= *max => cpal::BufferSize::Fixed(want.clamp(*min, *max)),
|
||||
_ => cpal::BufferSize::Default,
|
||||
};
|
||||
}
|
||||
let build = |cfg: &cpal::StreamConfig| match sup.sample_format() {
|
||||
cpal::SampleFormat::F32 => run::<f32>(&dev, cfg, feed.clone(), latency.clone(), map),
|
||||
cpal::SampleFormat::I16 => run::<i16>(&dev, cfg, feed.clone(), latency.clone(), map),
|
||||
cpal::SampleFormat::U16 => run::<u16>(&dev, cfg, feed.clone(), latency.clone(), map),
|
||||
cpal::SampleFormat::I32 => run::<i32>(&dev, cfg, feed.clone(), latency.clone(), map),
|
||||
f => Err(format!("unsupported sample format {f:?}")),
|
||||
};
|
||||
let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) {
|
||||
let stream = match build(&cfg) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
Err(_) => {
|
||||
// Some hosts reject a fixed period; a large default still plays, just later.
|
||||
cfg.buffer_size = cpal::BufferSize::Default;
|
||||
match build(&cfg) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = ready_tx.send(Ok(()));
|
||||
if !wait_to_play(&feed, &stop_rx) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = stream.play() {
|
||||
eprintln!("effectcraft: audio output: {e}");
|
||||
return;
|
||||
}
|
||||
let _ = stop_rx.recv();
|
||||
drop(stream);
|
||||
})
|
||||
@@ -174,3 +218,37 @@ impl Drop for CpalOut {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preferred_buffer_is_about_ten_milliseconds() {
|
||||
assert_eq!(preferred_buffer_frames(48_000), 480);
|
||||
assert_eq!(preferred_buffer_frames(8_000), 256);
|
||||
assert_eq!(preferred_buffer_frames(192_000), 1920);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_to_play_returns_once_the_feeder_has_samples() {
|
||||
let feed = Arc::new(AudioFeed::default());
|
||||
let (_tx, rx) = mpsc::channel();
|
||||
let f = feed.clone();
|
||||
let h = std::thread::spawn(move || wait_to_play(&f, &rx));
|
||||
assert_eq!(feed.queued_frames(), 0);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
feed.push(&[0.1, 0.2]);
|
||||
assert!(h.join().unwrap());
|
||||
assert_eq!(feed.queued_frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_to_play_stops_without_samples() {
|
||||
let feed = AudioFeed::default();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let h = std::thread::spawn(move || wait_to_play(&feed, &rx));
|
||||
tx.send(()).unwrap();
|
||||
assert!(!h.join().unwrap());
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
|
||||
("ec.blur.cameralens", &["irisProperties/diffractionFringe", "blurMap/blurMapLayer"]),
|
||||
("ec.noise.removegrain", &["temporalFiltering/enabled"]),
|
||||
("ec.distort.liquify", &["viewFreezeAreaMask", "viewMesh"]),
|
||||
("ec.distort.ccpowerpin", &["unstretch"]),
|
||||
("ec.matte.refinesoft", &["reduceChatter", "useMotionBlur", "invert"]),
|
||||
("ec.matte.refinehard", &["reduceChatter", "useMotionBlur", "invert"]),
|
||||
];
|
||||
@@ -201,6 +202,8 @@ mod tests {
|
||||
assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))]));
|
||||
assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))]));
|
||||
assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))]));
|
||||
assert!(ok("ec.distort.ccpowerpin", &[]));
|
||||
assert!(!ok("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))]));
|
||||
assert!(ok("ec.blur.gaussian", &[]));
|
||||
}
|
||||
|
||||
|
||||
@@ -182,8 +182,9 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh);
|
||||
let (x1, y1) = (lw + ex[2] / 100.0 * lw, lh + ex[3] / 100.0 * lh);
|
||||
let corners_layer = [ctx.params.v2("topLeft"), ctx.params.v2("topRight"), ctx.params.v2("bottomRight"), ctx.params.v2("bottomLeft")];
|
||||
// Grow the buffer to hold the pinned quad.
|
||||
if !ctx.adjustment {
|
||||
let unstretch = ctx.params.b("unstretch");
|
||||
// Grow the buffer to hold the pinned quad (Unstretch writes inside the layer rectangle, so it needs no room).
|
||||
if !ctx.adjustment && !unstretch {
|
||||
let mut need = 0.0f64;
|
||||
for c in &corners_layer {
|
||||
let (px, py) = b.to_px(*c);
|
||||
@@ -198,7 +199,8 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
vec2(p.0, p.1)
|
||||
});
|
||||
let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0);
|
||||
let Some(inv) = Mat3::square_to_quad(q).inverse() else { return b };
|
||||
let fwd = Mat3::square_to_quad(q);
|
||||
let Some(inv) = fwd.inverse() else { return b };
|
||||
let src = b.img.clone();
|
||||
let (sc, off) = (b.scale, b.offset);
|
||||
let bil = move |u: f64, v: f64| -> (f64, f64) {
|
||||
@@ -208,6 +210,17 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
};
|
||||
b.img = crate::util::gen_image(src.width, src.height, |x, y| {
|
||||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
if unstretch {
|
||||
// Reverse pinning: the layer rectangle is the target, and the pinned quad is the region that fills it.
|
||||
let u = ((px - off[0]) / sc - x0) / (x1 - x0);
|
||||
let v = ((py - off[1]) / sc - y0) / (y1 - y0);
|
||||
if !(-1e-9..=1.0 + 1e-9).contains(&u) || !(-1e-9..=1.0 + 1e-9).contains(&v) {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let m = fwd.apply(vec2(u, v));
|
||||
let (bx, by) = bil(u, v);
|
||||
return src.sample_bilinear(bx + (m.x - bx) * persp, by + (m.y - by) * persp);
|
||||
}
|
||||
let m = inv.apply(vec2(px, py));
|
||||
let (mut u, mut v) = (m.x, m.y);
|
||||
if persp < 1.0 {
|
||||
@@ -1127,6 +1140,23 @@ mod tests {
|
||||
assert!((px[0] - 0.5).abs() < 0.1, "{px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_pin_unstretch_expands_pinned_region() {
|
||||
let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]);
|
||||
let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))];
|
||||
let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count();
|
||||
let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]);
|
||||
assert_eq!(opaque(&off), 96);
|
||||
let mut set = inset.to_vec();
|
||||
set.push(("unstretch", Value::Bool(true)));
|
||||
let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
|
||||
assert_eq!(opaque(&on), 24 * 16);
|
||||
assert!(on.img.get(0, 0)[3] > 0.99);
|
||||
// Default corners are the identity either way.
|
||||
let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]);
|
||||
assert!(maxd(&id.img, &im) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_opens_a_gap() {
|
||||
let im = img(40, 30);
|
||||
|
||||
@@ -340,6 +340,24 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let (nx, ny) = (-ty, tx);
|
||||
let src = b.img.clone();
|
||||
let soft_w = half * slope as f64;
|
||||
// Frame Offset: the same pixels from the previous or next frame, resampled into this grid.
|
||||
let offset = ctx.params.f("frameOffset").round();
|
||||
let neighbour = if style == 1 && offset != 0.0 {
|
||||
ctx.env.host.and_then(|h| h.self_at(ctx.time + offset / ctx.fps(), ctx.env.effect_index)).map(|nb| {
|
||||
if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale {
|
||||
nb.img
|
||||
} else {
|
||||
let k = nb.scale / b.scale.max(1e-9);
|
||||
crate::util::gen_image(b.img.width, b.img.height, |x, y| {
|
||||
let lx = (x as f64 + 0.5 - b.offset[0]) * k + nb.offset[0];
|
||||
let ly = (y as f64 + 0.5 - b.offset[1]) * k + nb.offset[1];
|
||||
nb.img.sample_bilinear(lx, ly)
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay);
|
||||
@@ -357,7 +375,9 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let t = ((d + edge) / (2.0 * edge)) as f32;
|
||||
lerp4(pos(-edge), pos(edge), t.clamp(0.0, 1.0))
|
||||
}
|
||||
// Displace: shift the image across the wire from the nearer side.
|
||||
1 if neighbour.is_some() => neighbour.as_ref().map_or(*px, |n| n.get(x as i64, y as i64)),
|
||||
// Displace: shift the image across the wire from the nearer side (also Frame Offset
|
||||
// when no other frame is available).
|
||||
2 | 1 => {
|
||||
let s = if d >= 0.0 { d + 2.0 * (half - ad) + 1.0 } else { d - 2.0 * (half - ad) - 1.0 };
|
||||
let sh = pos(s);
|
||||
@@ -560,6 +580,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_removal_frame_offset_uses_neighbour_frame() {
|
||||
use crate::{EffectHost, LayerPixels};
|
||||
struct Frames;
|
||||
impl EffectHost for Frames {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
|
||||
// Frame n (at 10 fps) is a flat grey of n / 10.
|
||||
let v = (t * 10.0).round() as f32 / 10.0;
|
||||
Some(Buf { img: Image::filled(20, 20, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
}
|
||||
let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]);
|
||||
for y in 0..20 {
|
||||
img.set(10, y, [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
let host = Frames;
|
||||
let run = |style: u32, off: f64| {
|
||||
let env = EffectEnv { host: Some(&host), frame_rate: 10.0, ..Default::default() };
|
||||
let vals = [
|
||||
("pointA", Value::Vec2([10.5, 0.0])),
|
||||
("pointB", Value::Vec2([10.5, 20.0])),
|
||||
("removalStyle", Value::Enum(style)),
|
||||
("thickness", num(3.0)),
|
||||
("frameOffset", num(off)),
|
||||
];
|
||||
run_fx("ec.key.ccsimplewireremoval", &vals, img.clone(), 0.5, env).img
|
||||
};
|
||||
// At 0.5 s (frame 5) offset -1 shows frame 4 and +1 shows frame 6 inside the strip.
|
||||
let (prev, next) = (run(1, -1.0), run(1, 1.0));
|
||||
assert!((prev.get(10, 10)[0] - 0.4).abs() < 1e-4, "{:?}", prev.get(10, 10));
|
||||
assert!((next.get(10, 10)[0] - 0.6).abs() < 1e-4, "{:?}", next.get(10, 10));
|
||||
// Outside the strip the current frame stays.
|
||||
assert_eq!(prev.get(2, 10), img.get(2, 10));
|
||||
// Displace ignores Frame Offset.
|
||||
assert_eq!(run(2, -1.0).data, run(2, 1.0).data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_outer_key_more_masks_and_cleanup_strokes() {
|
||||
let sq = |x0: f64, x1: f64| MaskShape { name: String::new(), points: vec![[x0, 2.0], [x1, 2.0], [x1, 8.0], [x0, 8.0]], closed: true, inverted: false };
|
||||
|
||||
@@ -416,7 +416,24 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
filter.as_ref().is_none_or(|f| effectcraft_effects::name_matches(e, f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f))
|
||||
})
|
||||
.map(|e| {
|
||||
json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::<Vec<_>>()})
|
||||
json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| {
|
||||
let mut out = json!({"id": p.id, "name": p.name, "type": p.default.kind_name(), "default": p.default.to_json()});
|
||||
match &p.ui {
|
||||
effectcraft_project::ParamUi::Popup { options } => {
|
||||
// Engine/MCP popup values are zero-based; JSX exposes one-based values.
|
||||
out["options"] = json!(options.iter().enumerate().map(|(value, label)| json!({"value": value, "label": label})).collect::<Vec<_>>());
|
||||
}
|
||||
effectcraft_project::ParamUi::Slider { min, max, slider_min, slider_max, decimals } => {
|
||||
out["min"] = json!(min);
|
||||
out["max"] = json!(max);
|
||||
out["sliderMin"] = json!(slider_min);
|
||||
out["sliderMax"] = json!(slider_max);
|
||||
out["decimals"] = json!(decimals);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}).collect::<Vec<_>>()})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!(v))
|
||||
@@ -470,7 +487,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("effect.copy", "Copy Effects", [], None, "{layer?, effect? | effects?: [uid|name|index]} (default: the selected effects)", has_layers, copy),
|
||||
cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste),
|
||||
cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset),
|
||||
crate::query!("effect.list", "List Effects", "{filter?}", list),
|
||||
crate::query!(
|
||||
"effect.list",
|
||||
"List Effects",
|
||||
"{filter?} → [{params: [{id, name, type, default, options?, min?, max?, sliderMin?, sliderMax?, decimals?}]}]; popup indices are zero-based (JSX uses one-based values)",
|
||||
list
|
||||
),
|
||||
crate::query!(
|
||||
"effect.warning",
|
||||
"Effect Warning",
|
||||
@@ -494,3 +516,36 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod list_metadata_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn effect_list_exposes_popup_labels_and_numeric_slider_limits() {
|
||||
let mut s = Session::default();
|
||||
let menus = s.execute("effect.list", json!({"filter": "ec.control.dropdown"})).unwrap();
|
||||
assert_eq!(menus.as_array().map(Vec::len), Some(1));
|
||||
let menu = &menus[0]["params"][0];
|
||||
assert_eq!(menu["type"], "enum");
|
||||
assert_eq!(menu["default"], 0);
|
||||
assert_eq!(
|
||||
menu["options"],
|
||||
json!([
|
||||
{"value": 0, "label": "Item 1"},
|
||||
{"value": 1, "label": "Item 2"},
|
||||
{"value": 2, "label": "Item 3"}
|
||||
])
|
||||
);
|
||||
let sliders = s.execute("effect.list", json!({"filter": "ec.control.slider"})).unwrap();
|
||||
assert_eq!(sliders.as_array().map(Vec::len), Some(1));
|
||||
let slider = &sliders[0]["params"][0];
|
||||
assert_eq!(slider["type"], "scalar");
|
||||
assert_eq!(slider["min"], -1_000_000.0);
|
||||
assert_eq!(slider["max"], 1_000_000.0);
|
||||
assert_eq!(slider["sliderMin"], 0.0);
|
||||
assert_eq!(slider["sliderMax"], 100.0);
|
||||
assert_eq!(slider["decimals"], 2);
|
||||
assert!(slider["options"].is_null());
|
||||
}
|
||||
}
|
||||
@@ -981,6 +981,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
|
||||
t += fd;
|
||||
}
|
||||
// A time-reversed layer maps ascending comp times to descending layer times; keys must stay ordered by layer time.
|
||||
keys.sort_by_key(|k| k.time);
|
||||
let n = keys.len();
|
||||
with_prop(s, "Convert Expression to Keyframes", None, cid, lid, uid, move |pr, _| {
|
||||
pr.keys = keys;
|
||||
|
||||
@@ -38,7 +38,24 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) ->
|
||||
let start = fr.frame_at(comp.display_start) + st.frame_start;
|
||||
effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0)
|
||||
}
|
||||
None => effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0),
|
||||
None => {
|
||||
let parsed = effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0)?;
|
||||
let relative = text.trim().starts_with('+') || text.trim().starts_with('-');
|
||||
if relative {
|
||||
Ok(parsed)
|
||||
} else if st.time_display == TimeDisplayStyle::Frames
|
||||
&& let Ok(frame) = text.trim().parse::<i64>()
|
||||
{
|
||||
// Displayed digits are a plain frame count (00100 is frame 100, not 1 s) plus
|
||||
// Start Numbering Frames At.
|
||||
Ok(frame.saturating_sub(st.frame_start))
|
||||
} else if st.time_display == TimeDisplayStyle::Frames {
|
||||
Ok(parsed.saturating_sub(st.frame_start))
|
||||
} else {
|
||||
// Displayed timecode includes the composition Start Timecode offset.
|
||||
Ok(parsed.saturating_sub(fr.frame_at(comp.display_start)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,14 @@ struct Work {
|
||||
nested_switches: bool,
|
||||
}
|
||||
|
||||
/// Where a relative render output goes when there is no default folder and the project is unsaved:
|
||||
/// `~/Movies` when it exists, else the home folder itself.
|
||||
pub(crate) fn default_output_base(home: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
|
||||
let home = home.filter(|h| !h.as_os_str().is_empty())?;
|
||||
let movies = home.join("Movies");
|
||||
Some(if movies.is_dir() { movies } else { home })
|
||||
}
|
||||
|
||||
/// Bits per channel an output format writes for a project bit depth (named in the file when
|
||||
/// Settings ▸ Export ▸ Append Bit Depth to File Name is on).
|
||||
pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) -> u32 {
|
||||
@@ -324,7 +332,7 @@ impl Session {
|
||||
|
||||
/// The output path of a queue item: template tokens expanded, ending in the format's
|
||||
/// extension exactly once ([`OutputFormat::with_extension`]), relative paths resolved against
|
||||
/// the project's folder (or the working directory). `None` when the comp is gone or the
|
||||
/// the project's folder (an unsaved project's against ~/Movies or home). `None` when the comp is gone or the
|
||||
/// output is empty ("Needs Output").
|
||||
pub fn resolve_output(&self, item: &RenderQueueItem) -> Option<String> {
|
||||
let comp = self.project.comp(item.comp)?;
|
||||
@@ -351,6 +359,10 @@ impl Session {
|
||||
let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder));
|
||||
let base =
|
||||
base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty()));
|
||||
// Unsaved project: ~/Movies, else the home folder (as FilmCraft's exports do). The working directory
|
||||
// is `/` for an app started from the macOS Finder, where nothing can be written (#446).
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(std::path::PathBuf::from);
|
||||
let base = base.or_else(|| default_output_base(home));
|
||||
// (the web has no working directory: relative outputs land in its virtual root)
|
||||
let base = base.or_else(|| std::env::current_dir().ok()).or_else(|| cfg!(target_arch = "wasm32").then(|| "/".into()))?;
|
||||
Some(base.join(path).to_string_lossy().to_string())
|
||||
|
||||
@@ -682,3 +682,13 @@ fn dynamic_menus_and_window_viewer_labels() {
|
||||
assert!(labels.iter().any(|x| x == l), "{l}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsaved_project_renders_to_movies_or_home_not_the_working_directory() {
|
||||
// A Finder-launched app has `/` as its working directory, where renders fail (#446).
|
||||
let home = tmp("home446");
|
||||
assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.clone()));
|
||||
std::fs::create_dir_all(home.join("Movies")).unwrap();
|
||||
assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.join("Movies")));
|
||||
assert_eq!(crate::render_queue::default_output_base(None), None);
|
||||
}
|
||||
@@ -242,6 +242,25 @@ fn graph_editor_key_and_handle_edits() {
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_expression_to_keyframes_on_a_reversed_layer_keeps_values() {
|
||||
let (mut s, l) = setup();
|
||||
// These tests run without an expression host, so the conversion bakes the keyed ramp under the expression.
|
||||
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (3.0, json!(30))]);
|
||||
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/rotation", "expression": "value"})).unwrap();
|
||||
s.execute("layer.timeReverse", json!({"layers": [l]})).unwrap();
|
||||
let at = |t: f64| json!({"layer": l, "path": "transform/rotation", "time": t});
|
||||
let sample = |s: &mut Session| [0.5, 1.0, 2.0].map(|t| s.execute("prop.get", at(t)).unwrap()["value"].as_f64().unwrap());
|
||||
let before = sample(&mut s);
|
||||
assert!(before[0] > before[1] && before[1] > before[2], "{before:?}");
|
||||
s.execute("prop.convertExpressionToKeyframes", json!({"layer": l, "path": "transform/rotation"})).unwrap();
|
||||
let keys = prop(&s, l, "transform/rotation").keys;
|
||||
assert!(keys.len() > 1);
|
||||
assert!(keys.windows(2).all(|w| w[0].time < w[1].time));
|
||||
let after = sample(&mut s);
|
||||
assert!(before.iter().zip(after).all(|(b, a)| (b - a).abs() < 1e-6), "{before:?} -> {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_reverse_keyframes() {
|
||||
let (mut s, l) = setup();
|
||||
@@ -391,6 +410,36 @@ fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() {
|
||||
assert_eq!(effectcraft_time::format_timecode_ae(75, fr, true), "0;00;02;15");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_set_keeps_the_frame_shown_in_the_current_time_field() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Example", "width": 64, "height": 64, "frameRate": 30, "duration": 0.6, "startTimecode": "0:00:00:05"})).unwrap();
|
||||
let r = s.execute("time.set", json!({"frame": 0})).unwrap();
|
||||
assert_eq!(r["display"], "0:00:00:05");
|
||||
let r = s.execute("time.set", json!({"timecode": "0:00:00:05"})).unwrap();
|
||||
assert_eq!((r["frame"].as_i64(), r["time"].as_f64()), (Some(0), Some(0.0)));
|
||||
|
||||
s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.project).settings.frame_start = 1;
|
||||
let r = s.execute("time.set", json!({"frame": 12})).unwrap();
|
||||
assert_eq!(r["display"], "00013");
|
||||
let r = s.execute("time.set", json!({"timecode": "00013"})).unwrap();
|
||||
assert_eq!((r["frame"].as_i64(), r["time"].as_f64()), (Some(12), Some(0.4)));
|
||||
let r = s.execute("time.set", json!({"timecode": "+1"})).unwrap();
|
||||
assert_eq!(r["frame"], 13);
|
||||
}
|
||||
|
||||
/// Frames display style: typed digits are a frame count, so 00100 is frame 100, not 1 s (#530).
|
||||
#[test]
|
||||
fn time_set_reads_frames_style_digits_as_a_frame_count() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Long", "width": 64, "height": 64, "frameRate": 30, "duration": 10})).unwrap();
|
||||
s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap();
|
||||
let r = s.execute("time.set", json!({"timecode": "00100"})).unwrap();
|
||||
assert_eq!(r["frame"], 100);
|
||||
assert_eq!(r["display"], "00100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_dimensions_split_and_rejoin() {
|
||||
let (mut s, l) = setup();
|
||||
|
||||
@@ -476,6 +476,10 @@ fn wire_removal(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
if half <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
// Frame Offset reads another frame from the effect host: that renders on the CPU.
|
||||
if ctx.params.e("removalStyle") == 1 && ctx.params.f("frameOffset").round() != 0.0 && ctx.env.host.is_some() {
|
||||
return None;
|
||||
}
|
||||
let slope = (ctx.params.f("slope") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let mirror = (ctx.params.f("mirrorBlend") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let a = b.to_px(ctx.params.v2("pointA"));
|
||||
|
||||
@@ -486,6 +486,9 @@ fn ripple_pulse(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
}
|
||||
|
||||
fn power_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
if ctx.params.b("unstretch") {
|
||||
return None; // Unstretch (reverse pinning) renders on the CPU (catalog CPU_ONLY_CONTROLS).
|
||||
}
|
||||
let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0);
|
||||
let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0));
|
||||
let ex = [ctx.params.f("expandTop"), ctx.params.f("expandLeft"), ctx.params.f("expandRight"), ctx.params.f("expandBottom")];
|
||||
|
||||
@@ -243,6 +243,7 @@ fn keys_and_3d() {
|
||||
effect_case("ec.key.ccsimplewireremoval", &[]);
|
||||
effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(0)), ("thickness", n(8.0)), ("slope", n(50.0))]);
|
||||
effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(1)), ("mirrorBlend", n(60.0)), ("pointB", pt(60.0, 10.0))]);
|
||||
effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(1)), ("frameOffset", n(-2.0)), ("thickness", n(6.0))]);
|
||||
effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(3)), ("mirrorBlend", n(30.0)), ("slope", n(20.0))]);
|
||||
effect_case("ec.obsolete.basic3d", &[("swivel", n(25.0))]);
|
||||
effect_case("ec.obsolete.basic3d", &[("tilt", n(-30.0)), ("swivel", n(10.0)), ("specularHighlight", on())]);
|
||||
|
||||
@@ -426,7 +426,13 @@ pub fn retime_roving(keys: &mut [Keyframe], spatial: bool) -> bool {
|
||||
for (o, i) in (a + 1..b).enumerate() {
|
||||
let f = if total > 1e-9 { acc[o + 1] / total } else { (o + 1) as f64 / (b - a) as f64 };
|
||||
let span = i128::from(t1.0) - i128::from(t0.0);
|
||||
let retimed = i128::from(t0.0) + (span as f64 * f).round() as i128;
|
||||
let mut retimed = i128::from(t0.0) + (span as f64 * f).round() as i128;
|
||||
// Keep every roving key strictly between its neighbours: a zero-length segment would otherwise
|
||||
// put it on a fixed key's time, and two keys can't share a time.
|
||||
let (lo, hi) = (i128::from(keys[i - 1].time.0) + 1, i128::from(t1.0) - (b - i) as i128);
|
||||
if lo <= hi {
|
||||
retimed = retimed.clamp(lo, hi);
|
||||
}
|
||||
let nt = Tick(retimed.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64);
|
||||
if nt != keys[i].time {
|
||||
keys[i].time = nt;
|
||||
@@ -817,6 +823,21 @@ mod tests {
|
||||
assert!(!keys[0].roving);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roving_keys_never_share_a_time_with_a_fixed_key() {
|
||||
// Zero-length last segment (issue #532) and zero-length first segment.
|
||||
for xs in [[0.0, 40.0, 120.0, 120.0], [0.0, 0.0, 40.0, 120.0]] {
|
||||
let mut keys: Vec<Keyframe> = xs.iter().enumerate().map(|(i, x)| Keyframe::new(s(i as f64), Value::Vec2([*x, 64.0]))).collect();
|
||||
for k in &mut keys {
|
||||
k.spatial_auto = false;
|
||||
}
|
||||
keys[1].roving = true;
|
||||
keys[2].roving = true;
|
||||
retime_roving(&mut keys, true);
|
||||
assert!(keys.windows(2).all(|w| w[0].time < w[1].time), "{xs:?}: {:?}", keys.iter().map(|k| k.time).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_ease_reports_linear_and_eased_sides() {
|
||||
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).eased()];
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
//! The colour picker every colour input opens, laid out like After Effects' Color Picker: a
|
||||
//! square and a slider for the channel chosen with the H / S / B / R / G / B buttons, the new
|
||||
//! colour over the original, the HSB and RGB values, the hex code, and OK / Cancel.
|
||||
//!
|
||||
//! Colours are sRGB components (0–1), as the project stores them. Changes apply as they are made
|
||||
//! (After Effects' Preview); Cancel or Escape goes back to the colour the picker opened with.
|
||||
|
||||
use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
|
||||
use crate::i18n::tr;
|
||||
use crate::widgets::{open_popup, popup_is_open, pressed_outside, select_all};
|
||||
|
||||
/// A colour button for egui-laid-out rows and dialogs: a swatch that opens [`color_popup`]. Don't
|
||||
/// use egui's `color_edit_button_rgb(a)` for project colours: they read linear values, so the
|
||||
/// swatch shows lighter and a picked colour is stored darker. The value is only rewritten when the
|
||||
/// user picks a colour.
|
||||
pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response {
|
||||
let (rect, mut resp) = ui.allocate_exact_size(ui.spacing().interact_size, Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ColorButton, ui.is_enabled(), srgb_hex(*c)));
|
||||
let stroke = if resp.hovered() { ui.visuals().widgets.hovered.fg_stroke } else { ui.visuals().widgets.inactive.bg_stroke };
|
||||
ui.painter().rect_filled(rect, 2.0, srgb32(*c));
|
||||
ui.painter().rect_stroke(rect, 2.0, stroke, StrokeKind::Inside);
|
||||
if resp.clicked() {
|
||||
open_popup(ui, resp.id);
|
||||
}
|
||||
if color_popup(ui, resp.id, rect.left_bottom(), c) {
|
||||
resp.mark_changed();
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// The colour picker as a popup at `pos`, bound to `c`, opened with [`open_popup`]. Returns true
|
||||
/// when the colour changed (Cancel, Escape and the original swatch change it back).
|
||||
pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool {
|
||||
if !popup_is_open(ui, id) {
|
||||
return false;
|
||||
}
|
||||
let orig_id = id.with("original");
|
||||
let original = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(orig_id, || *c));
|
||||
let mut out = Outcome::default();
|
||||
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| out = color_picker(ui, id, c, original));
|
||||
});
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
out.close = Some(false);
|
||||
}
|
||||
if pressed_outside(ui.ctx(), &area.response) {
|
||||
out.close.get_or_insert(true);
|
||||
}
|
||||
if let Some(keep) = out.close {
|
||||
if !keep && *c != original {
|
||||
*c = original;
|
||||
out.changed = true;
|
||||
}
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(id.with("open"), false);
|
||||
d.remove::<[f32; 3]>(orig_id);
|
||||
});
|
||||
}
|
||||
out.changed
|
||||
}
|
||||
|
||||
/// What the picker did this frame.
|
||||
#[derive(Default)]
|
||||
struct Outcome {
|
||||
changed: bool,
|
||||
/// Close the picker, keeping the colour (OK) or not (Cancel).
|
||||
close: Option<bool>,
|
||||
}
|
||||
|
||||
/// The channel the slider sets; the square sets the other two of its model.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Chan {
|
||||
H,
|
||||
S,
|
||||
V,
|
||||
R,
|
||||
G,
|
||||
B,
|
||||
}
|
||||
|
||||
impl Chan {
|
||||
/// The square's (across, up) channels while `self` is on the slider (After Effects' layout).
|
||||
fn square(self) -> (Chan, Chan) {
|
||||
match self {
|
||||
Chan::H => (Chan::S, Chan::V),
|
||||
Chan::S => (Chan::H, Chan::V),
|
||||
Chan::V => (Chan::H, Chan::S),
|
||||
Chan::R => (Chan::B, Chan::G),
|
||||
Chan::G => (Chan::B, Chan::R),
|
||||
Chan::B => (Chan::R, Chan::G),
|
||||
}
|
||||
}
|
||||
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Chan::H | Chan::R => 0,
|
||||
Chan::S | Chan::G => 1,
|
||||
Chan::V | Chan::B => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hsv(self) -> bool {
|
||||
matches!(self, Chan::H | Chan::S | Chan::V)
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour as both HSB and RGB: HSB keeps the hue (and saturation) of greys and black, where
|
||||
/// RGB has none, so moving off black returns to the hue that was chosen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Pick {
|
||||
hsv: [f32; 3],
|
||||
rgb: [f32; 3],
|
||||
}
|
||||
|
||||
impl Pick {
|
||||
fn get(&self, ch: Chan) -> f32 {
|
||||
let v = if ch.is_hsv() { self.hsv } else { self.rgb };
|
||||
v.get(ch.index()).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn set(mut self, ch: Chan, v: f32) -> Pick {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
if ch.is_hsv() {
|
||||
if let Some(x) = self.hsv.get_mut(ch.index()) {
|
||||
*x = v;
|
||||
}
|
||||
self.rgb = hsv_to_rgb(self.hsv);
|
||||
} else {
|
||||
if let Some(x) = self.rgb.get_mut(ch.index()) {
|
||||
*x = v;
|
||||
}
|
||||
self.hsv = keep_hue(rgb_to_hsv(self.rgb), self.hsv);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// The colour at (`u` across, `v` up) on the square, or `t` up the slider.
|
||||
fn at(&self, ch: Chan, u: f32, v: f32) -> Pick {
|
||||
let (x, y) = ch.square();
|
||||
self.set(x, u).set(y, v)
|
||||
}
|
||||
}
|
||||
|
||||
const SQUARE: f32 = 200.0;
|
||||
const SLIDER_W: f32 = 16.0;
|
||||
|
||||
fn color_picker(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3], original: [f32; 3]) -> Outcome {
|
||||
let state_id = id.with("pick");
|
||||
let mut pick = match ui.data(|d| d.get_temp::<([f32; 3], [f32; 3])>(state_id)) {
|
||||
Some((hsv, rgb)) if rgb == *c => Pick { hsv, rgb },
|
||||
Some((hsv, _)) => Pick { hsv: keep_hue(rgb_to_hsv(*c), hsv), rgb: *c },
|
||||
None => Pick { hsv: rgb_to_hsv(*c), rgb: *c },
|
||||
};
|
||||
// The slider's channel is remembered between pickers, as After Effects does.
|
||||
let chan_id = egui::Id::new("color-picker-channel");
|
||||
let mut chan = ui.data(|d| d.get_temp::<usize>(chan_id)).and_then(|i| CHANS.get(i).map(|c| c.0)).unwrap_or(Chan::H);
|
||||
let before = pick;
|
||||
let mut out = Outcome::default();
|
||||
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
// The square and slider, with OK and Cancel under the square's left edge.
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
square(ui, chan, &mut pick);
|
||||
slider(ui, chan, &mut pick);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
if ui.add_sized(vec2(72.0, 20.0), egui::Button::new(tr("OK"))).clicked() {
|
||||
out.close = Some(true);
|
||||
}
|
||||
if ui.add_sized(vec2(72.0, 20.0), egui::Button::new(tr("Cancel"))).clicked() {
|
||||
out.close = Some(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 4.0;
|
||||
if swatches(ui, pick.rgb, original) {
|
||||
pick = Pick { hsv: keep_hue(rgb_to_hsv(original), pick.hsv), rgb: original };
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let mut field = |ui: &mut Ui, ch: Chan, name: &str, scale: f32, suffix: &str| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let radio = ui.radio(chan == ch, format!("{name}:"));
|
||||
// Line the fields up whatever each label's width.
|
||||
ui.add_space((40.0 - radio.rect.width()).max(0.0));
|
||||
if radio.clicked() {
|
||||
chan = ch;
|
||||
}
|
||||
// Rounded as the hex code rounds, and 360° shows as 0°.
|
||||
let mut v = (pick.get(ch) * scale).round();
|
||||
if ch == Chan::H && v >= 360.0 {
|
||||
v = 0.0;
|
||||
}
|
||||
let drag = egui::DragValue::new(&mut v).range(0.0..=scale).fixed_decimals(0).suffix(suffix).speed(scale / 255.0);
|
||||
if ui.add_sized(vec2(56.0, 18.0), drag).labelled_by(radio.id).changed() {
|
||||
pick = pick.set(ch, v.round() / scale);
|
||||
}
|
||||
});
|
||||
};
|
||||
field(ui, Chan::H, "H", 360.0, "°");
|
||||
field(ui, Chan::S, "S", 100.0, "%");
|
||||
field(ui, Chan::V, "B", 100.0, "%");
|
||||
ui.add_space(4.0);
|
||||
field(ui, Chan::R, "R", 255.0, "");
|
||||
field(ui, Chan::G, "G", 255.0, "");
|
||||
field(ui, Chan::B, "B", 255.0, "");
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let mut rgb = pick.rgb;
|
||||
if hex_field(ui, id.with("hex"), &mut rgb) {
|
||||
pick = Pick { hsv: keep_hue(rgb_to_hsv(rgb), pick.hsv), rgb };
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if let Some(i) = CHANS.iter().position(|(c, _)| *c == chan) {
|
||||
ui.data_mut(|d| d.insert_temp(chan_id, i));
|
||||
}
|
||||
if pick != before && pick.rgb != *c {
|
||||
*c = pick.rgb;
|
||||
out.changed = true;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(state_id, (pick.hsv, pick.rgb)));
|
||||
out
|
||||
}
|
||||
|
||||
/// The channels in the order of their buttons.
|
||||
const CHANS: [(Chan, &str); 6] = [(Chan::H, "H"), (Chan::S, "S"), (Chan::V, "B"), (Chan::R, "R"), (Chan::G, "G"), (Chan::B, "B")];
|
||||
|
||||
/// Drag or click on the square to set its two channels.
|
||||
fn square(ui: &mut Ui, chan: Chan, pick: &mut Pick) {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(SQUARE, SQUARE), Sense::click_and_drag());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color field"));
|
||||
let (x, y) = chan.square();
|
||||
if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) {
|
||||
*pick = pick.at(chan, (p.x - rect.min.x) / rect.width(), 1.0 - (p.y - rect.min.y) / rect.height());
|
||||
}
|
||||
// Hue changes unevenly across a row, so the HSB squares get a finer grid.
|
||||
let n: u32 = if chan.is_hsv() { 24 } else { 8 };
|
||||
let row = n + 1;
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for yi in 0..=n {
|
||||
for xi in 0..=n {
|
||||
let (u, v) = (xi as f32 / n as f32, yi as f32 / n as f32);
|
||||
mesh.colored_vertex(rect.lerp_inside(vec2(u, v)), srgb32(pick.at(chan, u, 1.0 - v).rgb));
|
||||
if xi < n && yi < n {
|
||||
let k = yi * row + xi;
|
||||
mesh.add_triangle(k, k + 1, k + row);
|
||||
mesh.add_triangle(k + 1, k + row + 1, k + row);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.painter().add(mesh);
|
||||
let at = rect.lerp_inside(vec2(pick.get(x), 1.0 - pick.get(y)));
|
||||
let light = pick.rgb[0] * 0.3 + pick.rgb[1] * 0.59 + pick.rgb[2] * 0.11 > 0.6;
|
||||
ui.painter().circle_stroke(at, 5.0, Stroke::new(1.5, if light { Color32::BLACK } else { Color32::WHITE }));
|
||||
}
|
||||
|
||||
/// Drag or click on the slider to set `chan`; arrows on both sides mark its value.
|
||||
fn slider(ui: &mut Ui, chan: Chan, pick: &mut Pick) {
|
||||
let (outer, resp) = ui.allocate_exact_size(vec2(SLIDER_W + 12.0, SQUARE), Sense::click_and_drag());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color slider"));
|
||||
let bar = Rect::from_center_size(outer.center(), vec2(SLIDER_W, SQUARE));
|
||||
if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) {
|
||||
*pick = pick.set(chan, 1.0 - (p.y - bar.min.y) / bar.height());
|
||||
}
|
||||
let n: u32 = if chan == Chan::H { 36 } else { 16 };
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for i in 0..=n {
|
||||
let t = i as f32 / n as f32;
|
||||
// The hue slider shows pure hues; the others vary their channel in the current colour.
|
||||
let col = if chan == Chan::H { hsv_to_rgb([1.0 - t, 1.0, 1.0]) } else { pick.set(chan, 1.0 - t).rgb };
|
||||
let y = bar.min.y + t * bar.height();
|
||||
mesh.colored_vertex(pos2(bar.min.x, y), srgb32(col));
|
||||
mesh.colored_vertex(pos2(bar.max.x, y), srgb32(col));
|
||||
if i < n {
|
||||
let k = 2 * i;
|
||||
mesh.add_triangle(k, k + 1, k + 2);
|
||||
mesh.add_triangle(k + 1, k + 3, k + 2);
|
||||
}
|
||||
}
|
||||
ui.painter().add(mesh);
|
||||
let y = bar.min.y + (1.0 - pick.get(chan)) * bar.height();
|
||||
let fg = ui.visuals().strong_text_color();
|
||||
let stroke = Stroke::new(1.2, fg);
|
||||
for (edge, dir) in [(outer.min.x + 1.0, 1.0), (outer.max.x - 1.0, -1.0)] {
|
||||
let tip = pos2(edge + dir * 4.0, y);
|
||||
ui.painter().line_segment([pos2(edge, y - 4.0), tip], stroke);
|
||||
ui.painter().line_segment([pos2(edge, y + 4.0), tip], stroke);
|
||||
}
|
||||
}
|
||||
|
||||
/// The new colour over the original; returns true when the original is clicked (to go back to it).
|
||||
fn swatches(ui: &mut Ui, new: [f32; 3], original: [f32; 3]) -> bool {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover());
|
||||
let (top, bottom) = rect.split_top_bottom_at_fraction(0.5);
|
||||
let new_resp = ui.interact(top, ui.id().with("new-swatch"), Sense::hover());
|
||||
new_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "New color"));
|
||||
let old = ui.interact(bottom, ui.id().with("original-swatch"), Sense::click()).on_hover_text(tr("Original color: click to go back to it"));
|
||||
old.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Original color"));
|
||||
ui.painter().rect_filled(top, 0.0, srgb32(new));
|
||||
ui.painter().rect_filled(bottom, 0.0, srgb32(original));
|
||||
ui.painter().rect_stroke(rect, 0.0, ui.visuals().widgets.inactive.bg_stroke, StrokeKind::Outside);
|
||||
old.clicked()
|
||||
}
|
||||
|
||||
/// `hsv` with `old`'s hue where `hsv` has none (a grey or black), and `old`'s saturation where it
|
||||
/// has neither (black).
|
||||
fn keep_hue(mut hsv: [f32; 3], old: [f32; 3]) -> [f32; 3] {
|
||||
if hsv[1] == 0.0 || hsv[2] == 0.0 {
|
||||
hsv[0] = old[0];
|
||||
}
|
||||
if hsv[2] == 0.0 {
|
||||
hsv[1] = old[1];
|
||||
}
|
||||
hsv
|
||||
}
|
||||
|
||||
/// The hex field. While it has focus it keeps what was typed (`id` holds the text); otherwise it
|
||||
/// shows the colour. Focusing it selects the code, so typing replaces it; a valid code applies as
|
||||
/// it is typed.
|
||||
fn hex_field(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3]) -> bool {
|
||||
let mut text = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| srgb_hex(*c).trim_start_matches('#').to_string());
|
||||
let mut changed = false;
|
||||
let label = ui.label("#");
|
||||
let resp = ui
|
||||
.add(egui::TextEdit::singleline(&mut text).id(id.with("edit")).desired_width(62.0).char_limit(7).font(egui::TextStyle::Monospace))
|
||||
.labelled_by(label.id);
|
||||
if resp.gained_focus() {
|
||||
select_all(ui.ctx(), resp.id, &text);
|
||||
}
|
||||
if resp.changed()
|
||||
&& let Some(rgb) = hex_rgb(&text)
|
||||
{
|
||||
let v = rgb.map(|b| b as f32 / 255.0);
|
||||
if v != *c {
|
||||
*c = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(id, text.clone()));
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// An sRGB colour (components 0–1) as an egui colour.
|
||||
pub fn srgb32(c: [f32; 3]) -> Color32 {
|
||||
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
Color32::from_rgb(b(c[0]), b(c[1]), b(c[2]))
|
||||
}
|
||||
|
||||
/// An sRGB colour (components 0–1) as `#RRGGBB`.
|
||||
pub fn srgb_hex(c: [f32; 3]) -> String {
|
||||
let col = srgb32(c);
|
||||
format!("#{:02X}{:02X}{:02X}", col.r(), col.g(), col.b())
|
||||
}
|
||||
|
||||
/// Parse a hex colour: `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB` (any case, surrounding space ignored).
|
||||
pub fn hex_rgb(s: &str) -> Option<[u8; 3]> {
|
||||
let h = s.trim().trim_start_matches('#');
|
||||
if !h.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let digit = |i: usize| h.get(i..i + 1).and_then(|d| u8::from_str_radix(d, 16).ok());
|
||||
let pair = |i: usize| h.get(i..i + 2).and_then(|d| u8::from_str_radix(d, 16).ok());
|
||||
match h.len() {
|
||||
3 => Some([digit(0)? * 17, digit(1)? * 17, digit(2)? * 17]),
|
||||
6 => Some([pair(0)?, pair(2)?, pair(4)?]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// sRGB (components 0–1) to hue, saturation and value (0–1).
|
||||
pub fn rgb_to_hsv([r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||
let (r, g, b) = (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0));
|
||||
let max = r.max(g).max(b);
|
||||
let d = max - r.min(g).min(b);
|
||||
if d <= 0.0 {
|
||||
return [0.0, 0.0, max];
|
||||
}
|
||||
let h = if max == r {
|
||||
((g - b) / d).rem_euclid(6.0)
|
||||
} else if max == g {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
};
|
||||
[h / 6.0, d / max, max]
|
||||
}
|
||||
|
||||
/// Hue, saturation and value (0–1) to sRGB (components 0–1).
|
||||
pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
|
||||
let h = h.clamp(0.0, 1.0) * 6.0;
|
||||
let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
|
||||
let f = |n: f32| {
|
||||
let k = (n + h) % 6.0;
|
||||
v - v * s * k.min(4.0 - k).clamp(0.0, 1.0)
|
||||
};
|
||||
[f(5.0), f(3.0), f(1.0)]
|
||||
}
|
||||
@@ -501,8 +501,27 @@ impl DockNode {
|
||||
},
|
||||
}
|
||||
}
|
||||
if !add(self, p, near) && !add(self, p, PanelKind::EffectsPresets) {
|
||||
add(self, p, PanelKind::Project);
|
||||
fn add_first(n: &mut DockNode, p: PanelKind) -> bool {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => add_first(a, p) || add_first(b, p),
|
||||
DockNode::Tabs { panels, active } if !panels.is_empty() => {
|
||||
panels.push(p);
|
||||
*active = panels.len() - 1;
|
||||
true
|
||||
}
|
||||
DockNode::Stack { entries } if !entries.is_empty() => {
|
||||
entries.push(StackEntry { panel: p, open: true, height: None });
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
if self.panel_count() == 0 {
|
||||
// Every panel was closed: there is no anchor, so the panel becomes the whole dock.
|
||||
*self = tabs(&[p], 0);
|
||||
} else if !add(self, p, near) && !add(self, p, PanelKind::EffectsPresets) && !add(self, p, PanelKind::Project) {
|
||||
// None of the anchors is docked (e.g. Minimal with Timeline closed): use the first group.
|
||||
add_first(self, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1181,6 +1200,24 @@ pub fn placeholder(ui: &mut egui::Ui, rect: Rect, t: &Tokens, text: &str) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_near_in_an_empty_dock_makes_the_panel_the_dock() {
|
||||
for p in [PanelKind::Composition, PanelKind::Timeline, PanelKind::Project] {
|
||||
let mut d = tabs(&[], 0);
|
||||
d.open_near(p, PanelKind::EffectsPresets);
|
||||
assert!(d.is_visible(p));
|
||||
assert_eq!(d.panel_count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_near_without_any_anchor_uses_the_first_group() {
|
||||
let mut d = tabs(&[PanelKind::Composition], 0);
|
||||
d.open_near(PanelKind::Timeline, PanelKind::EffectsPresets);
|
||||
assert!(d.is_visible(PanelKind::Timeline));
|
||||
assert_eq!(d.panel_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_panels_toggle_open_close() {
|
||||
use PanelKind::*;
|
||||
@@ -1419,6 +1456,27 @@ mod tests {
|
||||
assert_eq!(PanelKind::from_name("effects & presets"), Some(PanelKind::EffectsPresets));
|
||||
}
|
||||
|
||||
/// Ported from #630 (Ni-zav): the reporter's reproduction on the Minimal workspace.
|
||||
#[test]
|
||||
fn window_panels_reopen_after_last_panel_is_closed() {
|
||||
use PanelKind::*;
|
||||
for reopen in [Composition, Timeline, Project] {
|
||||
let mut d = workspace("Minimal");
|
||||
d.close(Composition);
|
||||
d.close(Timeline);
|
||||
assert_eq!(d.panel_count(), 0);
|
||||
d.open_near(reopen, EffectsPresets);
|
||||
assert_eq!(d.panel_count(), 1);
|
||||
assert!(d.is_visible(reopen), "{reopen:?} must be visible");
|
||||
}
|
||||
let mut d = tabs(&[Audio], 0);
|
||||
d.open_near(History, Project);
|
||||
assert_eq!(d, tabs(&[Audio, History], 1));
|
||||
let mut d = workspace("Minimal");
|
||||
d.open_near(EffectsPresets, Timeline);
|
||||
assert_eq!(d.path_of(EffectsPresets), d.path_of(Timeline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_earlier_inactive_tab_keeps_the_shown_panel() {
|
||||
use PanelKind::*;
|
||||
|
||||
@@ -364,7 +364,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
|
||||
}
|
||||
}
|
||||
let mut c = tp.color;
|
||||
if color_popup(ui, picker_id(key), sr.left_bottom(), &mut c) {
|
||||
if widgets::color_popup(ui, picker_id(key), sr.left_bottom(), &mut c) {
|
||||
tool_options(app, json!({key: c}));
|
||||
paint(app, key, json!(c));
|
||||
}
|
||||
@@ -445,12 +445,6 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t
|
||||
}
|
||||
}
|
||||
|
||||
/// sRGB components (0–1) as a colour.
|
||||
fn srgb32(c: [f32; 3]) -> Color32 {
|
||||
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
Color32::from_rgb(b(c[0]), b(c[1]), b(c[2]))
|
||||
}
|
||||
|
||||
/// A Fill or Stroke swatch: its colour, a gradient (white to black), or None's red slash.
|
||||
fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: &Tokens) {
|
||||
let clip = p.with_clip_rect(r);
|
||||
@@ -460,7 +454,7 @@ fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t:
|
||||
clip.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.5, Color32::from_rgb(0xe0, 0x30, 0x30)));
|
||||
}
|
||||
PaintKind::Solid => {
|
||||
clip.rect_filled(r, 2.0, srgb32(paint.color));
|
||||
clip.rect_filled(r, 2.0, widgets::srgb32(paint.color));
|
||||
}
|
||||
PaintKind::Linear => {
|
||||
let mut m = egui::Mesh::default();
|
||||
@@ -555,28 +549,6 @@ fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small colour picker popup bound to an RGB value.
|
||||
pub fn color_popup(ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool {
|
||||
let open_id = id.with("open");
|
||||
if !ui.data(|d| d.get_temp::<bool>(open_id).unwrap_or(false)) {
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
let mut col = srgb32(*c);
|
||||
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
|
||||
*c = [col.r() as f32 / 255.0, col.g() as f32 / 255.0, col.b() as f32 / 255.0];
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if widgets::pressed_outside(ui.ctx(), &area.response) || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
ui.data_mut(|d| d.insert_temp(open_id, false));
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The ArtCraft mark as supplied in `docs/brand` (the README's and getartcraft.com's logo; used
|
||||
/// unmodified, see `docs/brand/LICENSE-brand.txt`).
|
||||
static ARTCRAFT_MARK: &[u8] = include_bytes!("../../../docs/brand/artcraft-mark.png");
|
||||
|
||||
@@ -1927,9 +1927,11 @@ mod tests {
|
||||
/// The interface text that has moved into this catalog. Add a panel here when you convert it;
|
||||
/// the tests below then keep it translated.
|
||||
const CONVERTED: &[(&str, &str)] = &[
|
||||
("color_picker.rs", include_str!("../color_picker.rs")),
|
||||
("credits.rs", include_str!("../credits.rs")),
|
||||
("header.rs", include_str!("../header.rs")),
|
||||
("menus.rs", include_str!("../menus.rs")),
|
||||
("widgets.rs", include_str!("../widgets.rs")),
|
||||
("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")),
|
||||
("panels/camera_tracker_ui.rs", include_str!("../panels/camera_tracker_ui.rs")),
|
||||
("panels/comp_settings.rs", include_str!("../panels/comp_settings.rs")),
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
pub mod audio;
|
||||
pub mod automation;
|
||||
pub mod bench;
|
||||
pub mod color_picker;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dock;
|
||||
|
||||
@@ -1391,7 +1391,16 @@ pub fn shortcut_text(s: &str) -> String {
|
||||
pub fn parse_shortcut(s: &str) -> Option<(egui::Modifiers, egui::Key)> {
|
||||
let mut m = egui::Modifiers::NONE;
|
||||
let mut key = None;
|
||||
let parts: Vec<&str> = if s == "+" { vec!["+"] } else { s.split('+').collect() };
|
||||
// The plus key itself is written `+` or `Cmd++`.
|
||||
let (head, plus) = match s.strip_suffix("++") {
|
||||
Some(h) => (h, true),
|
||||
None if s == "+" => ("", true),
|
||||
None => (s, false),
|
||||
};
|
||||
let mut parts: Vec<&str> = if head.is_empty() { vec![] } else { head.split('+').collect() };
|
||||
if plus {
|
||||
parts.push("+");
|
||||
}
|
||||
for p in parts {
|
||||
match p {
|
||||
"Cmd" => {
|
||||
|
||||
@@ -215,8 +215,22 @@ pub fn viewer_hook(
|
||||
return;
|
||||
}
|
||||
// Interaction over the image.
|
||||
let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), Sense::click_and_drag());
|
||||
// While Space (Hand tool) is held the overlay only hovers, so the viewer underneath gets the pan drag.
|
||||
let sense = if super::space_hand(&ctx) { Sense::hover() } else { Sense::click_and_drag() };
|
||||
let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), sense);
|
||||
app.auto.add("viewer.cameraTracker", map.area, "3D Camera Tracker points");
|
||||
// The overlay is on top of the viewer, so a middle-button drag lands here: pan the viewer like the Hand tool.
|
||||
if resp.dragged_by(egui::PointerButton::Middle) {
|
||||
let pid = egui::Id::new("viewer-camera-tracker-pan");
|
||||
if resp.drag_started() {
|
||||
let start_pan = app.ui.viewer.pan;
|
||||
ui.data_mut(|d| d.insert_temp(pid, start_pan));
|
||||
}
|
||||
if let (Some(start_pan), Some(d)) = (ui.data(|m| m.get_temp::<[f32; 2]>(pid)), resp.total_drag_delta()) {
|
||||
app.ui.viewer.pan = [start_pan[0] + d.x, start_pan[1] + d.y];
|
||||
}
|
||||
return;
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tr("Color"));
|
||||
ui.color_edit_button_rgb(&mut d.color);
|
||||
crate::widgets::srgb_color_button(ui, &mut d.color);
|
||||
ui.end_row();
|
||||
ui.label(tr("Intensity"));
|
||||
ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %")));
|
||||
|
||||
@@ -353,7 +353,7 @@ fn prop_row(
|
||||
}
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name);
|
||||
let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
|
||||
if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) {
|
||||
if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) {
|
||||
set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,11 +344,10 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt
|
||||
}
|
||||
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);
|
||||
let mut rgb = [c[0], c[1], c[2]];
|
||||
let r = crate::widgets::srgb_color_button(ui, &mut rgb);
|
||||
if r.changed() {
|
||||
let u = rgba.to_rgba_unmultiplied();
|
||||
out = Some(json!([u[0], u[1], u[2], u[3]]));
|
||||
out = Some(json!([rgb[0], rgb[1], rgb[2], c[3]]));
|
||||
}
|
||||
r.rect
|
||||
}
|
||||
|
||||
@@ -581,7 +581,7 @@ fn colorama_wheel(
|
||||
t.text_dim,
|
||||
);
|
||||
let mut rgb = [col[0], col[1], col[2]];
|
||||
if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) {
|
||||
if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) {
|
||||
let mut st = pal.stops.clone();
|
||||
st[sel].1 = [rgb[0], rgb[1], rgb[2], col[3]];
|
||||
new_stops = Some(st);
|
||||
|
||||
@@ -111,9 +111,9 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
|
||||
(r.changed() && y != *x).then(|| json!(y))
|
||||
}
|
||||
(KV::Color(c), _) => {
|
||||
let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32);
|
||||
let changed = egui::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::color_picker::Alpha::Opaque).changed();
|
||||
changed.then(|| json!([rgba.r(), rgba.g(), rgba.b()]))
|
||||
let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
|
||||
let changed = crate::widgets::srgb_color_button(ui, &mut rgb).changed();
|
||||
changed.then(|| json!(rgb))
|
||||
}
|
||||
(KV::Gradient(_), _) => {
|
||||
ui.label(egui::RichText::new(tr("Edit in the Timeline")).weak());
|
||||
|
||||
@@ -78,6 +78,32 @@ pub enum DragPayload {
|
||||
Files(Vec<String>),
|
||||
}
|
||||
|
||||
/// Layers carried by a Project item drag. Dragging a selected item uses the
|
||||
/// complete selection, while dragging an unselected item uses only that item.
|
||||
/// Folders and data files are not composition layers.
|
||||
pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec<u64> {
|
||||
use effectcraft_engine::project::{FootageKind, ItemId, ItemKind};
|
||||
|
||||
let selected = &app.session.state.project_selection;
|
||||
let candidates: Vec<u64> = if selected.iter().any(|id| id.0 == dragged) {
|
||||
selected.iter().map(|id| id.0).collect()
|
||||
} else {
|
||||
// One unselected item goes through as is, so a folder or data file still gets the
|
||||
// engine's error instead of a silent no-op.
|
||||
return vec![dragged];
|
||||
};
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
app.session.project.item(ItemId(*id)).is_some_and(|item| match &item.kind {
|
||||
ItemKind::Comp(_) | ItemKind::Solid(_) => true,
|
||||
ItemKind::Footage(f) => f.kind != FootageKind::Data,
|
||||
ItemKind::Folder => false,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The current time formatted per project settings (timecode with `;` for drop-frame, frames
|
||||
/// or Feet + Frames).
|
||||
pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String {
|
||||
|
||||
@@ -102,11 +102,11 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add("paint.color", fg, "Foreground color");
|
||||
app.auto.add("paint.background", bg, "Background color");
|
||||
let mut fc = rgb(o.color);
|
||||
if crate::header::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) {
|
||||
if crate::widgets::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) {
|
||||
set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]}));
|
||||
}
|
||||
let mut bc = rgb(o.background);
|
||||
if crate::header::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) {
|
||||
if crate::widgets::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) {
|
||||
set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]}));
|
||||
}
|
||||
let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0));
|
||||
|
||||
@@ -395,7 +395,7 @@ fn text_section(
|
||||
}
|
||||
app.auto.add(&format!("properties.text.{key}"), sw, label);
|
||||
let mut rgb = [c[0], c[1], c[2]];
|
||||
if crate::header::color_popup(ui, pop, sw.left_bottom(), &mut rgb) {
|
||||
if crate::widgets::color_popup(ui, pop, sw.left_bottom(), &mut rgb) {
|
||||
set(actions, json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("props-text-{key}")}));
|
||||
}
|
||||
p.text(pos2(sw.max.x + 8.0, sw.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text);
|
||||
|
||||
@@ -38,6 +38,12 @@ fn hex(c: [u8; 3]) -> String {
|
||||
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
/// sRGB components (0–1) as bytes.
|
||||
fn srgb_bytes(c: [f32; 3]) -> [u8; 3] {
|
||||
let col = crate::widgets::srgb32(c);
|
||||
[col.r(), col.g(), col.b()]
|
||||
}
|
||||
|
||||
fn reg(app: &mut EffectcraftApp, id: &str, r: &egui::Response, label: &str) {
|
||||
app.auto.add(id, r.rect, label);
|
||||
}
|
||||
@@ -178,8 +184,9 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
}
|
||||
Kind::Color => {
|
||||
let c = crate::panels::viewer::hex_rgb(v.as_str().unwrap_or("")).unwrap_or([128, 128, 128]);
|
||||
let mut rgb = c;
|
||||
let r = ui.color_edit_button_srgb(&mut rgb);
|
||||
let mut f = c.map(|v| v as f32 / 255.0);
|
||||
let r = crate::widgets::srgb_color_button(ui, &mut f);
|
||||
let rgb = if r.changed() { srgb_bytes(f) } else { c };
|
||||
ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim));
|
||||
if rgb != c {
|
||||
acts.push(Act::Set(key.into(), json!(hex(rgb))));
|
||||
@@ -559,8 +566,9 @@ fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mu
|
||||
egui::Grid::new("settings-labels").num_columns(4).spacing([10.0, 4.0]).show(ui, |ui| {
|
||||
for (i, l) in labels.iter().enumerate() {
|
||||
let c = crate::panels::viewer::hex_rgb(l["color"].as_str().unwrap_or("")).unwrap_or([128, 128, 128]);
|
||||
let mut rgb = c;
|
||||
let r = ui.color_edit_button_srgb(&mut rgb);
|
||||
let mut f = c.map(|v| v as f32 / 255.0);
|
||||
let r = crate::widgets::srgb_color_button(ui, &mut f);
|
||||
let rgb = if r.changed() { srgb_bytes(f) } else { c };
|
||||
app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour");
|
||||
if rgb != c {
|
||||
acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb))));
|
||||
|
||||
@@ -204,6 +204,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
// The shortcut field must not also type the key it just recorded (Alt+R came out as `rAlt+R`).
|
||||
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Text(_) | egui::Event::Key { .. })));
|
||||
if let Some((k, m)) = got {
|
||||
st.recording = false;
|
||||
if k != egui::Key::Escape
|
||||
@@ -586,6 +588,41 @@ mod tests {
|
||||
}
|
||||
assert_eq!(split("Cmd+Shift+K"), ([false, true, false, true], "K".into()));
|
||||
assert_eq!(split("Cmd++"), ([false, true, false, false], "+".into()));
|
||||
// The plus key, with and without modifiers, parses back (typed by hand or recorded).
|
||||
for txt in ["+", "Cmd++", "Alt++"] {
|
||||
assert_eq!(crate::menus::parse_shortcut(txt).map(|(_, k)| k), Some(egui::Key::Plus), "{txt}");
|
||||
}
|
||||
assert_eq!(crate::menus::parse_shortcut("Alt+R").map(|(m, k)| (m.alt, k)), Some((true, egui::Key::R)));
|
||||
assert_eq!(split("F9"), ([false; 4], "F9".into()));
|
||||
}
|
||||
|
||||
fn frame(app: &mut EffectcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||||
app.auto.begin_frame();
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::panels::dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_a_shortcut_does_not_also_type_the_key_into_the_field() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app));
|
||||
open(&mut app);
|
||||
app.dialog_state.shortcuts.selected = app.session.shortcuts().bindables.first().map(|b| b.key.clone());
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
}
|
||||
let [x, y, w, h] = app.auto.find("shortcuts.record").expect("shortcut field").rect;
|
||||
let at = pos2(x + w / 2.0, y + h / 2.0);
|
||||
let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(true)]);
|
||||
frame(&mut app, &ctx, vec![click(false)]);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert!(app.dialog_state.shortcuts.recording);
|
||||
// Alt+R arrives as the key and as the typed text `r`.
|
||||
let key = egui::Event::Key { key: egui::Key::R, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::ALT };
|
||||
frame(&mut app, &ctx, vec![key, egui::Event::Text("r".into())]);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(app.dialog_state.shortcuts.pending, "Alt+R");
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
app.auto.add(&format!("character.{key}"), fs, label);
|
||||
let mut rgb = [c[0], c[1], c[2]];
|
||||
if crate::header::color_popup(ui, pop, fs.left_bottom(), &mut rgb) {
|
||||
if crate::widgets::color_popup(ui, pop, fs.left_bottom(), &mut rgb) {
|
||||
actions.push(json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("char-{key}")}));
|
||||
}
|
||||
p.text(pos2(fs.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
|
||||
|
||||
@@ -2959,9 +2959,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
rp.galley(tag.min, g, Color32::WHITE);
|
||||
}
|
||||
if released {
|
||||
let (index, time) = (json!(index), json!(start.map(Tick::seconds)));
|
||||
let time = json!(start.map(Tick::seconds));
|
||||
match payload.as_ref() {
|
||||
crate::panels::DragPayload::Item(id) => actions.push(("layer.addItem".into(), json!({"item": id, "index": index, "time": time}))),
|
||||
crate::panels::DragPayload::Item(id) => {
|
||||
// Insert each selected item at the drop point, in selection order.
|
||||
for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() {
|
||||
actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()})));
|
||||
}
|
||||
}
|
||||
crate::panels::DragPayload::Files(paths) => {
|
||||
actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time})))
|
||||
}
|
||||
@@ -3558,7 +3563,7 @@ fn value_editor(
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
|
||||
if crate::header::color_popup(ui, pop, r.left_bottom(), &mut rgb) {
|
||||
if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) {
|
||||
set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,10 +522,12 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo
|
||||
let cam = (lv.view != View3D::ActiveCamera && comp.has_3d())
|
||||
.then(|| app.session.state.views3d.get(&lv.comp).cloned().unwrap_or_default().cam(lv.view, comp.width as f64, comp.height as f64).state());
|
||||
let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session);
|
||||
// The display conversion's inputs, keyed as in the main viewer's texture caches.
|
||||
let dc_key = format!("{cam:?}{}", super::viewer_tools::display_key(app, dc.is_some()));
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(app.session.revision, lv.comp.0, t.0, scale.to_bits(), format!("{cam:?}{}", dc.is_some())).hash(&mut h);
|
||||
(app.session.revision, lv.comp.0, t.0, scale.to_bits(), dc_key).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let id = egui::Id::new("viewer-locked");
|
||||
@@ -1044,7 +1046,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
let gid = egui::Id::new("viewer-gesture");
|
||||
if let Some(hp) = resp.hover_pos() {
|
||||
// An overlay on top of the viewer (e.g. the camera tracker points) takes the hover, but the pointer is still over the viewer: keep wheel zoom working.
|
||||
let hover_pos = resp.hover_pos().or_else(|| if resp.contains_pointer() { ui.input(|i| i.pointer.hover_pos()) } else { None });
|
||||
if let Some(hp) = hover_pos {
|
||||
app.pointer_comp = Some({
|
||||
let c = map.to_comp(hp);
|
||||
[c[0] as f32, c[1] as f32]
|
||||
@@ -2147,14 +2151,7 @@ pub(crate) fn minify_factor(smooth: bool, pixels: f64, screen: f64) -> usize {
|
||||
if smooth && k >= 1.0 { k.min(256.0) as usize } else { 1 }
|
||||
}
|
||||
|
||||
pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> {
|
||||
let h = s.trim().trim_start_matches('#');
|
||||
if h.len() != 6 || !h.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
|
||||
Some([p(0)?, p(2)?, p(4)?])
|
||||
}
|
||||
pub(crate) use crate::widgets::hex_rgb;
|
||||
|
||||
/// The Extended Viewer's render region for this frame (see [`ViewerState::extended`]):
|
||||
/// Settings ▸ 3D ▸ Extended Viewer on, the comp has 3D layers and the view is a custom 3D view
|
||||
|
||||
@@ -23,32 +23,37 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain
|
||||
let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(map.area)) else { return };
|
||||
let stroke = Stroke::new(2.0, app.tokens.accent);
|
||||
let at = map.to_comp(ptr);
|
||||
let action: Option<(&str, Value)> = match payload.as_ref() {
|
||||
DragPayload::Effect(effect) => layer_at(ectx, at).map(|l| {
|
||||
if let Some((_, q, _)) = layer_quad(ectx, l) {
|
||||
painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke));
|
||||
}
|
||||
("effect.apply", json!({"effect": effect, "layers": [l.id.0]}))
|
||||
}),
|
||||
let actions: Vec<(&str, Value)> = match payload.as_ref() {
|
||||
DragPayload::Effect(effect) => layer_at(ectx, at)
|
||||
.map(|l| {
|
||||
if let Some((_, q, _)) = layer_quad(ectx, l) {
|
||||
painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke));
|
||||
}
|
||||
("effect.apply", json!({"effect": effect, "layers": [l.id.0]}))
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
DragPayload::Item(item) => {
|
||||
// The layer's frame, centred on the pointer.
|
||||
if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) {
|
||||
painter.rect_stroke(Rect::from_center_size(ptr, vec2(w, h) * map.zoom), 0.0, Stroke::new(1.0, app.tokens.accent), StrokeKind::Middle);
|
||||
}
|
||||
Some(("layer.addItem", json!({"item": item, "position": at})))
|
||||
// New layers go above the current selection, so insert in reverse
|
||||
// to preserve the order of the dragged Project items.
|
||||
crate::panels::project_drop_items(app, *item).into_iter().rev().map(|item| ("layer.addItem", json!({"item": item, "position": at}))).collect()
|
||||
}
|
||||
DragPayload::Files(paths) => Some(("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))),
|
||||
DragPayload::Property { .. } => None,
|
||||
DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))],
|
||||
DragPayload::Property { .. } => vec![],
|
||||
};
|
||||
if action.as_ref().is_some_and(|(id, _)| *id != "effect.apply") {
|
||||
if actions.first().is_some_and(|(id, _)| *id != "effect.apply") {
|
||||
painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside);
|
||||
}
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
egui::DragAndDrop::clear_payload(&ctx);
|
||||
if let Some((id, params)) = action
|
||||
&& let Err(e) = crate::menus::invoke(app, &ctx, id, params)
|
||||
{
|
||||
app.ui.status = e;
|
||||
for (id, params) in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +349,14 @@ fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32,
|
||||
egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect())
|
||||
}
|
||||
|
||||
/// The display conversion's inputs, for the texture caches.
|
||||
pub(crate) fn display_key(app: &EffectcraftApp, has_conversion: bool) -> String {
|
||||
let opts = &app.session.state.viewer;
|
||||
let custom = if opts.simulation.profile == vw::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() };
|
||||
format!("{:?}{:?}{}{custom}", has_conversion, opts.simulation, app.session.prefs.previews.display_profile)
|
||||
+ &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management)
|
||||
}
|
||||
|
||||
/// Draw the frame into `comp_rect`: the rendered frame (covering the region of interest when one
|
||||
/// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the
|
||||
/// exposure. Fast Previews ▸ Wireframe draws layer outlines instead.
|
||||
@@ -378,10 +386,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
|
||||
return;
|
||||
}
|
||||
let dc = vw::DisplayColor::of(&app.session);
|
||||
// The display conversion's inputs, for the texture caches.
|
||||
let custom = if opts.simulation.profile == vw::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() };
|
||||
let dc_key = format!("{:?}{:?}{}{custom}", dc.is_some(), opts.simulation, app.session.prefs.previews.display_profile)
|
||||
+ &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management);
|
||||
let dc_key = display_key(app, dc.is_some());
|
||||
let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0 && dc.is_none();
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
if snap {
|
||||
|
||||
@@ -102,7 +102,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) {
|
||||
app.request_frame(cid, frame, scale);
|
||||
let key = app.frame_key(cid, frame, scale);
|
||||
if let Some((key, img)) = app.frames.get_for_viewer(&key, app.passive_tex.get(&id).map(|(k, _)| k)) {
|
||||
set_texture(app, &ctx, id, key, img);
|
||||
let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session);
|
||||
let dkey = display_hash(&super::viewer_tools::display_key(app, dc.is_some()));
|
||||
set_texture(app, &ctx, id, key, img, dc, dkey);
|
||||
}
|
||||
match app.passive_tex.get(&id).filter(|(k, _)| k.comp == cid.0) {
|
||||
Some((_, tex)) => {
|
||||
@@ -126,16 +128,38 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) {
|
||||
}
|
||||
|
||||
/// Keep the frame viewer `id` shows in its texture.
|
||||
fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: FrameKey, img: FrameImage) {
|
||||
fn set_texture(
|
||||
app: &mut EffectcraftApp,
|
||||
ctx: &egui::Context,
|
||||
id: u32,
|
||||
key: FrameKey,
|
||||
img: FrameImage,
|
||||
dc: Option<effectcraft_engine::viewer::DisplayColor>,
|
||||
dkey: u64,
|
||||
) {
|
||||
let dc_id = egui::Id::new(("passive-viewer-display", id));
|
||||
let same_display = ctx.data(|d| d.get_temp::<u64>(dc_id)) == Some(dkey);
|
||||
if let Some((k, _)) = app.passive_tex.get_mut(&id)
|
||||
&& *k == key
|
||||
&& same_display
|
||||
{
|
||||
// An undo or an edit elsewhere can reuse this content at a newer revision. Keep that
|
||||
// revision as the lower bound for subsequent intermediate edit frames.
|
||||
k.revision = key.revision;
|
||||
return;
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(dc_id, dkey));
|
||||
let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
|
||||
// The monitor conversion the active viewer applies (Use Display Color Management).
|
||||
let img = match (img, &dc) {
|
||||
(FrameImage::Cpu(img), Some(dc)) => {
|
||||
let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect();
|
||||
dc.apply(&mut px);
|
||||
let pixels = px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect();
|
||||
FrameImage::Cpu(Arc::new(egui::ColorImage::new(img.size, pixels)))
|
||||
}
|
||||
(img, _) => img,
|
||||
};
|
||||
let tex = match (img, app.passive_tex.remove(&id)) {
|
||||
(FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h))))
|
||||
if h.size() == crate::frames::fitted_size(img.size, crate::frames::max_texture_side(ctx)) =>
|
||||
@@ -165,6 +189,14 @@ fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: Fram
|
||||
app.passive_tex.insert(id, (key, tex));
|
||||
}
|
||||
|
||||
/// A hash of the display conversion's inputs (see `viewer_tools::display_key`).
|
||||
fn display_hash(key: &str) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
key.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Give a passive viewer's texture back (GPU textures are registered with the renderer).
|
||||
fn free(app: &EffectcraftApp, tex: Option<PassiveTexture>) {
|
||||
if let (Some(PassiveTexture::Gpu(id, _)), Some(rs)) = (tex, &app.wgpu) {
|
||||
|
||||
@@ -339,8 +339,9 @@ const SCRIPT_PROBES: &[(char, &str)] = &[
|
||||
/// Japanese-locale machine would be drawn with the Japanese face. The interface language is the
|
||||
/// better signal, so the probe for its script comes first — kana for Japanese, a Han character for
|
||||
/// the Chinese catalogs. Both scripts are registered, so file, layer and template names in the
|
||||
/// other one still render; then [`SCRIPT_PROBES`]. The web build has no system fonts and installs
|
||||
/// nothing.
|
||||
/// other one still render; then [`SCRIPT_PROBES`], then Hangul, since the Japanese and Chinese faces
|
||||
/// may lack it (Hiragino Sans on macOS has none). The web build has no system fonts
|
||||
/// and installs nothing.
|
||||
fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) -> Vec<String> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -354,13 +355,17 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str)
|
||||
// language's face draws them.
|
||||
const KANA: (char, &str) = ('あ', "japanese-system");
|
||||
const HAN: (char, &str) = ('文', "chinese-system");
|
||||
// Hangul is probed last: with no Korean font installed the lookup can land on a catch-all
|
||||
// face (Unifont) that also covers Arabic or Indic text but can't shape it, so the
|
||||
// script-specific faces must be registered ahead of it. A face already seen is skipped.
|
||||
const HANGUL: (char, &str) = ('한', "korean-system");
|
||||
// Han text follows the interface language, not the operating system's locale.
|
||||
fonts::set_cjk_locale(cjk_locale(language));
|
||||
let base = fonts::resolve("Inter", "Regular").face;
|
||||
let cjk = if is_chinese(language) { [HAN, KANA] } else { [KANA, HAN] };
|
||||
let mut names = Vec::new();
|
||||
let mut seen = Vec::new();
|
||||
for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES) {
|
||||
for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) {
|
||||
let id = fonts::fallback_for(probe, base);
|
||||
if seen.contains(&id) {
|
||||
continue;
|
||||
@@ -597,6 +602,24 @@ mod tests {
|
||||
install_script_fallbacks(&mut english, "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hangul_gets_a_face_when_one_is_installed() {
|
||||
use effectcraft_text::fonts;
|
||||
let mut fonts_def = FontDefinitions::default();
|
||||
install_script_fallbacks(&mut fonts_def, "en");
|
||||
let base = fonts::resolve("Inter", "Regular").face;
|
||||
let id = fonts::fallback_for('한', base);
|
||||
let face = fonts::face(id);
|
||||
if !face.has_char('한') {
|
||||
eprintln!("no Hangul font is installed: nothing to check here");
|
||||
return;
|
||||
}
|
||||
let Some(font) = face.font() else { return };
|
||||
let bytes = font.data().as_bytes();
|
||||
let covered = fonts_def.font_data.values().any(|d| d.font.as_ref() == bytes && d.index == face.info.index);
|
||||
assert!(covered, "a face covering Hangul must be registered with egui");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fallback_face_is_registered_at_most_once() {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
|
||||
@@ -192,17 +192,8 @@ pub fn swatch(ui: &mut Ui, rect: Rect, c: [f32; 4], id: egui::Id, t: &Tokens) ->
|
||||
resp
|
||||
}
|
||||
|
||||
/// A colour button with egui's picker for a colour stored as sRGB components (0–1), as the
|
||||
/// project stores colours (`color_edit_button_rgb` reads linear values and shows them lighter).
|
||||
/// The value is only rewritten when the user picks a colour.
|
||||
pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response {
|
||||
let mut u = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
let r = ui.color_edit_button_srgb(&mut u);
|
||||
if r.changed() {
|
||||
*c = u.map(|v| v as f32 / 255.0);
|
||||
}
|
||||
r
|
||||
}
|
||||
// The colour picker and its helpers (every colour input uses them).
|
||||
pub use crate::color_picker::{color_popup, hex_rgb, hsv_to_rgb, rgb_to_hsv, srgb_color_button, srgb_hex, srgb32};
|
||||
|
||||
/// A twirl-down triangle; returns clicked.
|
||||
pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> Response {
|
||||
|
||||
@@ -145,6 +145,60 @@ fn viewer_points_selection_and_effect_controls() {
|
||||
assert!(h.state().session.is_enabled("track.camera"));
|
||||
}
|
||||
|
||||
/// Drag with `button` from `from` to `to` in steps, then release.
|
||||
fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: egui::Pos2, to: egui::Pos2) {
|
||||
h.input_mut().events.push(egui::Event::PointerMoved(from));
|
||||
h.input_mut().events.push(egui::Event::PointerButton { pos: from, button, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
for i in 1..=8 {
|
||||
h.input_mut().events.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(egui::Event::PointerButton { pos: to, button, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// #505: with the track points shown, the wheel still zooms the viewer, and a middle-button drag
|
||||
/// and a Spacebar (Hand tool) drag over the points still pan it.
|
||||
#[test]
|
||||
fn viewer_zooms_and_pans_over_the_track_points() {
|
||||
let (a, ..) = app();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| a);
|
||||
h.state_mut().show_panel(PanelKind::Composition);
|
||||
h.run_steps(4);
|
||||
// Wheel over a point: zooms.
|
||||
let at = rect(&h, "viewer.cameraTracker.point.7").center();
|
||||
let zoom = h.state().ui.viewer.zoom;
|
||||
h.input_mut().events.push(egui::Event::PointerMoved(at));
|
||||
h.input_mut().events.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, 120.0),
|
||||
modifiers: Default::default(),
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.run_steps(20);
|
||||
assert_ne!(h.state().ui.viewer.zoom, zoom, "the wheel zooms over the track points");
|
||||
// Middle-button drag from a point: pans, and selects nothing.
|
||||
let at = rect(&h, "viewer.cameraTracker.point.7").center();
|
||||
let before = h.state().ui.viewer.pan;
|
||||
drag_with(&mut h, egui::PointerButton::Middle, at, at + egui::vec2(120.0, 60.0));
|
||||
let after = h.state().ui.viewer.pan;
|
||||
assert!((after[0] - before[0] - 120.0).abs() < 1.0 && (after[1] - before[1] - 60.0).abs() < 1.0, "{before:?} → {after:?}");
|
||||
assert!(h.state().session.state.camera_points.is_empty(), "a pan selects no points");
|
||||
// Spacebar held: a primary drag from a point pans instead of drawing a marquee.
|
||||
let at = rect(&h, "viewer.cameraTracker.point.7").center();
|
||||
let space = |h: &mut Harness<'_, EffectcraftApp>, pressed: bool| {
|
||||
h.input_mut().events.push(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Default::default() });
|
||||
h.step();
|
||||
};
|
||||
space(&mut h, true);
|
||||
drag_with(&mut h, egui::PointerButton::Primary, at, at + egui::vec2(80.0, 40.0));
|
||||
space(&mut h, false);
|
||||
let end = h.state().ui.viewer.pan;
|
||||
assert!((end[0] - after[0] - 80.0).abs() < 1.0 && (end[1] - after[1] - 40.0).abs() < 1.0, "{after:?} → {end:?}");
|
||||
assert!(h.state().session.state.camera_points.is_empty(), "a Spacebar pan selects no points");
|
||||
}
|
||||
|
||||
/// Step the UI until background frame renders have landed.
|
||||
fn settle(h: &mut Harness<'_, EffectcraftApp>) {
|
||||
for _ in 0..600 {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! The colour picker every colour input opens: its hex field sets an effect's colour (CC Toner),
|
||||
//! the dialogs' colour button stores sRGB (egui's own `color_edit_button_rgba` read the stored
|
||||
//! sRGB values as linear, so mid-grey `#808080` was stored as `#373737`), the square, slider and
|
||||
//! HSB / RGB fields set the colour, and Cancel / Escape / the original swatch restore it.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use effectcraft_ui_egui::widgets;
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, Pos2, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
const GREY: f32 = 128.0 / 255.0;
|
||||
|
||||
fn click<S>(h: &mut Harness<'_, S>, p: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// Click the open popup's Hex field and type `text` over the code it shows.
|
||||
fn type_hex<S>(h: &mut Harness<'_, S>, text: &str) {
|
||||
let at = h.get_by_role_and_label(Role::TextInput, "#").rect().center();
|
||||
click(h, at);
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND });
|
||||
}
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::Text(text.into()));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn close(c: [f32; 3], want: [f32; 3]) -> bool {
|
||||
c.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_code_sets_an_effect_colour() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Hex", "width": 320, "height": 180, "duration": 4})).unwrap();
|
||||
let layer = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#40b080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "CC Toner"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let uid = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().get("midtones").unwrap().uid;
|
||||
s.state.selected_layers = vec![LayerId(layer)];
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.show_panel(PanelKind::EffectControls);
|
||||
app.toggle_maximize(PanelKind::EffectControls);
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app);
|
||||
h.run_steps(4);
|
||||
let midtones = |h: &Harness<'_, EffectcraftApp>| {
|
||||
let c = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(uid).unwrap().value.as_color();
|
||||
[c[0], c[1], c[2]]
|
||||
};
|
||||
|
||||
let [x, y, w, ht] = h.state().auto.find(&format!("effectControls.prop.{uid}.value")).expect("the Midtones swatch").rect;
|
||||
click(&mut h, pos2(x + w / 2.0, y + ht / 2.0));
|
||||
type_hex(&mut h, "#FF8000");
|
||||
assert!(close(midtones(&h), [1.0, GREY, 0.0]), "{:?}", midtones(&h));
|
||||
// A partial code doesn't apply; the short form does.
|
||||
type_hex(&mut h, "#12");
|
||||
assert!(close(midtones(&h), [1.0, GREY, 0.0]), "{:?}", midtones(&h));
|
||||
type_hex(&mut h, "0f8");
|
||||
assert!(close(midtones(&h), [0.0, 1.0, 136.0 / 255.0]), "{:?}", midtones(&h));
|
||||
}
|
||||
|
||||
/// A colour button in a harness of its own, its picker open.
|
||||
fn picker(c: [f32; 3]) -> Harness<'static, [f32; 3]> {
|
||||
let mut h = Harness::builder().with_size(vec2(560.0, 400.0)).build_ui_state(
|
||||
|ui, c: &mut [f32; 3]| {
|
||||
widgets::srgb_color_button(ui, c);
|
||||
},
|
||||
c,
|
||||
);
|
||||
h.run_steps(2);
|
||||
let button = h.get_by_role(Role::ColorWell).rect().center();
|
||||
click(&mut h, button);
|
||||
h
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness<'_, [f32; 3]>, key: Key, modifiers: Modifiers) {
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn click_label(h: &mut Harness<'_, [f32; 3]>, label: &str) {
|
||||
let at = h.get_by_label(label).rect().center();
|
||||
click(h, at);
|
||||
}
|
||||
|
||||
/// Type `text` into the first number field labelled `label` ("R:", "H:" …; "B:" is Brightness
|
||||
/// before Blue, as in After Effects) and press Enter.
|
||||
fn type_field(h: &mut Harness<'_, [f32; 3]>, label: &str, text: &str) {
|
||||
let at = h.get_all_by_role_and_label(Role::SpinButton, label).next().expect("the field").rect().center();
|
||||
click(h, at);
|
||||
press(h, Key::A, Modifiers::COMMAND);
|
||||
h.input_mut().events.push(Event::Text(text.into()));
|
||||
h.step();
|
||||
press(h, Key::Enter, Modifiers::NONE);
|
||||
}
|
||||
|
||||
fn near(c: [f32; 3], want: [f32; 3]) -> bool {
|
||||
c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02)
|
||||
}
|
||||
|
||||
fn is_open(h: &Harness<'_, [f32; 3]>) -> bool {
|
||||
h.query_by_role_and_label(Role::TextInput, "#").is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dialog_colour_button_stores_srgb() {
|
||||
let mut h = picker([1.0, 1.0, 1.0]);
|
||||
type_hex(&mut h, "808080");
|
||||
assert!(close(*h.state(), [GREY; 3]), "{:?}", h.state());
|
||||
click_label(&mut h, "OK");
|
||||
assert!(!is_open(&h));
|
||||
assert!(close(*h.state(), [GREY; 3]));
|
||||
}
|
||||
|
||||
/// Changes apply as they are made; Cancel, Escape and the original swatch go back to the colour
|
||||
/// the picker opened with, OK keeps the new one.
|
||||
#[test]
|
||||
fn cancel_escape_and_the_original_swatch_restore_the_colour() {
|
||||
let red = [1.0, 0.0, 0.0];
|
||||
let mut h = picker(red);
|
||||
type_hex(&mut h, "00FF00");
|
||||
assert!(close(*h.state(), [0.0, 1.0, 0.0]));
|
||||
click_label(&mut h, "Cancel");
|
||||
assert!(!is_open(&h));
|
||||
assert!(close(*h.state(), red), "{:?}", h.state());
|
||||
|
||||
let button = h.get_by_role(Role::ColorWell).rect().center();
|
||||
click(&mut h, button);
|
||||
type_hex(&mut h, "0000FF");
|
||||
press(&mut h, Key::Escape, Modifiers::NONE);
|
||||
assert!(!is_open(&h));
|
||||
assert!(close(*h.state(), red), "{:?}", h.state());
|
||||
|
||||
click(&mut h, button);
|
||||
type_hex(&mut h, "0000FF");
|
||||
click_label(&mut h, "Original color");
|
||||
assert!(is_open(&h), "going back to the original keeps the picker open");
|
||||
assert!(close(*h.state(), red), "{:?}", h.state());
|
||||
type_hex(&mut h, "FFFF00");
|
||||
click_label(&mut h, "OK");
|
||||
assert!(close(*h.state(), [1.0, 1.0, 0.0]), "{:?}", h.state());
|
||||
}
|
||||
|
||||
/// With H on the slider the square is saturation × brightness; the hue is kept while the colour is
|
||||
/// black, where RGB has none. HSB and RGB values can be typed.
|
||||
#[test]
|
||||
fn the_hsb_square_slider_and_fields_set_the_colour() {
|
||||
let mut h = picker([1.0, 0.0, 0.0]);
|
||||
let square = h.get_by_label("Color field").rect();
|
||||
let slider = h.get_by_label("Color slider").rect();
|
||||
|
||||
click(&mut h, pos2(square.center().x, square.max.y - 1.0));
|
||||
assert!(near(*h.state(), [0.0; 3]), "the bottom edge is black: {:?}", h.state());
|
||||
click(&mut h, slider.center());
|
||||
assert!(near(*h.state(), [0.0; 3]), "a new hue leaves black black: {:?}", h.state());
|
||||
click(&mut h, pos2(square.max.x - 1.0, square.min.y + 1.0));
|
||||
assert!(near(*h.state(), [0.0, 1.0, 1.0]), "the hue chosen while black is kept: {:?}", h.state());
|
||||
|
||||
type_field(&mut h, "R:", "64");
|
||||
assert!((h.state()[0] - 64.0 / 255.0).abs() < 1e-4 && near(*h.state(), [64.0 / 255.0, 1.0, 1.0]), "{:?}", h.state());
|
||||
type_field(&mut h, "H:", "0");
|
||||
type_field(&mut h, "S:", "100");
|
||||
type_field(&mut h, "B:", "50");
|
||||
assert!(near(*h.state(), [0.5, 0.0, 0.0]), "{:?}", h.state());
|
||||
}
|
||||
|
||||
/// With R chosen the slider sets red and the square blue (across) and green (up), as in After
|
||||
/// Effects; the choice is remembered by the next picker.
|
||||
#[test]
|
||||
fn choosing_r_puts_red_on_the_slider_and_blue_green_on_the_square() {
|
||||
let mut h = picker([0.0, 0.0, 0.0]);
|
||||
let radio = h.get_by_role_and_label(Role::RadioButton, "R:").rect().center();
|
||||
click(&mut h, radio);
|
||||
let square = h.get_by_label("Color field").rect();
|
||||
let slider = h.get_by_label("Color slider").rect();
|
||||
click(&mut h, pos2(slider.center().x, slider.min.y + 1.0));
|
||||
assert!(near(*h.state(), [1.0, 0.0, 0.0]), "the top of the slider is full red: {:?}", h.state());
|
||||
click(&mut h, pos2(square.max.x - 1.0, square.max.y - 1.0));
|
||||
assert!(near(*h.state(), [1.0, 0.0, 1.0]), "bottom right adds full blue: {:?}", h.state());
|
||||
click(&mut h, pos2(square.min.x + 1.0, square.min.y + 1.0));
|
||||
assert!(near(*h.state(), [1.0, 1.0, 0.0]), "top left is full green, no blue: {:?}", h.state());
|
||||
click_label(&mut h, "OK");
|
||||
|
||||
let button = h.get_by_role(Role::ColorWell).rect().center();
|
||||
click(&mut h, button);
|
||||
let slider = h.get_by_label("Color slider").rect();
|
||||
click(&mut h, pos2(slider.center().x, slider.max.y - 1.0));
|
||||
assert!(near(*h.state(), [0.0, 1.0, 0.0]), "the slider still sets red: {:?}", h.state());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_codes_parse() {
|
||||
assert_eq!(widgets::hex_rgb("#ff8000"), Some([255, 128, 0]));
|
||||
assert_eq!(widgets::hex_rgb(" FF8000 "), Some([255, 128, 0]));
|
||||
assert_eq!(widgets::hex_rgb("#0f8"), Some([0, 255, 136]));
|
||||
for bad in ["", "#", "#12", "#12345", "#1234567", "#gg0000", "#ffé000"] {
|
||||
assert_eq!(widgets::hex_rgb(bad), None, "{bad}");
|
||||
}
|
||||
assert_eq!(widgets::srgb_hex([1.0, GREY, 0.0]), "#FF8000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsv_round_trips() {
|
||||
for c in [[1.0, 0.0, 0.0], [0.0, 1.0, 1.0], [GREY, 0.25, 0.75], [0.2, 0.2, 0.2], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]] {
|
||||
let back = widgets::hsv_to_rgb(widgets::rgb_to_hsv(c));
|
||||
assert!(close(back, c), "{c:?} -> {back:?}");
|
||||
}
|
||||
assert!(close(widgets::hsv_to_rgb([0.5, 1.0, 1.0]), [0.0, 1.0, 1.0]));
|
||||
}
|
||||
|
||||
/// The fields round as the hex code does (0.7 × 255 = 178.5 shows as 179, `B3`), and a hue just
|
||||
/// under 360° shows as 0°.
|
||||
#[test]
|
||||
fn the_fields_agree_with_the_hex_code() {
|
||||
let h = picker([1.0, 0.699, 0.7]);
|
||||
let value = |label: &str, nth: usize| h.get_all_by_role_and_label(Role::SpinButton, label).nth(nth).and_then(|n| n.value()).unwrap_or_default();
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, "#").value().as_deref(), Some("FFB2B3"));
|
||||
assert_eq!(value("G:", 0), "178");
|
||||
assert_eq!(value("B:", 1), "179");
|
||||
assert_eq!(value("H:", 0), "0°");
|
||||
}
|
||||
@@ -123,6 +123,46 @@ fn project_items_dropped_on_the_viewer_land_under_the_pointer() {
|
||||
assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}");
|
||||
}
|
||||
|
||||
/// #351: dragging one of several selected Project items inserts all the items,
|
||||
/// in selection order, instead of only the item the drag started on.
|
||||
#[test]
|
||||
fn dragging_selected_project_items_adds_every_item_to_the_timeline() {
|
||||
let (mut h, clip) = harness();
|
||||
let main = h.state().session.active_comp_id().unwrap().0;
|
||||
let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap();
|
||||
h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)];
|
||||
h.run_steps(3);
|
||||
let from = rect(&h, &format!("project.item.{clip}.name")).center();
|
||||
let top = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Top")));
|
||||
drag(&mut h, from, pos2(top.center().x, top.max.y - 2.0), Modifiers::NONE);
|
||||
assert_eq!(stack(&h), ["Top", "Clip", "Other", "Middle", "Bottom"]);
|
||||
assert_eq!(in_point(&h, 1), in_point(&h, 2), "both layers use the drop time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_selected_project_items_adds_every_item_to_the_viewer() {
|
||||
let (mut h, clip) = harness();
|
||||
let main = h.state().session.active_comp_id().unwrap().0;
|
||||
let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap();
|
||||
h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)];
|
||||
h.run_steps(3);
|
||||
let from = rect(&h, &format!("project.item.{clip}.name")).center();
|
||||
let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap();
|
||||
drag(&mut h, from, to, Modifiers::NONE);
|
||||
assert_eq!(stack(&h), ["Clip", "Other", "Top", "Middle", "Bottom"]);
|
||||
for layer in &h.state().session.active_comp().unwrap().layers[..2] {
|
||||
let Some(KV::Vec3(p)) = layer.props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") };
|
||||
let tol = 1.0 / last_fit(&h.ctx) as f64 + 1e-6;
|
||||
assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #88: an effect dragged from Effects & Presets onto the viewer goes on the layer under the
|
||||
/// pointer, not the selected one.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! The split locked viewer re-converts its pixels when the display profile or the output
|
||||
//! simulation changes (egui_kittest, texture uploads captured).
|
||||
|
||||
use effectcraft_engine::{Session, render::RenderOpts, viewer::DisplayColor};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui_kittest::{Harness, TestRenderer};
|
||||
use serde_json::{Value, json};
|
||||
use std::{cell::RefCell, collections::HashMap, rc::Rc};
|
||||
|
||||
type Images = Rc<RefCell<HashMap<egui::TextureId, egui::ColorImage>>>;
|
||||
|
||||
struct Capture(Images);
|
||||
|
||||
impl TestRenderer for Capture {
|
||||
fn handle_delta(&mut self, delta: &mut egui::TexturesDelta) {
|
||||
for (id, ops) in std::mem::take(&mut delta.set) {
|
||||
for op in ops {
|
||||
let egui::ImageData::Color(image) = op.image;
|
||||
if op.pos.is_none() && image.size[0] <= 256 && image.size[1] <= 256 {
|
||||
self.0.borrow_mut().insert(id, (*image).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in std::mem::take(&mut delta.free) {
|
||||
self.0.borrow_mut().remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, _: &egui::Context, _: &egui::FullOutput) -> Result<image::RgbaImage, String> {
|
||||
Err("Texture inspection only".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn invoke(h: &mut Harness<'_, EffectcraftApp>, command: &str, params: Value) {
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, command, params).unwrap();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn locked_center(h: &Harness<'_, EffectcraftApp>, images: &Images) -> [u8; 4] {
|
||||
let (_, texture) = h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::new("viewer-locked"))).unwrap();
|
||||
let images = images.borrow();
|
||||
let image = images.get(&texture.id()).unwrap();
|
||||
image.pixels[(image.size[1] / 2) * image.size[0] + image.size[0] / 2].to_array()
|
||||
}
|
||||
|
||||
fn expected_center(h: &mut Harness<'_, EffectcraftApp>) -> [u8; 4] {
|
||||
let s = &mut h.state_mut().session;
|
||||
let comp = s.active_comp_id().unwrap();
|
||||
let image = s.render(comp, s.time(), RenderOpts::default());
|
||||
let ci = effectcraft_ui_egui::frames::to_color_image(&image);
|
||||
let mut pixels = vec![ci.pixels[(ci.size[1] / 2) * ci.size[0] + ci.size[0] / 2].to_array()];
|
||||
DisplayColor::of(s).unwrap().apply(&mut pixels);
|
||||
pixels[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_viewer_follows_display_profile_and_simulation() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Color", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 64, "height": 64})).unwrap();
|
||||
s.execute("file.projectSettings", json!({"workingSpace": "srgb", "outputSpace": "rec2020", "renderer": "software"})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "srgb"})).unwrap();
|
||||
s.execute("view.displayColorManagement", json!({"value": true})).unwrap();
|
||||
let images = Images::default();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).renderer(Capture(images.clone())).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
invoke(&mut h, "view.splitLockedViewer", json!({}));
|
||||
let first = expected_center(&mut h);
|
||||
assert_eq!(locked_center(&h, &images), first);
|
||||
|
||||
invoke(&mut h, "prefs.set", json!({"key": "previews.displayProfile", "value": "p3"}));
|
||||
let after_display = expected_center(&mut h);
|
||||
assert_ne!(after_display, first);
|
||||
assert_eq!(locked_center(&h, &images), after_display);
|
||||
|
||||
invoke(&mut h, "view.simulateOutput", json!({"profile": "rec709", "preserveRgb": true}));
|
||||
let after_sim = expected_center(&mut h);
|
||||
assert_ne!(after_sim, after_display);
|
||||
assert_eq!(locked_center(&h, &images), after_sim);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! A passive Composition viewer applies the same monitor conversion as the active one (#547).
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::{EffectcraftApp, menus, panels::viewers};
|
||||
use egui::{Color32, Context, ImageData, RawInput, Rect, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn frames(app: &mut EffectcraftApp, ctx: &Context, textures: &mut HashMap<String, Vec<Color32>>) {
|
||||
for _ in 0..4 {
|
||||
let mut output = ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(256.0, 256.0))), ..Default::default() }, |ui| {
|
||||
viewers::show(app, ui, 0, Rect::from_min_size(pos2(0.0, 0.0), vec2(128.0, 256.0)));
|
||||
viewers::show(app, ui, 1, Rect::from_min_size(pos2(128.0, 0.0), vec2(128.0, 256.0)));
|
||||
});
|
||||
for (&id, deltas) in &output.textures_delta.set {
|
||||
let Some(name) = ctx.tex_manager().read().meta(id).map(|m| m.name.clone()) else { continue };
|
||||
for delta in deltas {
|
||||
let ImageData::Color(image) = &delta.image;
|
||||
if delta.pos.is_none() {
|
||||
textures.insert(name.clone(), image.pixels.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
output.textures_delta.clear();
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel(textures: &HashMap<String, Vec<Color32>>, name: &str) -> [u8; 4] {
|
||||
let p = &textures[name];
|
||||
p[p.len() / 2].to_array()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_viewer_matches_the_monitor_conversion() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Display test", "width": 16, "height": 16, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Color", "color": "#406080", "width": 16, "height": 16})).unwrap();
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
let ctx = Context::default();
|
||||
menus::invoke(&mut app, &ctx, "view.newViewer", json!({})).unwrap();
|
||||
let mut textures = HashMap::new();
|
||||
app.session.execute("file.projectSettings", json!({"workingSpace": "rec2020"})).unwrap();
|
||||
app.session.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "p3"})).unwrap();
|
||||
frames(&mut app, &ctx, &mut textures);
|
||||
assert_eq!(pixel(&textures, "viewer-display"), [37, 89, 122, 255]);
|
||||
assert_eq!(pixel(&textures, "viewer-0"), [37, 89, 122, 255]);
|
||||
// Changing which viewer is active keeps the conversion in both.
|
||||
viewers::activate(&mut app, &ctx, 0);
|
||||
frames(&mut app, &ctx, &mut textures);
|
||||
assert_eq!(pixel(&textures, "viewer-display"), [37, 89, 122, 255]);
|
||||
assert_eq!(pixel(&textures, "viewer-1"), [37, 89, 122, 255]);
|
||||
// No output-to-monitor conversion needed: both show the raw frame.
|
||||
app.session.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "srgb"})).unwrap();
|
||||
frames(&mut app, &ctx, &mut textures);
|
||||
assert_eq!(pixel(&textures, "viewer-frame"), [0, 90, 125, 255]);
|
||||
assert_eq!(pixel(&textures, "viewer-1"), [0, 90, 125, 255]);
|
||||
}
|
||||
Reference in new issue
Block a user