Compare commits

...

5 Commits

Author SHA1 Message Date
bflatastic 97390f15fb M13.32: View ▸ New Viewer — several Composition viewers
- PanelKind::Viewer(n): more Composition viewers (panels::viewers). One is active (the full
  interactive viewer, showing the active comp); the others show their own comp's frame at that
  comp's current time (CPU or GPU frames, their own textures) and become active on a click or
  their tab, their comp becoming the active comp.
- view.newViewer (View menu, the Composition panel menu, Alt+Shift+N): a new viewer of the
  active comp next to the one in use, which is locked. Opening a comp while the active viewer
  is locked shows it in an unlocked viewer, or a new one when every viewer is locked. Closing
  the active viewer hands over to another with its comp. Tabs are named after their comp, with
  their own lock and swatch; Preview full screen maximizes the active viewer.
- UI state: viewers (comp per viewer) and active_viewer (serde).
- Tests: ui_viewers (lock on New Viewer, routing, click to activate, all locked → new viewer,
  close hand-over, tab titles; an ignored snapshot). The bounded-drag frames test asserts what
  holds when freed addresses are reused. Docs: parity, control protocol.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:00:11 +07:00
BFlat 6a5abad753
Merge pull request #54 from storytold/m4-content-keyed-frames
M4.14: content-keyed RAM preview frames (M4 done); proxies in the disk cache key
2026-10-05 14:39:33 +07:00
bflatastic 26c714db7f M4.14: content-keyed RAM preview frames; proxies in the disk cache's content key
- FrameKey's identity is the comp's content (frames::comp_content), not the project revision:
  an edit keeps the cached frames of comps it doesn't touch and undo finds the frames of the
  state it returns to. The identity hashes the addresses of the comps a comp draws (~0.1 ms on
  the 1500-layer test project; the full content hash took ~117 ms), by value the footage, solids,
  proxies and project settings; with expressions, every item. Frames::content_of keeps a
  project snapshot per identity with frames (or in flight), so Arc::make_mut always copies an
  edited comp and its address can't be reused meanwhile; past 64 identities the least recently
  used go with their frames (long drags).
- drop_stale is gone (frames of other contents are evicted least-recently-shown first).
- disk_cache::comp_content_key covers items' proxies and Use Proxy (it served stale frames).
- Tests: frames (content keys, edits elsewhere and undo, bounded drags, in-place edit safety),
  ui_ram_preview (end to end), the disk cache proxy key. Docs: roadmap (M4 done), parity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:39:17 +07:00
bflatastic 4f7723a7b6 M13.31: Home size column test uses an unambiguous size (12.5 KB rounds to even)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:33:44 +07:00
BFlat 0f1cad8bbd
Merge pull request #53 from storytold/ci-packaging-lint
CI: fix Packaging lint (SC2015 in release.yml)
2026-10-05 13:52:21 +07:00
21 changed files with 763 additions and 77 deletions

View File

@ -10,7 +10,7 @@ once.
| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done |
| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done |
| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) |
| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content; left: content-keyed RAM preview frames) |
| M4 | Preview: precomps, motion blur, cached playback | Done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content, content-keyed RAM preview frames) |
| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done |
| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done |
| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review |
@ -33,6 +33,6 @@ Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026)
- **≈ 8.5 agent-hours of work remain**: about **2–3 hours of wall-clock time** with five Claude
Opus 5.5 agents in parallel, ≈ 1 hour without learned models for Roto Brush and face tracking.
- What is left: GPU kernels for the remaining visual effects, and learned-model quality for Roto
Brush and face tracking; in previews, content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
Brush and face tracking ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).

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",

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

View File

@ -119,4 +119,13 @@ fn footage_changes_change_the_salt() {
// Unrelated comps do not change it.
s.execute("comp.new", json!({"name": "Other", "width": 10, "height": 10, "frameRate": 30, "duration": 1})).unwrap();
assert_eq!(effectcraft_render::disk_cache::comp_content_key(&s.project, cid), k2);
// A proxy, and its Use Proxy switch, change the pixels.
let set_proxy = |s: &mut Session, enabled: bool| {
let it = std::sync::Arc::make_mut(&mut s.project).items.get_mut(&cid).unwrap();
it.proxy = Some(Box::new(effectcraft_project::Proxy { footage: effectcraft_project::Footage::default(), enabled }));
effectcraft_render::disk_cache::comp_content_key(&s.project, cid)
};
let k3 = set_proxy(&mut s, true);
assert_ne!(k3, k2);
assert_ne!(set_proxy(&mut s, false), k3);
}

