Merge pull request #55 from storytold/m13-new-viewer

M13.32: View ▸ New Viewer — several Composition viewers
This commit is contained in:
BFlat authored and GitHub committed 2026-10-05 15:00:30 +07:00
commit 1ac55c33a3
13 files changed
+445 -28

No files matched your search

+8
View File
@@ -64,6 +64,14 @@ pub fn specs() -> Vec<CommandSpec> {
fe!("view.options", "View Options...", ["View"], Some("Cmd+Alt+U"), "{}", has_comp),
fe!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H"), "{value?}", always),
fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", always),
fe!(
"view.newViewer",
"New Viewer",
["View"],
Some("Alt+Shift+N"),
"{} — another Composition viewer of the active comp; the one in use is locked",
has_comp
),
// Panels and workspaces.
fe!(
"window.panel",
+1
View File
@@ -1046,6 +1046,7 @@ Animation
Reveal Properties with Animation | anim.reveal {"kind":"animation"}
Reveal All Modified Properties | anim.reveal {"kind":"modified"}
View
New Viewer | view.newViewer
Split with New Locked Viewer | view.splitLockedViewer
---
Zoom In | view.zoomIn
+3
View File
@@ -38,6 +38,8 @@ pub enum PanelKind {
EssentialGraphics,
/// A dockable ScriptUI panel (a script from the ScriptUI Panels folder), by script window id.
ScriptPanel(u32),
/// Another Composition viewer (View ▸ New Viewer), by viewer id (`panels::viewers`).
Viewer(u32),
LumetriScopes,
Footage,
MediaBrowser,
@@ -116,6 +118,7 @@ impl PanelKind {
PanelKind::ScriptConsole => "Script Console",
PanelKind::EssentialGraphics => "Essential Graphics",
PanelKind::ScriptPanel(_) => "ScriptUI Panel",
PanelKind::Viewer(_) => "Composition",
PanelKind::LumetriScopes => "Lumetri Scopes",
PanelKind::Footage => "Footage",
PanelKind::MediaBrowser => "Media Browser",
+25 -9
View File
@@ -25,9 +25,13 @@ impl EffectcraftApp {
out.push((PanelKind::ScriptPanel(w.id), t));
}
}
// Each Composition viewer is named after the comp it shows.
for id in std::iter::once(0).chain(self.ui.viewers.keys().copied().filter(|v| *v != 0)) {
if let Some(t) = panels::viewers::title(self, id) {
out.push((panels::viewers::panel(id), t));
}
}
let Some(comp) = self.session.active_comp() else { return out };
let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default();
out.push((PanelKind::Composition, format!("Composition {cname}")));
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name)));
out.push((PanelKind::Properties, format!("Properties: {}", l.name)));
@@ -64,10 +68,14 @@ impl EffectcraftApp {
fn tab_decos(&self) -> Vec<(PanelKind, dock::TabDeco)> {
let Some(cid) = self.session.active_comp_id() else { return vec![] };
let swatch = self.comp_swatch(cid);
let mut v: Vec<(PanelKind, dock::TabDeco)> = [PanelKind::Composition, PanelKind::Timeline]
.into_iter()
.map(|p| (p, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&p.id()), viewer: true }))
.collect();
let mut v: Vec<(PanelKind, dock::TabDeco)> =
vec![(PanelKind::Timeline, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&PanelKind::Timeline.id()), viewer: true })];
// Every Composition viewer: its comp's swatch and its own lock.
for id in std::iter::once(0).chain(self.ui.viewers.keys().copied().filter(|v| *v != 0)) {
let p = panels::viewers::panel(id);
let swatch = panels::viewers::comp_of(self, id).map_or(self.tokens.text_faint, |c| self.comp_swatch(c));
v.push((p, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&p.id()), viewer: true }));
}
// Effect Controls carries the selected layer's label colour.
if let Some(l) = self.session.active_comp().and_then(|c| self.session.state.selected_layers.first().and_then(|id| c.layer(*id)))
&& l.label != effectcraft_engine::color::Label::None
@@ -123,6 +131,7 @@ impl EffectcraftApp {
}
self.ui.dock.close(p);
self.edit_layout(|l| l.unfloat(p));
panels::viewers::on_close(self, p);
if self.ui.maximized == Some(p) {
self.ui.maximized = None;
}
@@ -141,7 +150,8 @@ impl EffectcraftApp {
fn panel_body(&mut self, ui: &mut egui::Ui, p: PanelKind, rect: Rect) {
self.auto.add(&format!("panel.{}", p.id()), rect, p.title());
let mut content = rect;
if p == PanelKind::Composition && !self.ui.start_screen && panels::precomp::has_flow(self) {
let viewer = p == panels::viewers::active_panel(self);
if viewer && !self.ui.start_screen && panels::precomp::has_flow(self) {
// Composition Navigator: the flow of nested comps above the viewer.
let nav = Rect::from_min_size(content.min, vec2(content.width(), panels::precomp::NAV_H));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(nav).id_salt("comp-navigator"));
@@ -152,7 +162,7 @@ impl EffectcraftApp {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())));
child.set_clip_rect(content.intersect(ui.clip_rect()));
panels::show(self, &mut child, p, content);
if p == PanelKind::Composition && !self.ui.start_screen {
if viewer && !self.ui.start_screen {
panels::anim_tools::sketch_overlay(self, &mut child);
}
}
@@ -220,7 +230,13 @@ impl EffectcraftApp {
DockAction::ToggleStacked(p) => {
self.ui.dock.toggle_stacked(p);
}
DockAction::Focus(p) => self.ui.focused = p,
DockAction::Focus(p) => {
self.ui.focused = p;
// A click in (or on the tab of) another Composition viewer makes it active.
if let Some(id) = panels::viewers::id_of(p) {
panels::viewers::activate(self, &ctx, id);
}
}
DockAction::Close(p) => self.close_panel(p),
DockAction::PanelMenu(p, pos) => {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("panel-menu"), (p, pos)));
+3 -3
View File
@@ -928,18 +928,18 @@ mod tests {
let mut s = Session::default();
let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let f = Frames::default();
let mut first = None;
let mut last = None;
for i in 0..200 {
s.execute("comp.settings", json!({"width": 8 + i, "merge": "drag"})).unwrap();
let k = FrameKey { content: f.content_of(&s.project, s.revision, a), ..key(0, 0, 0) };
insert(&f, k);
first.get_or_insert(k);
last = Some(k);
}
// The snapshots and the frames kept stay bounded (a dropped identity's frames go with it,
// so a later state that reuses its addresses can't be shown an old frame).
assert!(f.keepers.lock().unwrap().1.len() <= MAX_KEEPERS);
assert!(f.cache.lock().unwrap().map.len() <= MAX_KEEPERS);
assert!(f.is_cached(&last.unwrap()), "the current state's frame stays");
assert!(!f.is_cached(&first.unwrap()), "the oldest states' frames went with their snapshots");
}
#[test]
+9 -11
View File
@@ -187,6 +187,8 @@ pub struct EffectcraftApp {
pub(crate) toast: Option<(String, f64)>,
/// Texture of the last CPU frame shown in the viewer and the key it came from.
pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey)>,
/// The frames the other (passive) Composition viewers show, by viewer id.
pub(crate) passive_tex: std::collections::HashMap<u32, (FrameKey, panels::viewers::PassiveTexture)>,
/// The last GPU frame shown: its egui texture id (registered with egui-wgpu), key and texture.
pub(crate) viewer_native: Option<(egui::TextureId, FrameKey, Arc<effectcraft_gpu::DisplayFrame>)>,
/// The texture the viewer draws and its frame key (CPU or GPU frame).
@@ -261,6 +263,7 @@ impl EffectcraftApp {
last_activity: (0.0, 0),
toast: None,
viewer_tex: None,
passive_tex: std::collections::HashMap::new(),
viewer_native: None,
viewer_shown: None,
viewer_image: None,
@@ -377,7 +380,7 @@ impl EffectcraftApp {
}
if !self.ui.dock.contains(p) {
let near = match p {
PanelKind::Layer | PanelKind::Flowchart => PanelKind::Composition,
PanelKind::Layer | PanelKind::Flowchart | PanelKind::Viewer(_) => PanelKind::Composition,
PanelKind::RenderQueue => PanelKind::Timeline,
PanelKind::EffectControls | PanelKind::History => PanelKind::Project,
_ => PanelKind::EffectsPresets,
@@ -619,8 +622,9 @@ impl EffectcraftApp {
self.session.set_time(fr.tick_of(plan.first));
}
let restore_max = preset.full_screen.then_some(self.ui.maximized);
if preset.full_screen && self.ui.dock.contains(PanelKind::Composition) {
self.ui.maximized = Some(PanelKind::Composition);
let viewer = panels::viewers::active_panel(self);
if preset.full_screen && self.ui.dock.contains(viewer) {
self.ui.maximized = Some(viewer);
}
self.playback = Playback {
playing: true,
@@ -1118,18 +1122,12 @@ impl EffectcraftApp {
fn handle_events(&mut self, ctx: &egui::Context) {
for ev in self.session.drain_events() {
match ev {
effectcraft_engine::Event::OpenComp(_) => {
// The comp's viewer and its Timeline tab come to the front.
for p in [PanelKind::Composition, PanelKind::Timeline] {
if !self.ui.locked_tabs.contains(&p.id()) {
self.ui.dock.activate(p);
}
}
effectcraft_engine::Event::OpenComp(c) => {
panels::viewers::on_open_comp(self, c);
self.ui.timeline.pps = None;
}
effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))),
effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
// Frames of earlier revisions can't be shown again.
// Frames are keyed by content: an edit keeps those of comps it doesn't touch.
effectcraft_engine::Event::ProjectChanged { .. } => {}
effectcraft_engine::Event::Frontend { command, params } => {
+2
View File
@@ -562,6 +562,8 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
Value::Null
}
// Window ▸ <ScriptUI panel>: dock (or bring forward) the panel the script built.
// View ▸ New Viewer.
"view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}),
"window.scriptPanel" => {
let id = p.get("window").and_then(Value::as_u64).ok_or("no ScriptUI panel window")? as u32;
app.show_panel(PanelKind::ScriptPanel(id));
+5 -2
View File
@@ -51,6 +51,7 @@ pub mod viewer;
pub mod viewer_overlays;
pub mod viewer_text;
pub mod viewer_tools;
pub mod viewers;
pub mod waveform;
use effectcraft_engine::Session;
@@ -82,7 +83,8 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String {
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) {
match p {
PanelKind::Composition => viewer::show(app, ui, rect),
PanelKind::Composition => viewers::show(app, ui, 0, rect),
PanelKind::Viewer(n) => viewers::show(app, ui, n, rect),
PanelKind::Timeline => timeline::show(app, ui, rect),
PanelKind::Project => project::show(app, ui, rect),
PanelKind::EffectControls => effect_controls::show(app, ui, rect),
@@ -161,8 +163,9 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
close = true;
}
}
PanelKind::Composition => {
PanelKind::Composition | PanelKind::Viewer(_) => {
for (label, cmd) in [
("New Viewer", "view.newViewer"),
("Composition Settings…", "app.compSettings"),
("View Options…", "view.layerControls"),
("Show Grid", "view.grid"),
+253
View File
@@ -0,0 +1,253 @@
//! Several Composition viewers (View ▸ New Viewer), as in After Effects. Viewer 0 is the
//! Composition panel, the others are `PanelKind::Viewer(n)` panels. One viewer is *active*: it
//! is the full interactive viewer and shows the active comp. The others show their own comp's
//! frame at that comp's current time; a click (or their tab) makes them active and their comp
//! the active one. New Viewer locks the viewer in use, so it keeps its comp. Opening a comp while
//! the active viewer is locked shows it in an unlocked viewer, or in a new one if every viewer is
//! locked.
use std::sync::Arc;
use effectcraft_engine::project::ItemId;
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use serde_json::json;
use crate::EffectcraftApp;
use crate::dock::PanelKind;
use crate::frames::{FrameImage, FrameKey};
use crate::theme::Tokens;
/// A passive viewer's texture: the frame it shows.
pub enum PassiveTexture {
Cpu(egui::TextureHandle),
Gpu(egui::TextureId, Arc<effectcraft_gpu::DisplayFrame>),
}
impl PassiveTexture {
fn id(&self) -> egui::TextureId {
match self {
PassiveTexture::Cpu(h) => h.id(),
PassiveTexture::Gpu(id, _) => *id,
}
}
}
/// The dock panel of viewer `id` (0 = the Composition panel).
pub fn panel(id: u32) -> PanelKind {
if id == 0 { PanelKind::Composition } else { PanelKind::Viewer(id) }
}
/// The viewer a panel is, if it is one.
pub fn id_of(p: PanelKind) -> Option<u32> {
match p {
PanelKind::Composition => Some(0),
PanelKind::Viewer(n) => Some(n),
_ => None,
}
}
/// The active viewer's panel.
pub fn active_panel(app: &EffectcraftApp) -> PanelKind {
panel(app.ui.active_viewer)
}
pub fn locked(app: &EffectcraftApp, id: u32) -> bool {
app.ui.locked_tabs.contains(&panel(id).id())
}
/// Viewer `id`'s panel is docked or floating.
fn open(app: &EffectcraftApp, id: u32) -> bool {
let p = panel(id);
app.ui.dock.contains(p) || app.ui.floating.iter().any(|f| f.panels.contains(&p))
}
/// The comp viewer `id` shows: the active comp in the active viewer, else its own.
pub fn comp_of(app: &EffectcraftApp, id: u32) -> Option<ItemId> {
if id == app.ui.active_viewer {
return app.session.active_comp_id();
}
app.ui.viewers.get(&id).copied().flatten().map(ItemId).filter(|c| app.session.project.comp(*c).is_some())
}
/// Draw viewer `id`: the active one is the full viewer; another shows its comp's frame and
/// becomes the active viewer when clicked.
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) {
if id == app.ui.active_viewer {
let c = app.session.active_comp_id().map(|c| c.0);
app.ui.viewers.insert(id, c);
super::viewer::show(app, ui, rect);
return;
}
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, t.pasteboard);
let resp = ui.interact(rect, egui::Id::new(("passive-viewer", id)), Sense::click()).on_hover_text("Click to make this viewer active");
app.auto.add(&format!("viewers.{id}"), rect, "Composition viewer (click to make it active)");
match comp_of(app, id).and_then(|cid| Some((cid, app.session.project.comp(cid)?.clone()))) {
Some((cid, c)) => {
let (cw, ch) = ((c.width.max(1) as f64 * c.pixel_aspect.max(0.01)) as f32, c.height.max(1) as f32);
let fit = ((rect.width() - 24.0) / cw).min((rect.height() - 24.0) / ch).max(0.01);
let r = Rect::from_center_size(rect.center(), vec2(cw * fit, ch * fit));
// Render at the size it is drawn (eighths, so resizing reuses frames), at most 100%.
let scale = ((fit * ctx.pixels_per_point()) as f64 * 8.0).ceil().clamp(1.0, 8.0) / 8.0;
let frame = c.frame_rate.frame_at(app.session.time_of(cid));
app.request_frame(cid, frame, scale);
let key = app.frame_key(cid, frame, scale);
if let Some(img) = app.frames.get(&key) {
set_texture(app, &ctx, id, key, img);
}
match app.passive_tex.get(&id).filter(|(k, _)| k.comp == cid.0) {
Some((_, tex)) => {
p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
None => {
let bg = c.background;
p.rect_filled(r, 0.0, Color32::from_rgb((bg[0] * 255.0) as u8, (bg[1] * 255.0) as u8, (bg[2] * 255.0) as u8));
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
}
p.rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside);
}
None => {
p.text(rect.center(), Align2::CENTER_CENTER, "Click to show the active composition here", Tokens::ui(12.0), t.text_faint);
}
}
if resp.clicked() {
activate(app, &ctx, id);
}
}
/// Keep the frame viewer `id` shows in its texture.
fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: FrameKey, img: FrameImage) {
if app.passive_tex.get(&id).is_some_and(|(k, _)| *k == key) {
return;
}
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);
PassiveTexture::Cpu(h)
}
(FrameImage::Cpu(img), old) => {
free(app, old.map(|(_, t)| t));
PassiveTexture::Cpu(ctx.load_texture(format!("viewer-{id}"), (*img).clone(), opts))
}
(FrameImage::Gpu(f), old) => {
let Some(rs) = &app.wgpu else { return };
let view = f.texture.create_view(&Default::default());
let filter = if app.session.prefs.viewer_zoom_smooth() { eframe::wgpu::FilterMode::Linear } else { eframe::wgpu::FilterMode::Nearest };
let mut ren = rs.renderer.write();
let tid = match old {
Some((_, PassiveTexture::Gpu(tid, _))) => {
ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, filter, tid);
tid
}
_ => ren.register_native_texture(&rs.device, &view, filter),
};
PassiveTexture::Gpu(tid, f)
}
};
app.passive_tex.insert(id, (key, tex));
}
/// 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) {
rs.renderer.write().free_texture(&id);
}
}
/// Make viewer `id` the active one: its comp becomes the active comp.
pub fn activate(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32) {
if id == app.ui.active_viewer || !open(app, id) {
return;
}
let active = app.session.active_comp_id();
let target = comp_of(app, id);
app.ui.viewers.insert(app.ui.active_viewer, active.map(|c| c.0));
app.ui.active_viewer = id;
match target {
Some(c) if Some(c) != active => {
if let Err(e) = crate::menus::invoke(app, ctx, "comp.open", json!({"comp": c.0})) {
app.ui.status = e;
}
}
_ => {
app.ui.viewers.insert(id, active.map(|c| c.0));
}
}
}
/// A comp was opened (comp.open, a Timeline tab, a precomp double-click): it shows in the active
/// viewer, unless that one is locked to another comp: then in an unlocked viewer, or a new one.
pub fn on_open_comp(app: &mut EffectcraftApp, comp: ItemId) {
let av = app.ui.active_viewer;
let shown = app.ui.viewers.get(&av).copied().flatten().filter(|s| app.session.project.comp(ItemId(*s)).is_some());
if locked(app, av) && shown.is_some_and(|s| s != comp.0) {
let free = app.ui.viewers.keys().copied().find(|v| *v != av && !locked(app, *v) && open(app, *v));
app.ui.active_viewer = match free {
Some(v) => v,
None => add_viewer(app, av),
};
}
app.ui.viewers.insert(app.ui.active_viewer, Some(comp.0));
// The comp's viewer and its Timeline tab come to the front (unless locked).
for p in [active_panel(app), PanelKind::Timeline] {
if !app.ui.locked_tabs.contains(&p.id()) {
app.ui.dock.activate(p);
}
}
}
/// View ▸ New Viewer: a new viewer of the active comp next to the one in use, which is locked
/// (so it keeps its comp). Returns the new viewer's id.
pub fn new_viewer(app: &mut EffectcraftApp) -> u32 {
let prev = app.ui.active_viewer;
let active = app.session.active_comp_id().map(|c| c.0);
app.ui.viewers.insert(prev, active);
app.ui.locked_tabs.insert(panel(prev).id());
let id = add_viewer(app, prev);
app.ui.viewers.insert(id, active);
app.ui.active_viewer = id;
app.show_panel(panel(id));
id
}
/// Dock a new viewer panel as a tab next to viewer `near`.
fn add_viewer(app: &mut EffectcraftApp, near: u32) -> u32 {
let id = app.ui.viewers.keys().copied().max().unwrap_or(0).saturating_add(1).max(1);
app.ui.viewers.insert(id, None);
let p = panel(id);
let anchor = if app.ui.dock.contains(panel(near)) { panel(near) } else { PanelKind::Composition };
app.ui.dock.open_near(p, anchor);
id
}
/// A viewer panel was closed: forget it (the Composition panel keeps its slot), and if it was the
/// active viewer another open one takes over.
pub fn on_close(app: &mut EffectcraftApp, p: PanelKind) {
let Some(id) = id_of(p) else { return };
let tex = app.passive_tex.remove(&id).map(|(_, t)| t);
free(app, tex);
if id != 0 {
app.ui.viewers.remove(&id);
app.ui.locked_tabs.remove(&p.id());
}
if app.ui.active_viewer == id {
let next = std::iter::once(0).chain(app.ui.viewers.keys().copied()).find(|v| *v != id && open(app, *v)).unwrap_or(0);
app.ui.active_viewer = next;
// The viewer taking over keeps its comp (which becomes the active comp).
if let Some(c) = app.ui.viewers.get(&next).copied().flatten().map(ItemId).filter(|c| app.session.project.comp(*c).is_some())
&& app.session.active_comp_id() != Some(c)
{
app.session.open_comp(c);
}
}
}
/// The tab label of viewer `id` ("Composition Intro").
pub fn title(app: &EffectcraftApp, id: u32) -> Option<String> {
let c = comp_of(app, id)?;
Some(format!("Composition {}", app.session.project.item(c)?.name))
}
+9
View File
@@ -542,6 +542,13 @@ pub struct UiState {
/// bring a locked panel forward (the tab's lock icon, as After Effects' viewer lock).
#[serde(default)]
pub locked_tabs: BTreeSet<String>,
/// The comp each Composition viewer shows, by viewer id (0 = the Composition panel; see
/// `panels::viewers`).
#[serde(default)]
pub viewers: std::collections::BTreeMap<u32, Option<u64>>,
/// The active viewer (the interactive one, showing the active comp).
#[serde(default)]
pub active_viewer: u32,
pub focused: PanelKind,
pub viewer: ViewerState,
pub timeline: TimelineState,
@@ -650,6 +657,8 @@ impl Default for UiState {
saved_floating: Default::default(),
maximized: None,
locked_tabs: BTreeSet::new(),
viewers: Default::default(),
active_viewer: 0,
focused: PanelKind::Composition,
viewer: ViewerState::default(),
timeline: TimelineState::default(),
+115
View File
@@ -0,0 +1,115 @@
//! View ▸ New Viewer: several Composition viewers. New Viewer locks the viewer in use; opening a
//! comp goes to an unlocked viewer (a new one when every viewer is locked); a click on another
//! viewer makes it the active one; closing a viewer forgets it.
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::{PanelKind, Zone};
use effectcraft_ui_egui::panels::viewers;
use egui::{Event, pos2};
use egui_kittest::Harness;
use serde_json::{Value, json};
fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, p: Value) {
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, p).unwrap();
h.run_steps(3);
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
let p = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
}
fn shows(h: &Harness<'_, EffectcraftApp>, viewer: u32) -> Option<u64> {
viewers::comp_of(h.state(), viewer).map(|c| c.0)
}
#[test]
fn new_viewers_lock_route_comps_and_activate_on_click() {
let mut s = Session::default();
let new =
|s: &mut Session, name: &str| s.execute("comp.new", json!({"name": name, "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let c = new(&mut s, "C");
let b = new(&mut s, "B");
let a = new(&mut s, "A");
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
// View ▸ New Viewer: viewer 1 shows A too, viewer 0 (the Composition panel) is locked.
invoke(&mut h, "view.newViewer", json!({}));
assert!(h.state().ui.dock.contains(PanelKind::Viewer(1)));
assert!(viewers::locked(h.state(), 0) && !viewers::locked(h.state(), 1));
assert_eq!((h.state().ui.active_viewer, shows(&h, 0), shows(&h, 1)), (1, Some(a), Some(a)));
// Side by side, so both draw.
assert!(h.state_mut().edit_layout(|l| l.dock(PanelKind::Viewer(1), PanelKind::Composition, Zone::Right)));
h.state_mut().ui.dock.activate(PanelKind::Composition);
h.run_steps(3);
// Opening B shows it in the active (unlocked) viewer; the locked one keeps A.
invoke(&mut h, "comp.open", json!({"comp": b}));
assert_eq!((h.state().ui.active_viewer, shows(&h, 0), shows(&h, 1)), (1, Some(a), Some(b)));
assert!(h.state().auto.find("viewers.0").is_some(), "the other viewer draws passively");
// A click on viewer 0 makes it active, and A the active comp.
click(&mut h, "viewers.0");
assert_eq!(h.state().ui.active_viewer, 0);
assert_eq!(h.state().session.active_comp_id(), Some(ItemId(a)));
assert_eq!(shows(&h, 1), Some(b));
// Viewer 0 is locked to A: opening C goes to the unlocked viewer 1.
invoke(&mut h, "comp.open", json!({"comp": c}));
assert_eq!((h.state().ui.active_viewer, shows(&h, 0), shows(&h, 1)), (1, Some(a), Some(c)));
// Every viewer locked: opening B makes a new viewer.
h.state_mut().ui.locked_tabs.insert(PanelKind::Viewer(1).id());
invoke(&mut h, "comp.open", json!({"comp": b}));
assert_eq!(h.state().ui.active_viewer, 2);
assert!(h.state().ui.dock.contains(PanelKind::Viewer(2)));
assert_eq!((shows(&h, 0), shows(&h, 1), shows(&h, 2)), (Some(a), Some(c), Some(b)));
// Closing the active viewer forgets it; another open viewer takes over.
h.state_mut().close_panel(PanelKind::Viewer(2));
h.run_steps(2);
assert!(!h.state().ui.viewers.contains_key(&2));
// Viewer 0 takes over with its own comp (it is locked to A), which becomes the active comp.
assert_eq!(h.state().ui.active_viewer, 0);
assert_eq!((shows(&h, 0), h.state().session.active_comp_id()), (Some(a), Some(ItemId(a))));
// The tab names the comp each viewer shows.
let titles: Vec<String> = h
.state()
.auto
.elements
.iter()
.filter(|e| e.id.starts_with("panel.tab.Composition") || e.id.starts_with("panel.tab.Viewer"))
.map(|e| e.label.clone())
.collect();
assert!(titles.iter().any(|t| t == "Composition A") && titles.iter().any(|t| t == "Composition C"), "{titles:?}");
}
/// `cargo test -p effectcraft-ui-egui --test ui_viewers -- --ignored`: two viewers side by side
/// (`target/test-out/viewers.png`).
#[test]
#[ignore]
fn viewers_snapshot() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let other = s.project.items.values().filter(|i| i.as_comp().is_some()).map(|i| i.id).find(|id| Some(*id) != s.active_comp_id()).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
invoke(&mut h, "view.newViewer", json!({}));
assert!(h.state_mut().edit_layout(|l| l.dock(PanelKind::Viewer(1), PanelKind::Composition, Zone::Right)));
h.state_mut().ui.dock.activate(PanelKind::Composition);
invoke(&mut h, "comp.open", json!({"comp": other.0}));
for _ in 0..300 {
h.step();
if h.state().frames.inflight() == 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
h.run_steps(4);
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out");
std::fs::create_dir_all(dir).unwrap();
h.render().unwrap().save(format!("{dir}/viewers.png")).unwrap();
}
+2 -1
View File
@@ -86,7 +86,8 @@ Points are logical window coordinates. A target is `{id}` (the element's centre;
Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.tab.EffectControls`,
`panel.tab.Timeline.<comp>` (one Timeline tab per open comp; `panel.tab.Timeline` is the shown
one), `header.workspace.Animation`, `project.item.<id>` (a double-click opens the item, Enter
one), `panel.tab.Viewer(<n>)` and `viewers.<n>` (View ▸ New Viewer's other Composition viewers;
`viewers.0` is the Composition panel while another viewer is active), `header.workspace.Animation`, `project.item.<id>` (a double-click opens the item, Enter
renames it), `viewer.comp`, `viewer.handle.<layer>.<i>`,
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.layer.<id>.row` (click selects,
Enter or a double-click renames, drag reorders), `timeline.prop.<uid>.stopwatch`,
+10 -2
View File
@@ -163,7 +163,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) |
| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 |
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs; View ▸ New Viewer (several unlocked viewers) (an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
| Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
| Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
@@ -422,6 +422,14 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra
spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph
Editor keys are small squares whatever their interpolation, as in After Effects.
Since (M13.32): View ▸ New Viewer (Alt+Shift+N, also in the Composition panel menu) opens
another Composition viewer as its own panel, as in After Effects. The viewer in use is locked,
so it keeps its comp. One viewer is active: it is the interactive one and shows the active comp.
The others show their own comp's frame at that comp's current time, and a click (or their tab)
makes them active. Opening a comp while the active viewer is locked shows it in an unlocked
viewer, or in a new one if every viewer is locked. Closing a viewer hands over to another one,
with that viewer's comp. Each viewer's tab is named after its comp, with its own lock.
Since (M4.14): RAM preview frames are keyed by their comp's content, not the project revision. An
edit keeps the cached frames (the green bar) of every comp it doesn't touch, and undo finds the
frames of the state it returns to, as in After Effects. The identity costs about 0.1 ms on the
@@ -743,7 +751,7 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r
animators).~~ (M4.13)
17. ~~Content-keyed RAM preview frames, so an edit keeps the frames of comps it doesn't touch.~~
(M4.14)
18. View ▸ New Viewer (several unlocked Composition viewers), nested comp markers on the
18. ~~View ▸ New Viewer (several Composition viewers)~~ (M13.32); nested comp markers on the
precomp layer bar, audio scrubbing.
At the original audit the engine underneath (keyframes, expressions, shape operators, text