Merge pull request #220 from storytold/fix/198-201-204-app-startup

App: starts with an empty project, opens on older GPUs, shows comps wider than the texture limit (#198, #201, #204)
This commit is contained in:
BFlat 2026-10-08 05:16:25 +07:00 committed by GitHub
commit 0e6d37061a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 237 additions and 38 deletions

View File

@ -8,7 +8,7 @@ work apply to everyone, people and AI agents alike, and they are not negotiable.
You need [Rust](https://rustup.rs/) 1.95 or newer.
```sh
cargo run -p effectcraft # the desktop app, with the demo project
cargo run -p effectcraft -- --demo # the desktop app, with the demo project
cargo test --workspace
cargo xtask ci # what every commit must pass
```

View File

@ -193,7 +193,7 @@ To build it yourself you need [Rust](https://rustup.rs/) 1.95 or newer.
```sh
git clone https://github.com/storytold/effectcraft
cd effectcraft
cargo run --release -p effectcraft # the app, with the demo project open
cargo run --release -p effectcraft # the app (add `-- --demo` to open the demo project)
cargo run --release -p effectcraft-cli -- render --out intro.mp4 # render the demo headless
cargo xtask web --serve 8765 # the browser build on http://127.0.0.1:8765/ (docs/web.md)
```

View File

@ -2,6 +2,9 @@
//!
//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [project.ecproj | media files…]`
//!
//! Without a project the app starts with an empty Untitled Project, as After Effects does;
//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).
//!
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! see `effectcraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
@ -32,28 +35,43 @@ fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// The command line.
#[derive(Debug, Default, PartialEq)]
struct Args {
control_port: Option<u16>,
files: Vec<String>,
/// Open the demo project when no project is given (`--demo`).
demo: bool,
/// Show or skip the Home screen (`--home`), else the Startup preference decides.
home: Option<bool>,
}
/// Parse the arguments after the program name; `None` for `--version`. `env_port` is
/// `EFFECTCRAFT_CONTROL_PORT`.
fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>) -> Option<Args> {
let mut out = Args { control_port: env_port.and_then(|p| p.parse().ok()), ..Default::default() };
let mut args = args.into_iter();
while let Some(a) = args.next() {
match a.as_str() {
"--control" => out.control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => out.demo = true,
"--empty" => out.demo = false,
"--home" => out.home = Some(true),
"--version" => return None,
_ => out.files.push(a),
}
}
Some(out)
}
fn main() -> eframe::Result {
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
effectcraft_engine::logging::install();
effectcraft_engine::logging::install_panic_hook();
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;
let mut home: Option<bool> = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => demo = true,
"--empty" => demo = false,
"--home" => home = Some(true),
"--version" => {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ => files.push(a),
}
}
let Some(Args { control_port, files, demo, home }) = parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT").ok()) else {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
};
// The font menus list the installed fonts: read their names while the window opens.
effectcraft_engine::text::fonts::scan_system_in_background();
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
@ -71,9 +89,10 @@ fn main() -> eframe::Result {
// Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID.
.with_app_id(appimage::APP_ID),
event_loop_builder: agent_event_loop(control_port.is_some()),
wgpu_options: wgpu_options(),
..Default::default()
};
eframe::run_native(
let result = eframe::run_native(
"EffectCraft",
options,
Box::new(move |cc| {
@ -170,7 +189,50 @@ fn main() -> eframe::Result {
app,
}))
}),
)
);
if let Err(e) = &result {
startup_failed(e);
}
result
}
/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px)
/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
/// #198) still opens the app, and the compositor then checks what it needs and falls back to
/// the CPU when the device can't run it.
fn wgpu_options() -> eframe::WgpuConfiguration {
use eframe::egui_wgpu::WgpuSetup;
let mut config = eframe::WgpuConfiguration::default();
if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor {
label: Some("EffectCraft device"),
required_limits: device_limits(adapter.limits()),
..Default::default()
});
}
config
}
/// The limits to request from an adapter that offers `adapter`: all of them, with textures
/// capped at 16384 px like the headless renderer's device.
fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter }
}
/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting
/// silently, since release builds on Windows have no console.
fn startup_failed(e: &eframe::Error) {
let message = format!(
"EffectCraft couldn't start its window: {e}\n\nUpdating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
);
log::error!("{message}");
eprintln!("effectcraft: {message}");
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("EffectCraft")
.set_description(message)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
/// The desktop app: the egui app plus, on macOS, the system menu bar.
@ -227,3 +289,37 @@ fn disable_app_nap() {
std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason));
}
}
#[cfg(test)]
mod tests {
use super::{Args, parse_args};
fn parse(args: &[&str]) -> Option<Args> {
parse_args(args.iter().map(|a| a.to_string()), None)
}
/// The window's device never asks for more than the adapter offers (#198).
#[test]
fn device_limits_fit_the_adapter() {
use eframe::wgpu::Limits;
for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] {
let l = super::device_limits(adapter.clone());
assert!(l.check_limits(&adapter), "{l:?}");
assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage);
}
let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
}
/// Launching without a project opens an empty project, not the demo (#204).
#[test]
fn no_arguments_start_an_empty_project() {
assert_eq!(parse(&[]), Some(Args::default()));
assert!(parse(&["--demo"]).is_some_and(|a| a.demo));
assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo));
let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default();
assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()]));
assert_eq!(parse(&["--version"]), None);
assert_eq!(parse_args(Vec::new(), Some("9000".into())).map(|a| a.control_port), Some(Some(9000)));
}
}

View File

@ -425,6 +425,55 @@ pub fn to_color_image(img: &effectcraft_engine::render::Image) -> egui::ColorIma
egui::ColorImage::new([img.width as usize, img.height as usize], px)
}
/// The size `size` is shown at in a texture whose sides can't exceed `max_side`: divided by the
/// smallest whole factor that fits (frames of comps wider than the GPU's texture limit, #201).
pub fn fitted_size(size: [usize; 2], max_side: usize) -> [usize; 2] {
let k = size[0].max(size[1]).div_ceil(max_side.max(1)).max(1);
[size[0].div_ceil(k), size[1].div_ceil(k)]
}
/// `img` as a texture the renderer accepts: unchanged when it fits in `max_side`, else averaged
/// down by a whole factor (premultiplied pixels average correctly). The viewer draws it over the
/// same area, so only detail beyond the GPU's texture limit is lost, instead of the upload
/// failing (#201).
pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage {
let [w, h] = img.size;
let [fw, fh] = fitted_size(img.size, max_side);
if [fw, fh] == [w, h] {
return img;
}
let k = w.max(h).div_ceil(max_side.max(1)).max(1);
let px: Vec<egui::Color32> = (0..fw * fh)
.into_par_iter()
.map(|i| {
let (x0, y0) = ((i % fw) * k, (i / fw) * k);
let (mut sum, mut n) = ([0u32; 4], 0u32);
for y in y0..(y0 + k).min(h) {
for c in img.pixels.get(y * w + x0..y * w + (x0 + k).min(w)).unwrap_or_default() {
for (s, v) in sum.iter_mut().zip(c.to_array()) {
*s += v as u32;
}
n += 1;
}
}
let n = n.max(1);
let [r, g, b, a] = sum.map(|s| ((s + n / 2) / n) as u8);
egui::Color32::from_rgba_premultiplied(r, g, b, a)
})
.collect();
egui::ColorImage::new([fw, fh], px)
}
/// The largest texture side the renderer accepts.
pub fn max_texture_side(ctx: &egui::Context) -> usize {
ctx.input(|i| i.max_texture_side)
}
/// `ctx.load_texture` for frame-sized images: fitted to the renderer's texture limit first.
pub fn load_fitted(ctx: &egui::Context, name: impl Into<String>, img: egui::ColorImage, opts: egui::TextureOptions) -> egui::TextureHandle {
ctx.load_texture(name, fit_texture(img, max_texture_side(ctx)), opts)
}
impl Frames {
/// Retire only GPU preview state; documents, CPU frames and running job leases survive.
/// Publication checks the same latch while holding the cache lock, so a late GPU frame
@ -1015,6 +1064,26 @@ impl Queue {
mod tests {
use super::*;
/// Frames larger than the texture limit are averaged down by a whole factor (#201: an
/// 11000×2200 comp at Full resolution against an 8192 limit).
#[test]
fn frames_over_the_texture_limit_are_averaged_down() {
assert_eq!(fitted_size([11000, 2200], 8192), [5500, 1100]);
assert_eq!(fitted_size([8192, 100], 8192), [8192, 100]);
assert_eq!(fitted_size([30000, 30000], 16384), [15000, 15000]);
assert_eq!(fitted_size([0, 0], 0), [0, 0]);
// 5×2 (three columns of white, then black) into 2 px: 3×2 blocks (the last one 2×2).
let w = egui::Color32::WHITE;
let b = egui::Color32::from_rgba_premultiplied(0, 0, 0, 255);
let img = egui::ColorImage::new([5, 2], vec![w, w, w, b, b, w, w, w, b, b]);
let out = fit_texture(img.clone(), 2);
assert_eq!(out.size, [2, 1]);
assert_eq!(out.pixels, vec![w, b]);
assert_eq!(fit_texture(img.clone(), 5).pixels, img.pixels);
let mixed = fit_texture(egui::ColorImage::new([2, 1], vec![w, b]), 1);
assert_eq!(mixed.pixels, vec![egui::Color32::from_rgba_premultiplied(128, 128, 128, 255)]);
}
#[test]
fn retirement_reaches_existing_workers_and_keeps_cpu_frames_and_completion_live() {
let frames = Frames::default();

View File

@ -576,6 +576,11 @@ impl EffectcraftApp {
self.gpu.as_ref().map(effectcraft_engine::render::Accelerator::name)
}
/// The size of the viewer frame's texture (CPU frames; fitted to the GPU's texture limit).
pub fn viewer_texture_size(&self) -> Option<[usize; 2]> {
self.viewer_tex.as_ref().map(|(t, _)| t.size())
}
/// The viewer's current pixels as 8-bit premultiplied RGBA, reading a GPU frame back the
/// first time something asks for it.
///

View File

@ -58,7 +58,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Some((k, tex, size)) if k == key => Some((tex, size)),
_ => app.session.footage.frame(view.item, f, view.time).map(|img| {
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8());
let tex = ctx.load_texture("footage-panel", ci, egui::TextureOptions::LINEAR);
let tex = crate::frames::load_fitted(&ctx, "footage-panel", ci, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height])));
(tex, [img.width, img.height])
}),

View File

@ -147,7 +147,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let x0 = -buf.offset[0] / buf.scale;
let y0 = -buf.offset[1] / buf.scale;
let rect = [x0, y0, x0 + buf.img.width as f64 / buf.scale, y0 + buf.img.height as f64 / buf.scale];
let tex = ctx.load_texture("layer-panel", img, egui::TextureOptions::LINEAR);
let tex = crate::frames::load_fitted(&ctx, "layer-panel", img, egui::TextureOptions::LINEAR);
let c = Tex { key, tex, rect };
ctx.data_mut(|d| d.insert_temp(tex_id(), c.clone()));
c

View File

@ -122,7 +122,7 @@ pub fn draw_overlay(
let x0 = -inp.offset[0] / inp.scale;
let y0 = -inp.offset[1] / inp.scale;
let rect = [x0, y0, x0 + w as f64 / inp.scale, y0 + h as f64 / inp.scale];
let tex = ctx.load_texture("roto-overlay", img, egui::TextureOptions::NEAREST);
let tex = crate::frames::load_fitted(ctx, "roto-overlay", img, egui::TextureOptions::NEAREST);
let c = Overlay { key, tex, rect };
ctx.data_mut(|d| d.insert_temp(id, c.clone()));
Some(c)

View File

@ -397,7 +397,7 @@ fn aux_views(
_ => {
let opts = effectcraft_engine::render::RenderOpts { scale, view: comp.has_3d().then_some(cam), draft: true, ..Default::default() };
let img = app.session.render(cid, t, opts);
let tex = ctx.load_texture(format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR);
let tex = crate::frames::load_fitted(&ctx, format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
@ -459,7 +459,7 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo
dc.apply(&mut px);
ci = egui::ColorImage::new(ci.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect());
}
let tex = ctx.load_texture("viewer-locked", ci, egui::TextureOptions::LINEAR);
let tex = crate::frames::load_fitted(&ctx, "viewer-locked", ci, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
@ -1912,12 +1912,15 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames:
let opts = zoom_texture_options(smooth);
match img {
FrameImage::Cpu(img) => {
// Frames wider or taller than the GPU's texture limit go up averaged down (#201).
let max = crate::frames::max_texture_side(ctx);
let fitted = crate::frames::fit_texture((*img).clone(), max);
match &mut app.viewer_tex {
Some((tex, k)) if tex.size() == img.size => {
tex.set((*img).clone(), opts);
Some((tex, k)) if tex.size() == fitted.size => {
tex.set(fitted, opts);
*k = key;
}
_ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), opts), key)),
_ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", fitted, opts), key)),
}
app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k)| (t.id(), *k));
app.viewer_image = Some(img);

View File

@ -382,7 +382,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
_ => {
let img = crate::frames::to_color_image(&s.image);
let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure, dc) };
let tex = ctx.load_texture("viewer-snapshot", img, egui::TextureOptions::LINEAR);
let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
@ -432,7 +432,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), zoom_opts);
let tex = crate::frames::load_fitted(ctx, "viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), zoom_opts);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}

View File

@ -131,13 +131,15 @@ fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: Fram
}
let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
let tex = match (img, app.passive_tex.remove(&id)) {
(FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h)))) if h.size() == img.size => {
h.set((*img).clone(), opts);
(FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h))))
if h.size() == crate::frames::fitted_size(img.size, crate::frames::max_texture_side(ctx)) =>
{
h.set(crate::frames::fit_texture((*img).clone(), crate::frames::max_texture_side(ctx)), opts);
PassiveTexture::Cpu(h)
}
(FrameImage::Cpu(img), old) => {
free(app, old.map(|(_, t)| t));
PassiveTexture::Cpu(ctx.load_texture(format!("viewer-{id}"), (*img).clone(), opts))
PassiveTexture::Cpu(crate::frames::load_fitted(ctx, format!("viewer-{id}"), (*img).clone(), opts))
}
(FrameImage::Gpu(f), old) => {
let Some(rs) = &app.wgpu else { return };

View File

@ -108,10 +108,10 @@ fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) {
let opts = crate::panels::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
let t = match prev {
Some((_, mut t)) => {
t.set((*img).clone(), opts);
t.set(crate::frames::fit_texture((*img).clone(), crate::frames::max_texture_side(ctx)), opts);
t
}
None => ctx.load_texture("video-preview", (*img).clone(), opts),
None => crate::frames::load_fitted(ctx, "video-preview", (*img).clone(), opts),
};
ctx.data_mut(|d| d.insert_temp(tex_id, (k, t.clone())));
Some(t)

View File

@ -1019,3 +1019,26 @@ fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() {
drag_path(&mut h, &path, egui::Modifiers::SHIFT);
assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h));
}
/// A comp wider than the GPU's texture limit at Full resolution shows its frame (averaged down
/// into a texture the renderer accepts) instead of failing the upload (#201: 11000×2200).
#[test]
fn full_resolution_frames_wider_than_the_texture_limit_fit() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Wide", "width": 2400, "height": 400, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
// (egui's font atlas needs 1024.)
h.input_mut().max_texture_side = Some(1024);
h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Full;
let full = |h: &mut Harness<'_, EffectcraftApp>| h.state_mut().viewer_pixels().is_some_and(|px| px.size == [2400, 400]);
for _ in 0..400 {
h.step();
if full(&mut h) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().ui.viewer.res);
assert_eq!(h.state().viewer_texture_size(), Some([800, 134]), "2400×400 averaged by 3");
}

View File

@ -491,7 +491,8 @@ not read or written.
## Seeing the UI
To work on the UI, start the app with `cargo run -p effectcraft -- --control 9877` and use MCP bridge
To work on the UI, start the app with `cargo run -p effectcraft -- --control 9877` (add `--demo` to
open the demo project; without it the app starts with an empty project) and use MCP bridge
mode or the raw control channel. A good loop is: `ui_elements` to find an id, `ui_click` or `ui_drag`
to act, then `screenshot {"panel":"Timeline"}` to check the result. `render_frame` shows the
composition itself at any zoom.