mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #55 from storytold/m13-new-viewer
M13.32: View ▸ New Viewer — several Composition viewers
This commit is contained in:
13 files changed
+445
-28
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +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)));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user