mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
LR-BEHAV-HEADLESS: keep CPU snapshots isolated from saved preferences
Co-authored-by: Codex <[email protected]>
This commit is contained in:
1 parent
b1883231bd
commit
56754e5703
9 files changed
+431
-19
No files matched your search
@@ -728,9 +728,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
if script.is_none() && output.is_none() {
|
||||
return Err("snapshot: give -o OUT.png and/or --script FILE".into());
|
||||
}
|
||||
if !(scale > 0.0 && size[0] >= 1.0 && size[1] >= 1.0) {
|
||||
return Err("snapshot: bad --size/--scale".into());
|
||||
}
|
||||
lightcraft_ui_egui::headless::viewport_pixels(size, scale).map_err(|e| format!("snapshot: bad --size/--scale: {e}"))?;
|
||||
let t0 = Instant::now();
|
||||
let mut session = match &library {
|
||||
Some(dir) => {
|
||||
@@ -741,6 +739,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
None if files.is_empty() => Session::with_demo().with_fs(),
|
||||
None => Session::new().with_fs(),
|
||||
};
|
||||
// Apply compute policy before imports or the first scripted query can discover an adapter.
|
||||
session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?;
|
||||
if !files.is_empty() {
|
||||
let ti = Instant::now();
|
||||
let r = session.execute("library.import", &json!({"paths": expand_paths(&files)})).map_err(|e| e.to_string())?;
|
||||
@@ -755,7 +755,11 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let app = lightcraft_ui_egui::LightcraftApp::new(session, services);
|
||||
let mut app = lightcraft_ui_egui::LightcraftApp::new(session, services);
|
||||
// Snapshot sessions promise no GPU: disable photo compute as well as the compositor
|
||||
// before the first frame can start background adapter discovery.
|
||||
// This UI state is session-local: the CLI neither loads nor saves desktop ui.json.
|
||||
app.ui.settings.gpu = false;
|
||||
let mut h = Headless::new(app, size, scale);
|
||||
let timeout = Duration::from_secs(60);
|
||||
let mut shots = 0usize;
|
||||
|
||||
@@ -233,6 +233,58 @@ fn snapshot_script_failure_exits_non_zero() {
|
||||
assert_eq!((d.width, d.height), (320, 200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() {
|
||||
let script = tmp("snap-ui-zoom.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("{}\n{}\n", json!({"method": "ui.key", "params": {"key": "Plus", "cmd": true}}), json!({"method": "ui.inspect"})),
|
||||
)
|
||||
.unwrap();
|
||||
for (size, scale, expected) in [("400x240", "1", (400, 240)), ("345x200", "0.9", (311, 180))] {
|
||||
let out = tmp(&format!("snap-ui-zoom-{scale}.png"));
|
||||
let o = Command::new(BIN)
|
||||
.args(["snapshot", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", size, "--scale", scale])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
|
||||
assert!(replies[1]["result"]["pixelsPerPoint"].as_f64().unwrap() > scale.parse::<f64>().unwrap());
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(out).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Headless snapshots default to CPU photo rendering as well as CPU UI rasterization.
|
||||
#[test]
|
||||
fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() {
|
||||
let script = tmp("default-cpu.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.screenshot"})),
|
||||
)
|
||||
.unwrap();
|
||||
let image = tmp("default-cpu.png");
|
||||
let output = Command::new(BIN)
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.env_remove("LIGHTCRAFT_GPU_BACKEND")
|
||||
.env_remove("WGPU_BACKEND")
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", image.to_str().unwrap(), "--size", "480x320"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
|
||||
let gpu = &replies[0]["result"];
|
||||
assert_eq!(gpu["enabled"], false, "{gpu}");
|
||||
assert_eq!(gpu["available"], false, "{gpu}");
|
||||
assert_eq!(gpu["adapter"], Value::Null, "no device is created: {gpu}");
|
||||
assert!(gpu["reason"].as_str().is_some_and(|reason| reason.contains("preference")), "{gpu}");
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (480, 320));
|
||||
}
|
||||
|
||||
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
|
||||
/// without creating a GPU device (no driver is loaded), and says why.
|
||||
#[test]
|
||||
@@ -412,3 +464,79 @@ fn warnings_are_logged_on_stderr() {
|
||||
let off = run("off");
|
||||
assert!(!off.contains("names no known backend"), "{off}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_rejects_invalid_dimensions_before_creating_an_image() {
|
||||
for (index, (size, scale)) in
|
||||
[("NaNx100", "1"), ("100x100", "inf"), ("9000x9000", "1"), ("9000x100", "2"), ("100x100", "0.001")].into_iter().enumerate()
|
||||
{
|
||||
let image = tmp(&format!("invalid-viewport-{index}.png"));
|
||||
let output = Command::new(BIN).args(["snapshot", "--size", size, "--scale", scale, "-o"]).arg(&image).output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("snapshot: bad --size/--scale"), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(!image.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_preserves_fractional_scale_rounding() {
|
||||
let image = tmp("fractional-scale-rounding.png");
|
||||
let output =
|
||||
Command::new(BIN).env("LIGHTCRAFT_GPU", "0").args(["snapshot", "--size", "345x200", "--scale", "0.9", "-o"]).arg(&image).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (311, 180));
|
||||
}
|
||||
|
||||
/// Snapshot's CPU setting must never replace the desktop preference, including with --library.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn snapshot_leaves_desktop_gpu_preferences_untouched() {
|
||||
let dir = tmp("snapshot-desktop-preferences");
|
||||
let library = dir.join("library");
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
session.open_library(&library, true).unwrap();
|
||||
drop(session);
|
||||
let config = dir.join("config");
|
||||
let app_config = config.join("lightcraft");
|
||||
std::fs::create_dir_all(&app_config).unwrap();
|
||||
let ui_path = app_config.join("ui.json");
|
||||
let mut ui = lightcraft_ui_egui::UiState::default();
|
||||
ui.settings.gpu = true;
|
||||
let saved = serde_json::to_vec_pretty(&ui).unwrap();
|
||||
std::fs::write(&ui_path, &saved).unwrap();
|
||||
let script = dir.join("preferences.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.inspect"})),
|
||||
)
|
||||
.unwrap();
|
||||
for gpu_environment in [None, Some("1")] {
|
||||
let image = dir.join(format!("{}.png", gpu_environment.unwrap_or("default")));
|
||||
let mut cmd = Command::new(BIN);
|
||||
cmd.env("XDG_CONFIG_HOME", &config).env_remove("LIGHTCRAFT_GPU").env_remove("LIGHTCRAFT_GPU_BACKEND").env_remove("WGPU_BACKEND");
|
||||
if let Some(value) = gpu_environment {
|
||||
cmd.env("LIGHTCRAFT_GPU", value);
|
||||
}
|
||||
let output = cmd
|
||||
.args(["snapshot", "--library"])
|
||||
.arg(&library)
|
||||
.args(["--script"])
|
||||
.arg(&script)
|
||||
.args(["-o"])
|
||||
.arg(&image)
|
||||
.args(["--size", "480x320"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
|
||||
assert_eq!(replies[0]["result"]["enabled"], false);
|
||||
assert_eq!(replies[0]["result"]["available"], false);
|
||||
assert_eq!(replies[0]["result"]["adapter"], Value::Null);
|
||||
assert_eq!(replies[1]["result"]["ui"]["settings"]["gpu"], false);
|
||||
assert_eq!(std::fs::read(&ui_path).unwrap(), saved, "snapshot changed the desktop preferences");
|
||||
assert!(!app_config.join("ui.json.tmp").exists());
|
||||
assert!(image.exists());
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,6 @@
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
@@ -323,8 +323,18 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.resize" => {
|
||||
for name in ["width", "height"] {
|
||||
if p.get(name).is_some() && f(name).is_none() {
|
||||
return err(format!("ui.resize: {name} must be a number"));
|
||||
}
|
||||
}
|
||||
let w = f("width").unwrap_or(1600.0) as f32;
|
||||
let h = f("height").unwrap_or(1000.0) as f32;
|
||||
let zoom = ctx.zoom_factor();
|
||||
let native_scale = ctx.input(|i| i.viewport().native_pixels_per_point).unwrap_or_else(|| ctx.pixels_per_point() / zoom);
|
||||
if let Err(e) = crate::headless::resized_viewport([w, h], native_scale, zoom) {
|
||||
return err(e);
|
||||
}
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
|
||||
ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -28,13 +28,56 @@ const CLEAR: Color32 = Color32::from_rgb(12, 12, 12);
|
||||
/// Simulated frame interval (deterministic animation time).
|
||||
const FRAME_DT: f64 = 1.0 / 60.0;
|
||||
|
||||
/// Validate the scaled screenshot dimensions before layout or pixel allocation.
|
||||
pub fn viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize; 2], String> {
|
||||
if size.iter().any(|edge| *edge < 1.0) {
|
||||
return Err("viewport dimensions must be at least one logical point".into());
|
||||
}
|
||||
native_viewport_pixels(size, pixels_per_point)
|
||||
}
|
||||
|
||||
fn native_viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize; 2], String> {
|
||||
let [width, height] = size;
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 || !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
|
||||
return Err("viewport dimensions and scale must be finite and positive".into());
|
||||
}
|
||||
let axis = |value: f32| {
|
||||
// Match egui/scissor rounding and the existing screenshot dimension contract.
|
||||
let pixels = (value * pixels_per_point).round();
|
||||
if !(1.0..=softpaint::MAX_IMAGE_EDGE as f32).contains(&pixels) {
|
||||
return Err(format!("scaled viewport edges must be between 1 and {} pixels", softpaint::MAX_IMAGE_EDGE));
|
||||
}
|
||||
Ok(pixels as usize)
|
||||
};
|
||||
let (w, h) = (axis(width)?, axis(height)?);
|
||||
if w.checked_mul(h).is_none_or(|pixels| pixels > softpaint::MAX_IMAGE_PIXELS) {
|
||||
return Err(format!("scaled viewport exceeds {} pixels", softpaint::MAX_IMAGE_PIXELS));
|
||||
}
|
||||
Ok([w, h])
|
||||
}
|
||||
|
||||
/// Convert an effective-point resize to the native viewport before validating pixel bounds.
|
||||
/// Sub-point native edges after zooming out are valid when they round to at least one pixel.
|
||||
pub fn resized_viewport(size: [f32; 2], native_pixels_per_point: f32, zoom: f32) -> Result<egui::Vec2, String> {
|
||||
if size.iter().any(|edge| !edge.is_finite() || *edge < 1.0) || !zoom.is_finite() || zoom <= 0.0 {
|
||||
return Err("resize dimensions must be at least one finite logical point with a positive zoom".into());
|
||||
}
|
||||
let native_size = egui::Vec2::from(size) * zoom;
|
||||
native_viewport_pixels([native_size.x, native_size.y], native_pixels_per_point)?;
|
||||
Ok(native_size)
|
||||
}
|
||||
|
||||
fn bounded_viewport(size: egui::Vec2, scale: f32) -> (egui::Vec2, f32) {
|
||||
if native_viewport_pixels([size.x, size.y], scale).is_ok() { (size, scale) } else { (egui::vec2(1600.0, 1000.0), 1.0) }
|
||||
}
|
||||
|
||||
/// An offscreen egui context with our fonts and theme, and a CPU mirror of its textures.
|
||||
pub struct HeadlessView {
|
||||
pub ctx: egui::Context,
|
||||
pub textures: TextureStore,
|
||||
pub(crate) shapes: Vec<egui::epaint::ClippedShape>,
|
||||
pixels_per_point: f32,
|
||||
size: egui::Vec2,
|
||||
size: [usize; 2],
|
||||
frames: u64,
|
||||
}
|
||||
|
||||
@@ -49,11 +92,12 @@ impl HeadlessView {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx);
|
||||
HeadlessView { ctx, textures: TextureStore::default(), shapes: vec![], pixels_per_point: 1.0, size: egui::vec2(1600.0, 1000.0), frames: 0 }
|
||||
HeadlessView { ctx, textures: TextureStore::default(), shapes: vec![], pixels_per_point: 1.0, size: [1600, 1000], frames: 0 }
|
||||
}
|
||||
|
||||
/// Input for one frame of a `size` (points) viewport at `pixels_per_point`.
|
||||
pub fn raw_input(size: egui::Vec2, pixels_per_point: f32, time: f64, events: Vec<egui::Event>) -> RawInput {
|
||||
let (size, pixels_per_point) = bounded_viewport(size, pixels_per_point);
|
||||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
|
||||
let mut raw = RawInput {
|
||||
screen_rect: Some(rect),
|
||||
@@ -73,9 +117,20 @@ impl HeadlessView {
|
||||
}
|
||||
|
||||
/// Run one frame; keeps the shapes for [`Self::paint`]. Returns the root viewport's commands.
|
||||
pub fn run(&mut self, raw: RawInput, run_ui: impl FnMut(&mut egui::Ui)) -> Vec<ViewportCommand> {
|
||||
if let Some(r) = raw.screen_rect {
|
||||
self.size = r.size();
|
||||
pub fn run(&mut self, mut raw: RawInput, run_ui: impl FnMut(&mut egui::Ui)) -> Vec<ViewportCommand> {
|
||||
if let Some(rect) = raw.screen_rect {
|
||||
let native_scale = raw.viewports.get(&ViewportId::ROOT).and_then(|info| info.native_pixels_per_point).unwrap_or(1.0);
|
||||
self.size = native_viewport_pixels([rect.width(), rect.height()], native_scale).unwrap_or([0, 0]);
|
||||
// The host viewport is measured before UI zoom. Egui lays out in zoomed points;
|
||||
// retain the host's physical size while allowing text and controls to grow.
|
||||
let zoom = self.ctx.zoom_factor();
|
||||
if zoom.is_finite() && zoom > 0.0 {
|
||||
raw.screen_rect = Some(rect / zoom);
|
||||
if let Some(info) = raw.viewports.get_mut(&ViewportId::ROOT) {
|
||||
info.inner_rect = info.inner_rect.map(|rect| rect / zoom);
|
||||
info.outer_rect = info.outer_rect.map(|rect| rect / zoom);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = self.ctx.run_ui(raw, run_ui);
|
||||
self.frames += 1;
|
||||
@@ -92,11 +147,17 @@ impl HeadlessView {
|
||||
|
||||
/// Size in pixels of the last frame.
|
||||
pub fn size_px(&self) -> [usize; 2] {
|
||||
[(self.size.x * self.pixels_per_point).round() as usize, (self.size.y * self.pixels_per_point).round() as usize]
|
||||
self.size
|
||||
}
|
||||
|
||||
/// Rasterize the last frame. `extra` textures (by id) take precedence over the context's own.
|
||||
pub fn paint(&self, extra: &HashMap<TextureId, CpuTexture>) -> ColorImage {
|
||||
if viewport_pixels([self.size[0] as f32, self.size[1] as f32], 1.0).is_err()
|
||||
|| !self.pixels_per_point.is_finite()
|
||||
|| self.pixels_per_point <= 0.0
|
||||
{
|
||||
return ColorImage::new([0, 0], Vec::new());
|
||||
}
|
||||
let prims = self.ctx.tessellate(self.shapes.clone(), self.pixels_per_point);
|
||||
softpaint::paint(&prims, &Layered { over: extra, base: &self.textures }, self.size_px(), self.pixels_per_point, CLEAR)
|
||||
}
|
||||
@@ -125,7 +186,14 @@ pub struct Headless {
|
||||
|
||||
impl Headless {
|
||||
/// Wrap `app` (its control channel is replaced by the driver's).
|
||||
/// Invalid dimensions fall back to 1600×1000 at scale 1; external callers can use
|
||||
/// [`viewport_pixels`] to reject them before constructing the session.
|
||||
pub fn new(app: LightcraftApp, size: [f32; 2], pixels_per_point: f32) -> Self {
|
||||
let (size, pixels_per_point) = if viewport_pixels(size, pixels_per_point).is_ok() {
|
||||
(egui::Vec2::from(size), pixels_per_point)
|
||||
} else {
|
||||
(egui::vec2(1600.0, 1000.0), 1.0)
|
||||
};
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut app = app.with_control(rx);
|
||||
app.headless_host = true;
|
||||
@@ -133,7 +201,7 @@ impl Headless {
|
||||
app,
|
||||
view: HeadlessView::new(),
|
||||
max_texture_side: 16384,
|
||||
size: egui::vec2(size[0], size[1]),
|
||||
size,
|
||||
pixels_per_point,
|
||||
time: 0.0,
|
||||
frames: 0,
|
||||
@@ -156,6 +224,7 @@ impl Headless {
|
||||
|
||||
/// Run one frame.
|
||||
pub fn step(&mut self) {
|
||||
(self.size, self.pixels_per_point) = bounded_viewport(self.size, self.pixels_per_point);
|
||||
let mut raw = HeadlessView::raw_input(self.size, self.pixels_per_point, self.time, std::mem::take(&mut self.events));
|
||||
raw.viewports.entry(ViewportId::ROOT).or_default().maximized = Some(self.window_maximized);
|
||||
raw.max_texture_side = Some(self.max_texture_side);
|
||||
@@ -173,7 +242,12 @@ impl Headless {
|
||||
let image = Arc::new(self.paint());
|
||||
self.events.push(egui::Event::Screenshot { viewport_id: ViewportId::ROOT, user_data, image });
|
||||
}
|
||||
ViewportCommand::InnerSize(s) if s.x >= 1.0 && s.y >= 1.0 => self.size = s,
|
||||
ViewportCommand::InnerSize(size) => {
|
||||
// Viewport commands use current egui points, which include UI zoom.
|
||||
if let Ok(native_size) = resized_viewport([size.x, size.y], self.pixels_per_point, self.view.ctx.zoom_factor()) {
|
||||
self.size = native_size;
|
||||
}
|
||||
}
|
||||
ViewportCommand::Close => self.quit = true,
|
||||
ViewportCommand::Maximized(on) => {
|
||||
self.window_maximized = on;
|
||||
@@ -293,6 +367,151 @@ impl Headless {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn screenshot_dimensions_are_checked_before_layout() {
|
||||
assert_eq!(viewport_pixels([1600.0, 1000.0], 2.0).unwrap(), [3200, 2000]);
|
||||
assert_eq!(viewport_pixels([345.0, 200.0], 0.9).unwrap(), [311, 180]);
|
||||
assert_eq!(viewport_pixels([8000.0, 8000.0], 1.0).unwrap(), [8000, 8000]);
|
||||
for (size, scale) in [
|
||||
([f32::NAN, 10.0], 1.0),
|
||||
([10.0, f32::INFINITY], 1.0),
|
||||
([10.0, 10.0], f32::INFINITY),
|
||||
([10.0, 10.0], f32::NAN),
|
||||
([10.0, 10.0], 0.0),
|
||||
([0.0, 10.0], 1.0),
|
||||
([10.0, 10.0], 0.01),
|
||||
([9000.0, 9000.0], 1.0),
|
||||
([9000.0, 100.0], 2.0),
|
||||
] {
|
||||
assert!(viewport_pixels(size, scale).is_err(), "{size:?} at {scale}");
|
||||
}
|
||||
let raw = HeadlessView::raw_input(egui::vec2(f32::INFINITY, 10.0), 1.0, 0.0, vec![]);
|
||||
assert_eq!(raw.screen_rect.unwrap().size(), egui::vec2(1600.0, 1000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_zoom_preserves_host_pixels_and_scales_layout() {
|
||||
for (size, scale, expected) in
|
||||
[(egui::vec2(400.0, 240.0), 1.0, [400, 240]), (egui::vec2(345.0, 200.0), 0.9, [311, 180]), (egui::vec2(400.0, 240.0), 2.0, [800, 480])]
|
||||
{
|
||||
let mut view = HeadlessView::new();
|
||||
for frame in 0..9 {
|
||||
let key = match frame {
|
||||
1 => Some(egui::Key::Plus),
|
||||
5 => Some(egui::Key::Minus),
|
||||
_ => None,
|
||||
};
|
||||
let mut raw = HeadlessView::raw_input(size, scale, frame as f64 / 60.0, vec![]);
|
||||
if let Some(key) = key {
|
||||
let modifiers = egui::Modifiers { command: true, ..Default::default() };
|
||||
raw.events.push(egui::Event::ModifiersChanged(modifiers));
|
||||
raw.events.extend(vec![
|
||||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
]);
|
||||
}
|
||||
view.run(raw, |ui| {
|
||||
ui.label("UI zoom changes layout, keeping the host viewport size");
|
||||
});
|
||||
assert_eq!(view.size_px(), expected, "frame {frame} at scale {scale}");
|
||||
assert_eq!(view.paint(&HashMap::new()).size, expected);
|
||||
if frame == 4 {
|
||||
assert!(view.ctx.zoom_factor() > 1.0);
|
||||
assert!(view.ctx.content_rect().width() < size.x);
|
||||
}
|
||||
if frame == 8 {
|
||||
assert!(view.ctx.zoom_factor() < 1.01);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_screenshot_keeps_the_zoomed_hosts_size_and_scale() {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.settings.gpu = false;
|
||||
let mut h = Headless::new(app, [480.0, 320.0], 2.0);
|
||||
let t = Duration::from_secs(5);
|
||||
assert_eq!(h.request("ui.key", json!({"key": "Plus", "cmd": true}), t)["ok"], true);
|
||||
assert!(h.view.ctx.zoom_factor() > 1.0);
|
||||
let main = h.view.ctx.clone();
|
||||
let screenshot = h.app.headless_screenshot(&main, true).unwrap();
|
||||
assert_eq!(screenshot.size, [960, 640]);
|
||||
let shadow = h.app.shadow.as_ref().unwrap();
|
||||
assert!((shadow.ctx.pixels_per_point() - main.pixels_per_point()).abs() < 0.001);
|
||||
assert!((shadow.ctx.content_rect().width() - main.content_rect().width()).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_resize_uses_effective_points_after_ui_zoom() {
|
||||
for scale in [0.9, 1.0, 2.0] {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.settings.gpu = false;
|
||||
let mut h = Headless::new(app, [480.0, 320.0], scale);
|
||||
let t = Duration::from_secs(5);
|
||||
assert_eq!(h.request("ui.key", json!({"key": "Plus", "cmd": true}), t)["ok"], true);
|
||||
let native_size = resized_viewport([320.0, 240.0], scale, h.view.ctx.zoom_factor()).unwrap();
|
||||
let expected = native_viewport_pixels([native_size.x, native_size.y], scale).unwrap();
|
||||
assert_eq!(h.request("ui.resize", json!({"width": 320, "height": 240}), t)["ok"], true);
|
||||
assert_eq!(h.view.size_px(), expected);
|
||||
assert!((h.view.ctx.content_rect().width() - 320.0).abs() < 0.1);
|
||||
let main = h.view.ctx.clone();
|
||||
assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_resize_keeps_a_minimum_width_after_zooming_out() {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.settings.gpu = false;
|
||||
let mut h = Headless::new(app, [480.0, 320.0], 1.0);
|
||||
h.view.ctx.set_zoom_factor(0.5);
|
||||
let t = Duration::from_secs(5);
|
||||
assert_eq!(h.request("ui.resize", json!({"width": 1, "height": 120}), t)["ok"], true);
|
||||
assert_eq!(h.size, egui::vec2(0.5, 60.0));
|
||||
assert_eq!(h.view.size_px(), [1, 60]);
|
||||
assert!((h.view.ctx.content_rect().width() - 1.0).abs() < 0.01);
|
||||
let main = h.view.ctx.clone();
|
||||
assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, [1, 60]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_resize_rounds_in_native_points_after_a_fractional_zoom_out() {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.settings.gpu = false;
|
||||
let mut h = Headless::new(app, [480.0, 320.0], 0.9);
|
||||
let t = Duration::from_secs(5);
|
||||
assert_eq!(h.request("ui.key", json!({"key": "Minus", "cmd": true}), t)["ok"], true);
|
||||
assert!((h.view.ctx.zoom_factor() - 0.9).abs() < 0.001);
|
||||
assert_eq!(h.request("ui.resize", json!({"width": 150, "height": 150}), t)["ok"], true);
|
||||
assert_eq!(h.size, egui::vec2(135.0, 135.0));
|
||||
assert_eq!(h.view.size_px(), [122, 122]);
|
||||
assert!((h.view.ctx.content_rect().width() - 150.0).abs() < 0.01);
|
||||
let main = h.view.ctx.clone();
|
||||
assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, [122, 122]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_control_resize_preserves_the_headless_viewport() {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
|
||||
let mut h = Headless::new(app, [480.0, 320.0], 2.0);
|
||||
h.app.ui.settings.gpu = false;
|
||||
h.step();
|
||||
let size = h.size;
|
||||
let rejected = h.request("ui.resize", json!({"width": 9000, "height": 8000}), Duration::from_secs(5));
|
||||
assert_eq!(rejected["ok"], false, "{rejected}");
|
||||
assert_eq!(h.size, size);
|
||||
for bad in [json!({"width": "320", "height": 240}), json!({"width": 320, "height": null}), json!({"width": true})] {
|
||||
let rejected = h.request("ui.resize", bad, Duration::from_secs(5));
|
||||
assert_eq!(rejected["ok"], false, "{rejected}");
|
||||
assert_eq!(h.size, size);
|
||||
}
|
||||
let accepted = h.request("ui.resize", json!({"width": 320, "height": 240}), Duration::from_secs(5));
|
||||
assert_eq!(accepted["ok"], true, "{accepted}");
|
||||
assert_eq!(h.size, egui::vec2(320.0, 240.0));
|
||||
assert_eq!(h.view.size_px(), [640, 480]);
|
||||
}
|
||||
|
||||
/// Generous: renders are slow when the machine is loaded (parallel builds), and a timed-out
|
||||
/// settle would show a half-rendered UI.
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
@@ -606,7 +606,10 @@ impl LightcraftApp {
|
||||
let mut view = self.shadow.take().unwrap_or_default();
|
||||
let size = main.input(|i| i.content_rect()).size();
|
||||
let size = if size.x >= 1.0 && size.y >= 1.0 { size } else { egui::vec2(1600.0, 1000.0) };
|
||||
let ppp = main.pixels_per_point();
|
||||
// Feed native viewport points; the shadow context applies the copied UI zoom.
|
||||
let zoom = main.zoom_factor();
|
||||
let size = size * zoom;
|
||||
let ppp = main.pixels_per_point() / zoom;
|
||||
let time = main.input(|i| i.time);
|
||||
if view.frames() == 0 {
|
||||
// warm-up pass: activates our fonts (pending font definitions live in `Memory`, which
|
||||
|
||||
@@ -130,10 +130,24 @@ struct Prepared<'a> {
|
||||
}
|
||||
|
||||
const BAND: usize = 32;
|
||||
/// Limits for CPU screenshots, shared with headless input validation.
|
||||
pub const MAX_IMAGE_EDGE: usize = 16_384;
|
||||
pub const MAX_IMAGE_PIXELS: usize = 64_000_000;
|
||||
|
||||
fn image_len(size: [usize; 2]) -> Option<usize> {
|
||||
let [w, h] = size;
|
||||
if w == 0 || h == 0 || w > MAX_IMAGE_EDGE || h > MAX_IMAGE_EDGE {
|
||||
return None;
|
||||
}
|
||||
w.checked_mul(h).filter(|n| *n <= MAX_IMAGE_PIXELS)
|
||||
}
|
||||
|
||||
/// Rasterize `primitives` into a `size` (pixels) image cleared to `clear`. The result is opaque if
|
||||
/// `clear` is.
|
||||
pub fn paint(primitives: &[ClippedPrimitive], textures: &dyn TextureSource, size: [usize; 2], pixels_per_point: f32, clear: Color32) -> ColorImage {
|
||||
let Some(len) = image_len(size).filter(|_| pixels_per_point.is_finite() && pixels_per_point > 0.0) else {
|
||||
return ColorImage::new([0, 0], Vec::new());
|
||||
};
|
||||
let [w, h] = size;
|
||||
let ppp = pixels_per_point as f64;
|
||||
let prepared: Vec<Prepared<'_>> = primitives
|
||||
@@ -167,7 +181,7 @@ pub fn paint(primitives: &[ClippedPrimitive], textures: &dyn TextureSource, size
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut pixels = vec![clear; w * h];
|
||||
let mut pixels = vec![clear; len];
|
||||
let bands: Vec<(usize, &mut [Color32])> = pixels.chunks_mut((BAND * w).max(1)).enumerate().map(|(i, c)| (i * BAND, c)).collect();
|
||||
let threads = thread_count().min(bands.len()).max(1);
|
||||
if threads <= 1 {
|
||||
@@ -469,3 +483,20 @@ mod tests {
|
||||
assert_eq!(t.image.pixels[2 * 4 + 3], Color32::BLACK);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dimension_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejected_dimensions_allocate_no_pixels() {
|
||||
for size in [[usize::MAX, 2], [9000, 9000], [MAX_IMAGE_EDGE + 1, 1], [0, 1]] {
|
||||
let image = paint(&[], &TextureStore::default(), size, 1.0, Color32::BLACK);
|
||||
assert_eq!(image.size, [0, 0]);
|
||||
assert!(image.pixels.is_empty());
|
||||
}
|
||||
let image = paint(&[], &TextureStore::default(), [2, 3], 1.0, Color32::BLACK);
|
||||
assert_eq!(image.size, [2, 3]);
|
||||
assert_eq!(image.pixels.len(), 6);
|
||||
}
|
||||
}
|
||||
@@ -85,14 +85,27 @@ scissor clipping as egui's GPU backends, so the image matches the window (minus
|
||||
- **In the running app:** `{"method": "ui.screenshot", "params": {"path": "a.png", "headless": true}}`.
|
||||
The UI is drawn into an offscreen context from the app's logic tick, which keeps running when the
|
||||
window is occluded or the display sleeps/locks. Windowed screenshots fall back to this after 2 s.
|
||||
- **Without any app window:** `lightcraft-cli snapshot` runs a whole app session headlessly and
|
||||
answers the same control requests (same handler) from a JSON-lines script:
|
||||
- **Without any app window:** `lightcraft-cli snapshot` runs a whole app session headlessly.
|
||||
Photo development also defaults to the CPU, so the initial frames do not discover a GPU adapter
|
||||
or load a graphics driver. `LIGHTCRAFT_GPU=1` does not opt snapshots back into GPU rendering.
|
||||
This applies only to the snapshot session: even `--library DIR` leaves the desktop app's saved
|
||||
`ui.json` preferences untouched. It answers the same control requests (same handler) from a JSON-lines script:
|
||||
|
||||
```text
|
||||
lightcraft-cli snapshot --demo -o grid.png --size 1600x1000 [--scale 2]
|
||||
lightcraft-cli snapshot --library DIR --script tour.jsonl -o shot.png
|
||||
```
|
||||
|
||||
Headless screenshot dimensions must be finite and at least one logical point, with a finite,
|
||||
positive scale. Rounded output must be at least one pixel per edge, at most 16,384 pixels
|
||||
per edge and 64 million pixels in total. `snapshot` and `ui.resize` reject requests outside
|
||||
these limits before layout or rasterization; supplied resize dimensions must be numbers.
|
||||
UI zoom changes text and control sizes while preserving the snapshot viewport's requested
|
||||
physical pixel dimensions. Later `ui.resize` requests use current egui points; validation
|
||||
and rasterization both convert them to native viewport points before rounding the output
|
||||
pixels. A native edge below one point after zooming out is valid if it rounds to at least
|
||||
one pixel.
|
||||
|
||||
`tour.jsonl` holds one request per line (`#` comments allowed), e.g.
|
||||
`{"method": "ui.set", "params": {"view": "detail", "right": "edit", "openSections": ["optics"]}}`
|
||||
then `{"method": "ui.screenshot"}`. Replies are printed to stdout. A failed request
|
||||
|
||||
+4
-3
@@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 6 | 0 | 3 | 2 | 1/1 (100%) | 4/4 (100%) |
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 17 | 5 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 18 | 5 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
|
||||
| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) |
|
||||
| **Total** | 393 | 36 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
|
||||
| **Total** | 394 | 36 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 515 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 42.0% of 162.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 516 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 42.3% of 163.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
@@ -519,6 +519,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed |
|
||||
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
|
||||
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
|
||||
| LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported |
|
||||
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
|
||||
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
|
||||
|
||||
Reference in new issue
Block a user