mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 00:14:41 +00:00
Preview: playback runs at the pace frames arrive when it can't keep up
This commit is contained in:
@@ -46,6 +46,9 @@ impl EffectcraftApp {
|
||||
starting_since: now,
|
||||
first_cached: None,
|
||||
shown_at: Default::default(),
|
||||
pace: 0.0,
|
||||
pace_acc: None,
|
||||
paced_at: None,
|
||||
};
|
||||
if !self.playback.caching {
|
||||
if plan.video {
|
||||
|
||||
@@ -35,6 +35,46 @@ fn between_picture_and_sound(cur: i64, f: i64, target: i64, looping: bool) -> bo
|
||||
if target >= cur || !looping { cur < f && f < target } else { f > cur || f < target }
|
||||
}
|
||||
|
||||
/// How long the pace takes to follow the rate frames arrive at (seconds, the time constant of
|
||||
/// its smoothing): short enough to follow a change, long enough to play through a batch of
|
||||
/// frames finishing together.
|
||||
const PACE_TAU: f64 = 0.5;
|
||||
|
||||
/// The rate a preview plays at when frames arrive slower than its frame rate: proportional to
|
||||
/// the cached frames ahead (`lead` of `target`), so it settles where the clock takes frames as
|
||||
/// fast as they arrive and the lead stays put; smoothed over [`PACE_TAU`] (`dt` seconds since
|
||||
/// the last pass). Never above the frame rate, never under an eighth of it (slower reads as
|
||||
/// stopped: the clock holds instead). No frame ahead: the previous pace, the clock holds anyway.
|
||||
fn pace_for(prev: f64, lead: i64, target: i64, fps: f64, dt: f64) -> f64 {
|
||||
if lead <= 0 || target <= 0 || fps <= 0.0 {
|
||||
return prev;
|
||||
}
|
||||
let raw = (fps * lead as f64 / target as f64).clamp(fps / 8.0, fps);
|
||||
let k = 1.0 - (-dt.max(0.0) / PACE_TAU).exp();
|
||||
prev + (raw - prev) * k
|
||||
}
|
||||
|
||||
impl PlaybackPass<'_> {
|
||||
/// The frame `k` played frames after `from` (wrapping as the preview loops), `None` once a
|
||||
/// preview that doesn't loop has reached its end.
|
||||
fn frame_after(&self, from: i64, k: i64) -> Option<i64> {
|
||||
match self.plan {
|
||||
Some(p) => p.frame_after(from, k),
|
||||
None => {
|
||||
let FrameWindow { first: wa, last: wb } = self.window;
|
||||
let f = from + k;
|
||||
if f <= wb {
|
||||
Some(f)
|
||||
} else if self.looping {
|
||||
Some(wa + (f - wa).rem_euclid((wb - wa + 1).max(1)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectcraftApp {
|
||||
/// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold
|
||||
/// and keep rendering ahead (the green cache bar fills, then playback runs in real time).
|
||||
@@ -376,29 +416,42 @@ impl EffectcraftApp {
|
||||
}
|
||||
|
||||
/// The wall clock advances the preview when no audio device drives it, and shows the target
|
||||
/// frame (or holds when it is not cached yet).
|
||||
/// frame (or holds when it is not cached yet). While frames arrive slower than the frame
|
||||
/// rate it runs at their pace ([`pace_for`]): every frame shows, evenly, as After Effects
|
||||
/// plays a preview it hasn't cached yet; real time again once the frames ahead are cached.
|
||||
fn advance_frame_clock(&mut self, pass: &PlaybackPass<'_>, step: i64) {
|
||||
let PlaybackPass { ctx, now, plan, video, looping, frame_rate: fr, cur, series, .. } = *pass;
|
||||
let FrameWindow { first: wa, last: wb } = pass.window;
|
||||
let elapsed = now - self.playback.start_wall;
|
||||
let PlaybackPass { ctx, now, plan, video, frame_rate: fr, cur, series, .. } = *pass;
|
||||
let FrameWindow { last: wb, .. } = pass.window;
|
||||
// The plan carries the rate the preview was started with (and `play_with` copies it to
|
||||
// `Playback::fps`). Deriving the frame from the plan keeps a logic-only tick correct even
|
||||
// when a host/test supplies only the plan; a stale duplicate cannot hold playback open.
|
||||
let fps = plan.map_or(self.playback.fps, |p| p.fps);
|
||||
let n = ((elapsed * fps) / step as f64).floor() as i64;
|
||||
let target = match plan {
|
||||
Some(p) => p.frame_after(self.playback.start_frame, n),
|
||||
None => {
|
||||
let t = self.playback.start_frame + n;
|
||||
if t <= wb {
|
||||
Some(t)
|
||||
} else if looping {
|
||||
Some(wa + (t - wa).rem_euclid((wb - wa + 1).max(1)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let dt = self.playback.paced_at.map_or(0.0, |t| (now - t).max(0.0));
|
||||
self.playback.paced_at = Some(now);
|
||||
if video {
|
||||
// The lead counts out of twice the prefetch depth: the prefetch keeps that many
|
||||
// frames coming, so a full lead means rendering keeps up.
|
||||
let target = (pass.ahead * 2).max(1);
|
||||
let lead = self.cached_lead(pass, target);
|
||||
let prev = if self.playback.pace > 0.0 { self.playback.pace } else { fps };
|
||||
let pace = pace_for(prev, lead, target, fps, dt);
|
||||
self.playback.pace = pace;
|
||||
if pace < fps * 0.8 {
|
||||
self.step_down(now, pass.scale);
|
||||
}
|
||||
};
|
||||
if pace < fps * 0.98 {
|
||||
self.advance_paced(pass, pace, dt);
|
||||
return;
|
||||
}
|
||||
if self.playback.pace_acc.take().is_some() {
|
||||
// Back to real time from here.
|
||||
self.playback.start_wall = now;
|
||||
self.playback.start_frame = cur;
|
||||
}
|
||||
}
|
||||
let elapsed = now - self.playback.start_wall;
|
||||
let n = ((elapsed * fps) / step as f64).floor() as i64;
|
||||
let target = pass.frame_after(self.playback.start_frame, n);
|
||||
let Some(target) = target else {
|
||||
if video {
|
||||
self.session.set_time(fr.tick_of(wb));
|
||||
@@ -424,7 +477,7 @@ impl EffectcraftApp {
|
||||
// Not cached yet: hold the clock at the current frame (cache first, then play).
|
||||
self.playback.waiting = true;
|
||||
self.step_down(now, pass.scale);
|
||||
let next = plan.and_then(|p| p.frame_after(cur, 1)).unwrap_or(if cur + 1 > wb { wa } else { cur + 1 });
|
||||
let next = pass.frame_after(cur, 1).unwrap_or(cur);
|
||||
if self.frames.is_cached(&FrameKey { frame: next, ..series }) {
|
||||
self.session.set_time(fr.tick_of(next));
|
||||
}
|
||||
@@ -434,11 +487,54 @@ impl EffectcraftApp {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The paced clock: it owes `pace` played frames a second (plus what the last passes left
|
||||
/// over) and shows them one by one as far as they are cached; on a frame that isn't it holds
|
||||
/// (owing one frame, so that frame shows the moment it arrives), and at the end of a preview
|
||||
/// that doesn't loop it stops.
|
||||
fn advance_paced(&mut self, pass: &PlaybackPass<'_>, pace: f64, dt: f64) {
|
||||
let PlaybackPass { ctx, now, frame_rate: fr, cur, series, .. } = *pass;
|
||||
let step = pass.plan.map_or(1, |p| p.step).max(1) as f64;
|
||||
let mut owed = self.playback.pace_acc.unwrap_or(0.0) + dt * pace / step;
|
||||
let mut f = cur;
|
||||
while owed >= 1.0 {
|
||||
match pass.frame_after(f, 1) {
|
||||
Some(n) if self.frames.is_cached(&FrameKey { frame: n, ..series }) => {
|
||||
f = n;
|
||||
owed -= 1.0;
|
||||
}
|
||||
Some(_) => {
|
||||
owed = 1.0;
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
self.session.set_time(fr.tick_of(pass.window.last));
|
||||
self.stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.playback.waiting = owed >= 1.0;
|
||||
if f != cur {
|
||||
self.session.set_time(fr.tick_of(f));
|
||||
self.playback.frame_shown(now);
|
||||
}
|
||||
self.playback.pace_acc = Some(owed);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Cached frames among the next `max` in play order. Counted, not the consecutive run: one
|
||||
/// late frame (a GPU warm-up frame, a heavy one) with frames cached beyond it holds the clock
|
||||
/// for a moment when it gets there, rather than slowing the whole preview to a crawl.
|
||||
fn cached_lead(&self, pass: &PlaybackPass<'_>, max: i64) -> i64 {
|
||||
let cached = self.frames.cached_frames(&pass.series);
|
||||
(1..=max).map_while(|k| pass.frame_after(pass.cur, k)).filter(|f| cached.binary_search(f).is_ok()).count() as i64
|
||||
}
|
||||
|
||||
/// Settings ▸ Previews ▸ Lower Resolution When Playback Can't Keep Up: a preview at
|
||||
/// Resolution Auto stalled on a frame that isn't cached and shows under 80 % of its frame
|
||||
/// rate two seconds after it started or last stepped down (the first frames at a new scale,
|
||||
/// and the GPU's warm-up frames, take a moment), so it plays on at half `scale`, no lower than
|
||||
/// the Adaptive Resolution Limit. An explicit Resolution is kept.
|
||||
/// Resolution Auto plays under 80 % of its frame rate (its pace, or stalled on a frame that
|
||||
/// isn't cached) two seconds after it started or last stepped down (the first frames at a
|
||||
/// new scale, and the GPU's warm-up frames, take a moment), so it plays on at half `scale`,
|
||||
/// no lower than the Adaptive Resolution Limit. An explicit Resolution is kept.
|
||||
// ponytail: it never steps back up during a play, and frames cached at the higher scale aren't
|
||||
// shown once it stepped down; add both if loops over a partly cached comp need them.
|
||||
fn step_down(&mut self, now: f64, scale: f64) {
|
||||
@@ -556,6 +652,27 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pace_tests {
|
||||
use super::pace_for;
|
||||
|
||||
/// The pace follows the cached lead: real time with a full lead, a third of it with a third,
|
||||
/// never under an eighth, unchanged with nothing ahead, and smoothed over time.
|
||||
#[test]
|
||||
fn pace_follows_the_cached_lead() {
|
||||
// Settled (a long dt): proportional to the lead.
|
||||
assert!((pace_for(30.0, 12, 12, 30.0, 10.0) - 30.0).abs() < 1e-6);
|
||||
assert!((pace_for(30.0, 4, 12, 30.0, 10.0) - 10.0).abs() < 1e-6);
|
||||
assert!((pace_for(30.0, 1, 100, 30.0, 10.0) - 3.75).abs() < 1e-6, "never under an eighth");
|
||||
assert_eq!(pace_for(17.0, 0, 12, 30.0, 10.0), 17.0, "no frame ahead: unchanged");
|
||||
// Smoothing: after one time constant it has gone 63 % of the way.
|
||||
let p = pace_for(30.0, 4, 12, 30.0, 0.5);
|
||||
assert!((p - (30.0 - 20.0 * (1.0 - (-1.0f64).exp()))).abs() < 1e-6);
|
||||
assert_eq!(pace_for(30.0, 4, 12, 30.0, 0.0), 30.0, "no time passed: unchanged");
|
||||
assert!(pace_for(10.0, 12, 12, 30.0, 0.5) > 10.0, "and up again as the lead fills");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_tests {
|
||||
use super::{between_picture_and_sound, startup_frames};
|
||||
|
||||
@@ -222,6 +222,14 @@ pub struct Playback {
|
||||
pub first_cached: Option<f64>,
|
||||
/// When the last frames were shown (seconds, the last second's worth): the achieved rate.
|
||||
pub shown_at: std::collections::VecDeque<f64>,
|
||||
/// The rate the silent clock advances at (comp frames per second, at most `fps`; 0 = the
|
||||
/// frame rate): below real time while frames arrive slower than that, so a first playback
|
||||
/// shows them evenly instead of in bursts between freezes (see `pace_for`).
|
||||
pub pace: f64,
|
||||
/// Played frames the paced clock owes (fractional), `Some` while it runs below real time.
|
||||
pub pace_acc: Option<f64>,
|
||||
/// When the silent clock last ran (seconds), for the pace.
|
||||
pub paced_at: Option<f64>,
|
||||
}
|
||||
|
||||
impl Playback {
|
||||
|
||||
@@ -285,6 +285,9 @@ fn playback_prefetches_past_the_cached_frames_ahead() {
|
||||
h.state_mut().use_manual_clock();
|
||||
h.state_mut().session.set_time(Tick::ZERO);
|
||||
invoke(&mut h, "playback.toggle", json!({}));
|
||||
// The first pass sees the frames ahead cached and starts the clock (a silent preview holds
|
||||
// its first frame until a few are, as one with sound does).
|
||||
h.step();
|
||||
h.state_mut().clock_mut().advance(0.25);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().playback.playing);
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ are grouped by area; the issue tracker has the rest.
|
||||
| 2 | **Fidelity unmeasured** beyond scalar ease | [fidelity/](fidelity/README.md): 168 samples, one property type; `compare_frames` helper exists, no corpus | Every "done" feature may still differ; users find the differences | 60–100 h harness + P0/P1 corpus; more per feature | G1 · [target-app-parity.md](target-app-parity.md) |
|
||||
| 3 | **Open crash reports** | Open: #580, #501, #426, #341, #234, #879. Now closed: #591, #306, #511 | A crash loses work; outranks features ([AGENTS.md](../AGENTS.md)) | 10–25 h | G3 · this file |
|
||||
| 4 | ~~**No CI gate on pull requests**~~ closed | `.github/workflows/ci.yml` runs `cargo xtask ci` (fmt, clippy, tests, layers, assets, wasm) on every pull request and push to `main` through craft-common-actions' shared `gate.yml` (#2204) | — | done | G3 · this file |
|
||||
| 5 | **Playback and scrubbing feel** | #595 stutter on cached frames (partly addressed: nearest-first RAM preview prefetch, cache bars at 1/3 resolution), #304, #504 / #335 audio late or crackling, #586 puppet lag (maintainers asked for details); #329 slow scrubbing (#784) and #554 closed | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) |
|
||||
| 5 | **Playback and scrubbing feel** | #595 stutter on cached frames (partly addressed: nearest-first RAM preview prefetch, cache bars at 1/3 resolution; an uncached first playback plays at the rate frames render instead of freezing between bursts, with frame lanes four threads wide), #304, #504 / #335 audio late or crackling, #586 puppet lag (maintainers asked for details); #329 slow scrubbing (#784) and #554 closed | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) |
|
||||
| 6 | **Linux and Windows interaction bugs** | Open: #488 rename arrows (hardening landed, reporter asked for details), #492 anchor drag, #268 file drops on Wayland (winit), #613 Direct3D 12 compositor. Now closed: #491, #493, #467, #320, #302, #596 | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) |
|
||||
| 7 | **Animation presets: 76 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 76 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops; 22 Behaviors: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker on transform and opacity, #1510) and read only our `.ecpreset`. Missing: Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) |
|
||||
| 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) |
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@ Commands (CLI, MCP, control channel):
|
||||
- `previews.adaptiveResolutionLimit`: Adaptive Resolution Limit (the lowest resolution Fast Previews ▸
|
||||
Adaptive Resolution drops to while you drag, and the lowest a playing preview steps down to)
|
||||
- `previews.adaptivePlayback`: Lower Resolution When Playback Can't Keep Up. On by default. A
|
||||
preview at Resolution Auto that stalls on frames and shows under 80 % of its frame rate steps
|
||||
preview at Resolution Auto that plays under 80 % of its frame rate (frames render slower than
|
||||
that, so it plays them at the rate they arrive rather than freezing between them) steps
|
||||
down a resolution (half the scale after two seconds, a quarter two seconds later if still slow,
|
||||
no lower than the Adaptive Resolution Limit) for the rest of that play; stopping goes back to the viewer's resolution, and Cache
|
||||
Frames When Idle fills it in. An explicit Resolution (the comp's or the Preview panel's) is
|
||||
|
||||
Reference in New Issue
Block a user