View File

@ -778,8 +778,9 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 {
}
/// Content key of a composition's pixels: project render settings, the comp and everything it
/// uses (nested comps, footage, solids, footage files' size and time). With expressions in play
/// (which can read any comp) the whole project counts.
/// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy
/// files' size and time). With expressions in play (which can read any comp) the whole project
/// counts.
pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 {
use effectcraft_project::{ItemKind, LayerSource};
let mut h = Hash128::default();
@ -813,6 +814,10 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf
if let ItemKind::Footage(f) = &it.kind {
footage_stamp(&mut h, f);
}
hash_debug(&mut h, &it.proxy);
if let Some(px) = &it.proxy {
footage_stamp(&mut h, &px.footage);
}
}
h.write_u64(comp.0);
h.finish()

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",

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)));

View File

@ -1,11 +1,11 @@
//! Background frame rendering and the RAM preview cache.
//!
//! Frames render on a dedicated thread pool from immutable `Arc<Project>` snapshots, so the UI
//! never waits for the compositor. Results land in a memory-budgeted cache keyed by (project
//! revision, comp, frame, scale, view, render options); the timeline draws the cached range as
//! the green cache bar. When the budget is full the least recently shown frames go first, and
//! frames of an older revision are dropped as soon as the project changes (they can never be
//! shown again).
//! never waits for the compositor. Results land in a memory-budgeted cache keyed by the comp's
//! *content* ([`comp_content`]: the comp and everything it uses), frame, scale, view and render
//! options; the timeline draws the cached range as the green cache bar. An edit keeps the frames
//! of every comp it doesn't touch, and undo finds the frames of the state it returns to, as in
//! After Effects. When the budget is full the least recently shown frames go first.
//!
//! wasm32 has no threads: jobs wait in the same priority queue and [`Frames::pump`] hands them
//! to a [`RemoteFrames`] (the browser's frame worker, a second engine instance fed with project
@ -22,16 +22,19 @@
//! registers with egui-wgpu and draws directly; pixels are read back only when something needs
//! them (Info panel, eyedroppers, histograms).
use std::collections::{HashMap, HashSet, VecDeque};
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::sync::{Arc, Mutex};
use effectcraft_engine::project::{ItemId, Project};
use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, Node, Project, PropGroup};
use effectcraft_engine::render::disk_cache::{self, DiskCache};
use effectcraft_engine::render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_engine::time::Tick;
use effectcraft_gpu::{DisplayFrame, Gpu};
use rayon::prelude::*;
/// Comp identities (and the project snapshots behind them) the RAM preview keeps frames of.
const MAX_KEEPERS: usize = 64;
/// GPU frames kept for RAM preview (bytes of video memory).
const GPU_BUDGET: usize = 1 << 30;
@ -57,9 +60,14 @@ impl FrameImage {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
/// A viewer frame: its comp's content, the frame, scale, view and render options. `revision` is
/// the project revision it was asked for at (job bookkeeping); it is not part of the identity, so
/// a later revision with the same content finds the frame.
#[derive(Clone, Copy, Debug)]
pub struct FrameKey {
pub revision: u64,
/// The comp's content key ([`Frames::content_of`]).
pub content: u64,
pub comp: u64,
pub frame: i64,
/// Render scale × 1000.
@ -71,12 +79,31 @@ pub struct FrameKey {
}
impl FrameKey {
/// Whether `self` is a frame of the same comp, revision, scale, view and options as `base`.
/// Everything but the revision.
fn id(&self) -> (u64, u64, i64, u32, u64, u64) {
(self.content, self.comp, self.frame, self.scale, self.view, self.opts)
}
/// Whether `self` is a frame of the same comp content, scale, view and options as `base`.
pub fn same_series(&self, base: &FrameKey) -> bool {
FrameKey { frame: base.frame, ..*self } == *base
}
}
impl PartialEq for FrameKey {
fn eq(&self, o: &FrameKey) -> bool {
self.id() == o.id()
}
}
impl Eq for FrameKey {}
impl std::hash::Hash for FrameKey {
fn hash<H: std::hash::Hasher>(&self, h: &mut H) {
self.id().hash(h);
}
}
struct Cache {
map: HashMap<FrameKey, FrameImage>,
order: VecDeque<FrameKey>,
@ -129,6 +156,14 @@ impl Cache {
self.order.push_back(k);
}
}
/// Drop every frame of the identities in `gone`.
fn drop_contents(&mut self, gone: &HashSet<u64>) {
let keys: Vec<FrameKey> = self.map.keys().filter(|k| gone.contains(&k.content)).copied().collect();
for k in &keys {
self.remove(k);
}
self.order.retain(|k| !gone.contains(&k.content));
}
fn evict_to_budget(&mut self) {
while self.bytes > self.budget {
let Some(old) = self.order.pop_front() else { break };
@ -173,6 +208,54 @@ fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64)
k
}
/// The identity of a comp's pixels in `project`, cheap enough to take at every revision: the
/// addresses of the comps it draws (an edit replaces a comp's `Arc`: `Arc::make_mut` copies it
/// while another snapshot holds it, and [`Frames::content_of`] keeps one for every identity it
/// hands out), and by value the project settings and the footage, solids and proxies it uses.
/// With expressions in its comps (which can read anything) every item counts.
pub fn comp_content(project: &Project, comp: ItemId) -> u64 {
use std::hash::{Hash, Hasher};
fn has_expression(g: &PropGroup) -> bool {
g.children.iter().any(|c| match c {
Node::Prop(p) => p.expr.is_some(),
Node::Group(sub) => has_expression(sub),
})
}
let mut h = std::collections::hash_map::DefaultHasher::new();
format!("{:?}", project.settings).hash(&mut h);
let mut seen = BTreeSet::new();
let mut stack = vec![comp];
let mut expressions = false;
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
let Some(ItemKind::Comp(c)) = project.item(id).map(|i| &i.kind) else { continue };
for l in &c.layers {
if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source {
stack.push(item);
}
expressions = expressions || has_expression(&l.props);
}
}
let ids: Vec<ItemId> = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() };
for id in ids {
let Some(it) = project.item(id) else { continue };
id.0.hash(&mut h);
if expressions {
// (`comp("Name")`, `footage("Name")`)
it.name.hash(&mut h);
}
match &it.kind {
ItemKind::Comp(c) => (Arc::as_ptr(c) as usize).hash(&mut h),
k => format!("{k:?}").hash(&mut h),
}
format!("{:?}", it.proxy).hash(&mut h);
}
comp.0.hash(&mut h);
h.finish()
}
/// Hash of render options (part of [`FrameKey`] and of the disk key).
pub fn opts_hash(o: &RenderOpts) -> u64 {
let mut h = disk_cache::Hash128::default();
@ -259,6 +342,11 @@ pub struct Frames {
/// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout.
pub last_ms: Arc<Mutex<f64>>,
content_keys: ContentKeys,
/// [`comp_content`] identities by (revision, comp).
identities: Mutex<HashMap<(u64, u64), u64>>,
/// A project snapshot for each identity handed out, with when it was last asked for (see
/// [`Frames::content_of`]); the counter.
keepers: Mutex<(u64, HashMap<u64, (u64, Arc<Project>)>)>,
/// Where frames render instead of the UI thread (wasm32), and how many it is rendering.
remote: Option<Arc<dyn RemoteFrames>>,
remote_busy: Arc<Mutex<usize>>,
@ -284,6 +372,8 @@ impl Default for Frames {
ctx: None,
last_ms: Arc::new(Mutex::new(0.0)),
content_keys: Arc::default(),
identities: Mutex::default(),
keepers: Mutex::default(),
remote: None,
remote_busy: Arc::default(),
}
@ -325,16 +415,44 @@ impl Frames {
Some(img)
}
/// Drop frames of project revisions before `revision` (and their queued jobs): an edit
/// makes them unreachable, so they only take memory from frames that can still be shown.
pub fn drop_stale(&self, revision: u64) {
if let Ok(mut c) = self.cache.lock() {
let stale: Vec<FrameKey> = c.map.keys().filter(|k| k.revision < revision).copied().collect();
for k in &stale {
c.remove(k);
/// The [`comp_content`] identity of `comp` in `project` at `revision` (taken once per
/// revision). The project is kept while frames of that identity are cached or rendering, so
/// the comp addresses it hashes can't be reused by other comps meanwhile.
pub fn content_of(&self, project: &Arc<Project>, revision: u64, comp: ItemId) -> u64 {
let known = self.identities.lock().ok().and_then(|m| m.get(&(revision, comp.0)).copied());
let id = known.unwrap_or_else(|| {
let id = comp_content(project, comp);
if let Ok(mut m) = self.identities.lock() {
if m.len() > 256 {
m.clear();
}
m.insert((revision, comp.0), id);
}
id
});
if let Ok(mut guard) = self.keepers.lock() {
let (clock, k) = &mut *guard;
*clock += 1;
let now = *clock;
k.entry(id).or_insert_with(|| (now, project.clone())).0 = now;
if k.len() > MAX_KEEPERS {
let inflight: HashSet<u64> = self.inflight.lock().map(|s| s.iter().map(|f| f.content).collect()).unwrap_or_default();
if let Ok(mut c) = self.cache.lock() {
// Snapshots of identities without frames go; past the limit the least
// recently asked for go too, with their frames (a long drag leaves many).
let cached: HashSet<u64> = c.map.keys().map(|f| f.content).collect();
k.retain(|i, _| *i == id || cached.contains(i) || inflight.contains(i));
if k.len() > MAX_KEEPERS {
let mut ages: Vec<(u64, u64)> = k.iter().filter(|(i, _)| **i != id && !inflight.contains(i)).map(|(i, (t, _))| (*t, *i)).collect();
ages.sort_unstable();
let gone: HashSet<u64> = ages.into_iter().take(k.len().saturating_sub(MAX_KEEPERS / 2)).map(|(_, i)| i).collect();
k.retain(|i, _| !gone.contains(i));
c.drop_contents(&gone);
}
}
}
c.order.retain(|k| k.revision >= revision);
}
id
}
pub fn is_cached(&self, k: &FrameKey) -> bool {
@ -345,7 +463,7 @@ impl Frames {
self.inflight.lock().map(|s| s.len()).unwrap_or(0)
}
/// Cached frame numbers of the series of `base` (its comp, revision, scale, view and render
/// Cached frame numbers of the series of `base` (its comp content, scale, view and render
/// options) — for the cache bar.
pub fn cached_frames(&self, base: &FrameKey) -> Vec<i64> {
let Ok(c) = self.cache.lock() else { return vec![] };
@ -744,8 +862,8 @@ impl Queue {
mod tests {
use super::*;
fn key(revision: u64, frame: i64, opts: u64) -> FrameKey {
FrameKey { revision, comp: 1, frame, scale: 1000, view: 0, opts }
fn key(content: u64, frame: i64, opts: u64) -> FrameKey {
FrameKey { revision: content, content, comp: 1, frame, scale: 1000, view: 0, opts }
}
fn img() -> FrameImage {
@ -771,17 +889,73 @@ mod tests {
}
#[test]
fn stale_revisions_are_dropped_and_series_are_kept_apart() {
fn frames_are_keyed_by_content_and_series_are_kept_apart() {
let f = Frames::default();
insert(&f, key(1, 0, 0));
insert(&f, key(2, 0, 0));
insert(&f, key(2, 1, 7));
// Other render options (Draft, shadows…) are another series.
// Other content or render options (Draft, shadows…) are another series.
assert_eq!(f.cached_frames(&key(2, 0, 0)), vec![0]);
assert_eq!(f.cached_frames(&key(2, 0, 7)), vec![1]);
f.drop_stale(2);
assert!(!f.is_cached(&key(1, 0, 0)) && f.is_cached(&key(2, 0, 0)));
let c = f.cache.lock().unwrap();
assert_eq!((c.order.len(), c.bytes), (2, 2 * 16), "the memory is given back");
// A later revision with the same content finds the frame (an edit elsewhere, an undo).
assert!(f.is_cached(&FrameKey { revision: 99, ..key(1, 0, 0) }));
assert!(!f.is_cached(&key(3, 0, 0)));
}
#[test]
fn an_edit_keeps_the_frames_of_comps_it_does_not_touch() {
use effectcraft_engine::Session;
use serde_json::json;
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 b = ItemId(s.execute("comp.new", json!({"name": "B", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let f = Frames::default();
let at = |s: &Session, c: ItemId| f.content_of(&s.project, s.revision, c);
let (a0, b0) = (at(&s, a), at(&s, b));
// Edit B: A's content (and frames) stay, B's change.
s.execute("layer.newSolid", json!({"comp": b.0, "color": "#ff0000"})).unwrap();
assert_eq!(at(&s, a), a0);
assert_ne!(at(&s, b), b0);
// Undo: B is back to the content its old frames were rendered for.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(at(&s, b), b0);
}
#[test]
fn a_long_drag_keeps_a_bounded_number_of_states() {
use effectcraft_engine::Session;
use serde_json::json;
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 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);
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");
}
#[test]
fn an_identity_keeps_its_comps_so_an_edit_in_place_still_changes_it() {
use effectcraft_engine::Session;
use serde_json::json;
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());
// A project nothing else holds (no undo history): make_mut would edit the comp in place,
// at the same address, if the identity hadn't kept a snapshot holding it.
let mut p = Arc::new((*s.project).clone());
drop(s);
let f = Frames::default();
let before = f.content_of(&p, 1, a);
let comp = Arc::make_mut(&mut p).comp_mut(a).unwrap();
comp.width = 16;
assert_ne!(f.content_of(&p, 2, a), before);
}
}

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,
@ -511,11 +514,12 @@ impl EffectcraftApp {
FrameKey { frame, ..self.frame_series(comp, scale) }
}
/// The key of frame 0 of `comp`'s viewer frames at `scale` (the revision, view and render
/// The key of frame 0 of `comp`'s viewer frames at `scale` (the content, view and render
/// options are the same for every frame: loops compute this once).
pub fn frame_series(&self, comp: ItemId, scale: f64) -> FrameKey {
FrameKey {
revision: self.session.revision,
content: self.frames.content_of(&self.session.project, self.session.revision, comp),
comp: comp.0,
frame: 0,
scale: (scale * 1000.0).round() as u32,
@ -524,14 +528,11 @@ impl EffectcraftApp {
}
}
/// The frame series the viewer of `comp` shows now (its scale, view and options, at the
/// current revision): what the cache bars count.
/// The frame series the viewer of `comp` shows now (its scale, view and options, for the
/// comp's current content): what the cache bars count.
pub fn shown_series(&self, comp: ItemId) -> FrameKey {
match self.viewer_shown.as_ref().map(|(_, k)| *k) {
Some(k) if k.comp == comp.0 && k.revision == self.session.revision => FrameKey { frame: 0, ..k },
Some(k) => self.frame_series(comp, k.scale as f64 / 1000.0),
None => self.frame_series(comp, 1.0),
}
let scale = self.viewer_shown.as_ref().filter(|(_, k)| k.comp == comp.0).map_or(1000, |(_, k)| k.scale);
self.frame_series(comp, scale as f64 / 1000.0)
}
/// Hash of the comp viewer's 3D view camera (0 for the active camera view).
@ -621,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,
@ -1120,19 +1122,14 @@ 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.
effectcraft_engine::Event::ProjectChanged { revision } => self.frames.drop_stale(revision),
// 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 } => {
if let Err(e) = crate::menus::frontend(self, ctx, &command, params) {
self.ui.status = e;

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));

View File

@ -530,7 +530,7 @@ mod tests {
assert_eq!(format_ago(now, now - 3 * 86_400), "3 days ago");
assert_eq!(format_ago(now, now - 30 * 86_400), "2026-08-22");
assert_eq!(format_ago(now, now + 100), "Just now");
assert_eq!((format_size(900), format_size(12_800), format_size(3_500_000)), ("900 B".into(), "13 KB".into(), "3.3 MB".into()));
assert_eq!((format_size(900), format_size(13_000), format_size(3_500_000)), ("900 B".into(), "13 KB".into(), "3.3 MB".into()));
assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project"));
}

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"),

View File

@ -520,6 +520,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let stale = app.viewer_shown.as_ref().is_none_or(|(_, k)| *k != key);
if stale {
show_frame(app, &ctx, key, img);
} else if let Some((_, k)) = app.viewer_shown.as_mut() {
// The same frame at a later revision (an edit elsewhere, an undo): it shows that one.
k.revision = key.revision;
}
}
if app.ui.viewer.transparency_grid {

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))
}

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(),

View File

@ -0,0 +1,53 @@
//! RAM preview frames are keyed by content: editing one comp keeps the cached frames of another,
//! and undo finds the frames of the state it returns to.
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemId;
use effectcraft_ui_egui::EffectcraftApp;
use egui_kittest::Harness;
use serde_json::json;
fn settle(h: &mut Harness<'_, EffectcraftApp>) {
for _ in 0..600 {
h.step();
if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
h.run_steps(2);
}
fn shown_cached(h: &Harness<'_, EffectcraftApp>, comp: u64) -> bool {
let app = h.state();
let key = effectcraft_ui_egui::frames::FrameKey { frame: 0, ..app.shown_series(ItemId(comp)) };
app.frames.is_cached(&key)
}
#[test]
fn an_edit_in_another_comp_keeps_the_cached_frames_and_undo_finds_them_again() {
let mut s = Session::default();
let b = s.execute("comp.new", json!({"name": "B", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let a = s.execute("comp.new", json!({"name": "A", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
s.execute("layer.newSolid", json!({"color": "#3080ff"})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
settle(&mut h);
assert!(shown_cached(&h, a), "A's frame rendered");
// Edit B (open it, add a layer), then come back to A: its frame is still in RAM.
h.state_mut().session.execute("comp.open", json!({"comp": b})).unwrap();
h.state_mut().session.execute("layer.newSolid", json!({"color": "#ff8030"})).unwrap();
settle(&mut h);
let b_frame = h.state().shown_series(ItemId(b));
h.state_mut().session.execute("comp.open", json!({"comp": a})).unwrap();
assert!(shown_cached(&h, a), "A's frame survived the edit in B");
// Edit A, then undo: the frame of the state undo returns to is found again.
let before = h.state().shown_series(ItemId(a));
h.state_mut().session.execute("layer.newSolid", json!({"color": "#20c040"})).unwrap();
settle(&mut h);
assert_ne!(h.state().shown_series(ItemId(a)), before, "A's content changed");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(h.state().shown_series(ItemId(a)), before);
assert!(shown_cached(&h, a), "undo finds the earlier frame");
// B's frame is still there too.
assert!(h.state().frames.is_cached(&effectcraft_ui_egui::frames::FrameKey { frame: 0, ..b_frame }));
}

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();
}

View File

@ -137,7 +137,15 @@ fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() {
s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 12})).unwrap();
let opts = RenderOpts { backend: effectcraft_engine::render::Backend::Gpu, ..Default::default() };
let t = effectcraft_engine::time::Tick::ZERO;
let key = |comp: effectcraft_engine::project::ItemId| FrameKey { revision: s.revision, comp: comp.0, frame: 0, scale: 1000, view: 0, opts: 0 };
let key = |comp: effectcraft_engine::project::ItemId| FrameKey {
revision: s.revision,
content: effectcraft_ui_egui::frames::comp_content(&s.project, comp),
comp: comp.0,
frame: 0,
scale: 1000,
view: 0,
opts: 0,
};
let disk = Arc::new(Mutex::new(HashMap::new()));
let worker = Arc::new(GpuWorker::new(&s, worker_gpu.clone(), disk.clone()));
let mut frames = Frames::default();

View File

@ -105,16 +105,24 @@ fn viewer_frames_render_remotely_from_project_diffs() {
let comp = s.active_comp_id().unwrap();
let c = s.project.comp(comp).unwrap().clone();
let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() };
let key = |rev: u64, f: i64| FrameKey { revision: rev, comp: comp.0, frame: f, scale: 250, view: 0, opts: 0 };
let key = |s: &Session, f: i64| FrameKey {
revision: s.revision,
content: effectcraft_ui_egui::frames::comp_content(&s.project, comp),
comp: comp.0,
frame: f,
scale: 250,
view: 0,
opts: 0,
};
let worker = Arc::new(FakeWorker::new(&s, 2));
let mut frames = Frames::default();
frames.set_remote(Some(worker.clone()));
assert!(frames.remote_active());
// Three prefetch frames, then the viewer's frame.
for f in 1..=3 {
frames.request(&source(&s), key(s.revision, f), comp, c.frame_rate.tick_of(f), opts);
frames.request(&source(&s), key(&s, f), comp, c.frame_rate.tick_of(f), opts);
}
frames.request_urgent(&source(&s), key(s.revision, 10), comp, c.frame_rate.tick_of(10), opts);
frames.request_urgent(&source(&s), key(&s, 10), comp, c.frame_rate.tick_of(10), opts);
assert_eq!(frames.inflight(), 4);
// Nothing renders until dispatched (no frame threads with a remote renderer); the viewer's
// frame goes first, then prefetch in request order.
@ -126,15 +134,15 @@ fn viewer_frames_render_remotely_from_project_diffs() {
// Frames arrive: the cache holds exactly what the page would have rendered.
worker.deliver();
assert_eq!(frames.remote_busy(), 0);
let img = match frames.get(&key(s.revision, 10)).expect("viewer frame cached") {
let img = match frames.get(&key(&s, 10)).expect("viewer frame cached") {
FrameImage::Cpu(c) => c,
FrameImage::Gpu(_) => unreachable!(),
};
let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts));
assert_eq!(img.size, local.size);
assert_eq!(img.pixels, local.pixels);
assert!(frames.is_cached(&key(s.revision, 1)));
assert!(!frames.is_cached(&key(s.revision, 2)));
assert!(frames.is_cached(&key(&s, 1)));
assert!(!frames.is_cached(&key(&s, 2)));
// The rest; an edit later travels as a patch.
frames.dispatch_remote();
worker.deliver();
@ -142,19 +150,19 @@ fn viewer_frames_render_remotely_from_project_diffs() {
worker.deliver();
assert_eq!(frames.inflight(), 0);
s.execute("layer.newSolid", json!({"color": "#ff8800"})).unwrap();
frames.request_urgent(&source(&s), key(s.revision, 10), comp, c.frame_rate.tick_of(10), opts);
frames.request_urgent(&source(&s), key(&s, 10), comp, c.frame_rate.tick_of(10), opts);
frames.dispatch_remote();
worker.deliver();
assert_eq!(worker.syncs.lock().unwrap().last(), Some(&"patch"));
let FrameImage::Cpu(img) = frames.get(&key(s.revision, 10)).unwrap() else { unreachable!() };
let FrameImage::Cpu(img) = frames.get(&key(&s, 10)).unwrap() else { unreachable!() };
let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts));
assert_eq!(img.pixels, local.pixels);
// A lost frame is released (requested again later), not cached.
frames.request(&source(&s), key(s.revision, 20), comp, c.frame_rate.tick_of(20), opts);
frames.request(&source(&s), key(&s, 20), comp, c.frame_rate.tick_of(20), opts);
frames.dispatch_remote();
worker.lose();
assert_eq!(frames.inflight(), 0);
assert!(!frames.is_cached(&key(s.revision, 20)));
assert!(!frames.is_cached(&key(&s, 20)));
// Without slots the remote renderer is unusable (frames render here again).
frames.set_remote(Some(Arc::new(FakeWorker::new(&s, 0))));
assert!(!frames.remote_active());

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`,

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,23 @@ 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
1500-layer test project: it hashes the addresses of the comps a comp draws, and the RAM cache
keeps a project snapshot for every identity with frames, so those addresses can't be reused
meanwhile. Footage, solids, proxies and the project settings are hashed by value. At most 64
states are kept, so a long drag doesn't hold on to every intermediate state. The disk cache's
content key now also covers proxies and Use Proxy.
Since (M13.30–M13.31): the Tools bar and About dialog carry the ArtCraft mark. The
Home screen is laid out like After Effects' and covers the whole workspace. A left rail holds New
Project / Open Project, the Home, Templates and Learn pages and, at its foot, the community
@ -732,8 +749,9 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r
15. ~~Preferences and a shortcut editor that can rebind; real Wiggler, Smoother and Motion Sketch; the marker dialog.~~ (second and third waves)
16. ~~Motion blur of collapsed precomps and of animated content inside a layer (shape paths, text
animators).~~ (M4.13)
17. Content-keyed RAM preview frames, so an edit keeps the frames of comps it doesn't touch.
18. View ▸ New Viewer (several unlocked Composition viewers), nested comp markers on the
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 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