mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 18:31:30 -06:00
Merge main into #1328; i18n/ui.rs: keep both new pt-BR rows (Click to sample color, Export Scene to Blender...), add the pl row for main's new Polish catalog
This commit is contained in:
@@ -127,10 +127,13 @@ impl EffectcraftApp {
|
||||
self.now = self.clock.now();
|
||||
// The compositor exists before the first paint and while hidden (device-loss handling
|
||||
// must run there too).
|
||||
self.init_gpu(frame);
|
||||
self.init_gpu(ctx, frame);
|
||||
self.apply_gpu_failure(ctx);
|
||||
if self.gpu_failure_applied == Some(true) {
|
||||
self.close_after_device_loss(ctx);
|
||||
// On macOS the app quits through `-[NSApplication terminate:]` instead of closing the
|
||||
// window while AppKit's run loop is running (#341).
|
||||
craft_quit::on_close_request(ctx);
|
||||
return;
|
||||
}
|
||||
// Focus comes from the fresh viewport copy; a full pass's `InputState::focused` is only a
|
||||
@@ -141,6 +144,9 @@ impl EffectcraftApp {
|
||||
// commands handled here use the right catalog already.
|
||||
let language = crate::i18n::language(self);
|
||||
crate::i18n::set_current(language);
|
||||
// #1089: a font-menu rescan runs off the UI thread. Poll it on the logic tick so the
|
||||
// status and the menu update even when the window is not painting.
|
||||
crate::panels::text_panels::poll_font_rescan(self, ctx);
|
||||
if self.styled_language != language {
|
||||
self.want_paint = true;
|
||||
}
|
||||
@@ -384,6 +390,11 @@ impl EffectcraftApp {
|
||||
let full = ui.max_rect();
|
||||
if self.dialog.is_some() {
|
||||
ui.disable();
|
||||
// A dialog that edits what is behind it live (Preview) leaves the window at full
|
||||
// brightness: clicks still don't reach it, but the canvas is not greyed out.
|
||||
if self.dialog == Some(Dialog::GradientEditor) || panels::live_preview::active(self) {
|
||||
ui.set_opacity(1.0);
|
||||
}
|
||||
}
|
||||
ui.painter().rect_filled(full, 0.0, t.app_bg);
|
||||
let mut top = full.min.y;
|
||||
@@ -461,6 +472,13 @@ impl EffectcraftApp {
|
||||
self.ui.status.clear();
|
||||
self.toast = None;
|
||||
}
|
||||
// "Preparing the GPU compositor…" stays until the compositor is attached (tens of
|
||||
// seconds on a cold shader cache), not 5 s: `finish_gpu_build` clears it, and the
|
||||
// worker's repaint brings that frame, so nothing needs to tick meanwhile. A click
|
||||
// still dismisses it.
|
||||
if self.ui.status == crate::GPU_PREPARING && self.gpu_initializing() {
|
||||
return;
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
if !self.ui.status.is_empty() {
|
||||
let since: f64 = ui.data(|d| d.get_temp(egui::Id::unique("status-since")).unwrap_or(now));
|
||||
@@ -524,33 +542,40 @@ impl EffectcraftApp {
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(10));
|
||||
}
|
||||
self.tick_footage_reload(ctx, now);
|
||||
self.tick_footage_reload(ctx);
|
||||
}
|
||||
|
||||
/// Settings ▸ Import ▸ Reload Footage Changed on Disk: every two seconds, and as soon as the
|
||||
/// window comes back to the front (after saving in another app), stamp the footage files on a
|
||||
/// worker thread and reload the ones that changed.
|
||||
fn tick_footage_reload(&mut self, ctx: &egui::Context, now: f64) {
|
||||
/// Settings ▸ Import ▸ Reload Footage Changed on Disk: a worker thread stamps the footage
|
||||
/// files (every two seconds while the window is in front, and as soon as it comes back to
|
||||
/// the front after saving in another app) and wakes this frame only when a stamp changed;
|
||||
/// the changed files are reloaded here. Nothing here asks for a repaint: an idle app with
|
||||
/// unchanged footage stays asleep.
|
||||
fn tick_footage_reload(&mut self, ctx: &egui::Context) {
|
||||
use effectcraft_engine::footage_reload;
|
||||
if let Some(Err(e)) = footage_reload::poll_scan(&mut self.session) {
|
||||
if let Some(stamps) = self.footage_watcher.as_ref().and_then(footage_reload::Watcher::take)
|
||||
&& let Err(e) = footage_reload::apply_stamps(&mut self.session, stamps)
|
||||
{
|
||||
self.ui.status = e.to_string();
|
||||
}
|
||||
let (last_key, focus_key) = (egui::Id::unique("footageReload.lastScan"), egui::Id::unique("footageReload.focused"));
|
||||
let focused = self.focused;
|
||||
let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused);
|
||||
ctx.data_mut(|d| d.insert_temp(focus_key, focused));
|
||||
if self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty() {
|
||||
let off = self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty();
|
||||
if off {
|
||||
if let Some(w) = &mut self.footage_watcher {
|
||||
w.set_watched(Default::default());
|
||||
}
|
||||
self.footage_watch_key = None;
|
||||
return;
|
||||
}
|
||||
let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(0.0);
|
||||
if now - last >= 2.0 || (focused && !was_focused) {
|
||||
ctx.data_mut(|d| d.insert_temp(last_key, now));
|
||||
footage_reload::start_scan(&mut self.session);
|
||||
if self.footage_watcher.is_none() {
|
||||
let wake = ctx.clone();
|
||||
self.footage_watcher = footage_reload::Watcher::start(self.session.services.clone(), move || wake.request_repaint());
|
||||
}
|
||||
if self.session.footage_scan.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
} else {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(2));
|
||||
let Some(watcher) = &mut self.footage_watcher else { return };
|
||||
watcher.set_focused(self.focused);
|
||||
// Which files are watched follows the project and the preference, not the frame.
|
||||
let key = (self.session.revision, self.session.prefs.import.auto_reload_footage.clone());
|
||||
if self.footage_watch_key.as_ref() != Some(&key) {
|
||||
self.footage_watch_key = Some(key);
|
||||
watcher.set_watched(footage_reload::watched(&self.session));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
use super::{Arc, EffectcraftApp, RenderSource, gpu_failure, menus, panels};
|
||||
|
||||
/// The status line while the GPU compositor's worker thread is still building its pipelines
|
||||
/// (see [`EffectcraftApp::init_gpu`]).
|
||||
pub const GPU_PREPARING: &str = "Preparing the GPU compositor… (compositing on the CPU until it is ready)";
|
||||
/// What the compositor's worker thread hands back: the compositor, why the device can't run it,
|
||||
/// or the payload of a panic in the graphics backend.
|
||||
pub type GpuBuild = std::thread::Result<Result<effectcraft_gpu::Gpu, String>>;
|
||||
|
||||
impl EffectcraftApp {
|
||||
pub fn render_source(&self) -> RenderSource {
|
||||
RenderSource {
|
||||
@@ -124,7 +131,19 @@ impl EffectcraftApp {
|
||||
|
||||
/// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs
|
||||
/// compute shaders (WebGL2, software GL) everything stays on the CPU.
|
||||
pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) {
|
||||
///
|
||||
/// On the desktop the compositor is built on a worker thread. Its compute pipelines (about
|
||||
/// 235 of them) take tens of seconds to compile on a cold shader cache (first launch, after
|
||||
/// an OS update, after an update that changed a shader), and eframe shows the window only
|
||||
/// once the first frame has painted, so building them here kept the window from appearing
|
||||
/// at all. Until the worker is done the viewer composites on the CPU, as it does without a
|
||||
/// GPU, and the status bar says so; the browser has no threads and builds it in place.
|
||||
pub(crate) fn init_gpu(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.gpu_build.is_some() {
|
||||
self.finish_gpu_build(frame.wgpu_render_state());
|
||||
return;
|
||||
}
|
||||
if self.gpu_checked {
|
||||
return;
|
||||
}
|
||||
@@ -142,10 +161,80 @@ impl EffectcraftApp {
|
||||
info.device_type,
|
||||
if info.driver.is_empty() { String::new() } else { format!(", {} {}", info.driver, info.driver_info) }
|
||||
));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.spawn_gpu_build(ctx, rs) {
|
||||
return;
|
||||
}
|
||||
// In place (the browser, or no thread to be had): nothing to wake later.
|
||||
let _ = ctx;
|
||||
// Backend validation can panic on shader compilation even when device creation
|
||||
// succeeded. Keep the document and the egui renderer, and use CPU compositing.
|
||||
let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone())));
|
||||
match gpu {
|
||||
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone())));
|
||||
self.attach_gpu(built, Some(rs));
|
||||
}
|
||||
|
||||
/// Start building the compositor on a `gpu-compositor` worker thread, which hands its result
|
||||
/// back to [`Self::finish_gpu_build`] and wakes the UI. False when no thread could be spawned
|
||||
/// (the caller then builds it in place).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn_gpu_build(&mut self, ctx: &egui::Context, rs: &eframe::egui_wgpu::RenderState) -> bool {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (adapter, device, queue) = (rs.adapter.clone(), rs.device.clone(), rs.queue.clone());
|
||||
let gate = self.hooks.before_gpu_build.take();
|
||||
let wake = ctx.clone();
|
||||
let spawned = std::thread::Builder::new().name("gpu-compositor".into()).spawn(move || {
|
||||
// Backend validation can panic on shader compilation even when device creation
|
||||
// succeeded: the panic stays on this thread and arrives as a result.
|
||||
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
if let Some(gate) = gate {
|
||||
gate();
|
||||
}
|
||||
effectcraft_gpu::Gpu::new(&adapter, device, queue)
|
||||
}));
|
||||
// No receiver: the window closed, or the device failed, while this was running.
|
||||
let _ = tx.send(built);
|
||||
wake.request_repaint();
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("no worker thread for the GPU compositor ({e}); building it on the UI thread");
|
||||
return false;
|
||||
}
|
||||
self.gpu_build = Some(rx);
|
||||
self.session.accel_note = Some(GPU_PREPARING.into());
|
||||
if self.ui.status.is_empty() {
|
||||
self.ui.status = GPU_PREPARING.into();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Take the worker's result when it has arrived ([`Self::init_gpu`]). `rs`: the window's
|
||||
/// device, which a compositor that built is attached to.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn finish_gpu_build(&mut self, rs: Option<&eframe::egui_wgpu::RenderState>) {
|
||||
let Some(rx) = &self.gpu_build else { return };
|
||||
let built = match rx.try_recv() {
|
||||
Ok(built) => built,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
Err(Box::new("the compositor's worker thread ended without a result") as Box<dyn std::any::Any + Send>)
|
||||
}
|
||||
};
|
||||
self.gpu_build = None;
|
||||
if self.ui.status == GPU_PREPARING {
|
||||
self.ui.status.clear();
|
||||
}
|
||||
// The device failed while the compositor was being built: it stays detached, and
|
||||
// `apply_gpu_failure` has already said why the viewer is on the CPU.
|
||||
if self.gpu_failure_applied.is_some() || self.gpu_failures.as_ref().is_some_and(|bridge| bridge.failure().is_some()) {
|
||||
return;
|
||||
}
|
||||
self.attach_gpu(built, rs);
|
||||
}
|
||||
|
||||
/// Attach a built compositor to the viewer, or record why there is none. Keeps the document
|
||||
/// and the egui renderer either way; without a compositor the viewer composites on the CPU.
|
||||
fn attach_gpu(&mut self, built: GpuBuild, rs: Option<&eframe::egui_wgpu::RenderState>) {
|
||||
match built {
|
||||
Ok(Ok(g)) => {
|
||||
if let Some(bridge) = &self.gpu_failures {
|
||||
bridge.forward_to(g.context().device_failure_notifier());
|
||||
@@ -153,7 +242,12 @@ impl EffectcraftApp {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(rs) = rs else {
|
||||
self.session.accel_note = Some("GPU compositor unavailable: the window has no wgpu device".into());
|
||||
return;
|
||||
};
|
||||
log::info!("GPU compositor: {}", effectcraft_engine::render::Accelerator::name(&g));
|
||||
self.session.accel_note = None;
|
||||
self.session.accel = Some(Arc::new(g.clone()));
|
||||
self.gpu = Some(g);
|
||||
self.wgpu = Some(rs.clone());
|
||||
@@ -170,6 +264,19 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the GPU compositor is still being built on its worker thread (the viewer is on
|
||||
/// the CPU meanwhile). Always false in the browser, which builds it with the first frame.
|
||||
pub fn gpu_initializing(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.gpu_build.is_some()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewer shows a frame composited on the GPU (drawn from its wgpu texture).
|
||||
pub fn viewer_on_gpu(&self) -> bool {
|
||||
matches!((&self.viewer_shown, &self.viewer_native), (Some(s), Some(n)) if s.1 == n.1)
|
||||
|
||||
@@ -40,11 +40,16 @@ impl EffectcraftApp {
|
||||
restore_max,
|
||||
audio_frame: (!plan.video).then_some(plan.first),
|
||||
audio_held: false,
|
||||
starting: false,
|
||||
starting_since: now,
|
||||
first_cached: None,
|
||||
shown_at: Default::default(),
|
||||
};
|
||||
if !self.playback.caching {
|
||||
if plan.video {
|
||||
self.playback.audio_held = self.audio_plays();
|
||||
let audible = self.audio_plays();
|
||||
self.playback.audio_held = audible;
|
||||
self.playback.starting = audible;
|
||||
} else {
|
||||
self.start_audio(fr.tick_of(plan.first));
|
||||
}
|
||||
@@ -104,6 +109,7 @@ impl EffectcraftApp {
|
||||
let was = std::mem::replace(&mut self.playback.playing, false);
|
||||
self.audio = None;
|
||||
self.playback.caching = false;
|
||||
self.playback.starting = false;
|
||||
if !was {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,29 @@ use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio,
|
||||
/// [`EffectcraftApp::editing`]).
|
||||
const EDIT_QUIET: f64 = 0.3;
|
||||
|
||||
/// Longest a preview with sound holds its first frame, once that frame is cached, waiting for the
|
||||
/// frames ahead (seconds, #947). A frame ahead that never gets cached mustn't hold it for ever:
|
||||
/// the sound starts from the first frame anyway, and the preview goes on as a stalled one does.
|
||||
const STARTUP_WAIT: f64 = 2.0;
|
||||
|
||||
/// Longest a preview with sound holds its first frame in all (seconds, #947). The first frame
|
||||
/// itself can take a while (a heavy comp, a busy machine), and [`STARTUP_WAIT`] counts only from
|
||||
/// when it is cached; but one that never gets cached (larger than the RAM preview, which the size
|
||||
/// estimate can't always tell) mustn't hold it for ever. The preview then goes on as a stalled
|
||||
/// one does: its sound starts once the frames are cached.
|
||||
const STARTUP_LIMIT: f64 = 10.0;
|
||||
|
||||
/// How many frames from the first a preview with sound waits for before it starts (#947): the
|
||||
/// prefetch's `ahead`, but no more than its `window` requests from the current frame (offsets
|
||||
/// `0..window`), and no more than fit in the RAM preview `budget` (90 %, as for the prefetch) at
|
||||
/// `frame_bytes`, the size frames really render at; at least the current frame, which the viewer
|
||||
/// always requests. Waiting for a frame that is never requested, or that can't stay cached with
|
||||
/// the others, would hold the first frame for ever.
|
||||
fn startup_frames(ahead: i64, window: i64, budget: usize, frame_bytes: usize) -> i64 {
|
||||
let fit = (budget as f64 * 0.9 / frame_bytes.max(1) as f64) as i64;
|
||||
ahead.min(window).min(fit).max(1)
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -53,6 +76,9 @@ impl EffectcraftApp {
|
||||
return;
|
||||
}
|
||||
let fit = self.frames_that_fit(&c, scale);
|
||||
if self.hold_first_frame(&pass, fit) {
|
||||
return;
|
||||
}
|
||||
self.start_held_audio(&pass, fit);
|
||||
if self.advance_audio_clock(&pass, step) {
|
||||
return;
|
||||
@@ -179,8 +205,54 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the
|
||||
/// prefetch window when the silent preview keeps real time, else the rest of the preview.
|
||||
/// Starting a preview with sound: hold the first frame until the frames ahead are cached, then
|
||||
/// start the sound from that same frame (see `Playback::starting`). Without an output device
|
||||
/// the picture plays on its own clock from it. How many frames ahead: only ones
|
||||
/// `prefetch_playback` requests and that fit in the RAM preview at the size they really render
|
||||
/// (an Extended Viewer frame is larger than the comp), so the wait always ends. Returns true
|
||||
/// while it holds the first frame.
|
||||
fn hold_first_frame(&mut self, pass: &PlaybackPass<'_>, fit: i64) -> bool {
|
||||
let PlaybackPass { ctx, comp, scale, now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass;
|
||||
if !self.playback.starting {
|
||||
return false;
|
||||
}
|
||||
let Some(p) = plan else { return false };
|
||||
self.playback.start_wall = now;
|
||||
self.playback.start_frame = cur;
|
||||
// `prefetch_playback` requests every uncached frame of offsets 0..window from here.
|
||||
let window = (ahead * 3).min(fit - 1);
|
||||
let need = self.frames.get(&FrameKey { frame: cur, ..series }).map(|img| {
|
||||
let [w, h] = img.size();
|
||||
startup_frames(ahead, window, self.frames.budget(), w.saturating_mul(h).saturating_mul(4))
|
||||
});
|
||||
if need.is_some() && self.playback.first_cached.is_none() {
|
||||
self.playback.first_cached = Some(now);
|
||||
}
|
||||
// Don't wait for frames the RAM preview can't hold (not even one frame fits), for the
|
||||
// frames ahead longer than STARTUP_WAIT once the first is in, nor longer than
|
||||
// STARTUP_LIMIT in all (a frame that never gets cached).
|
||||
let give_up = (self.frames.budget() as f64) < self.frame_bytes(comp, scale)
|
||||
|| self.playback.first_cached.is_some_and(|t| now - t >= STARTUP_WAIT)
|
||||
|| now - self.playback.starting_since >= STARTUP_LIMIT;
|
||||
if !give_up && !need.is_some_and(|n| self.cached_ahead(&p, &series, cur, n)) {
|
||||
self.playback.waiting = true;
|
||||
ctx.request_repaint();
|
||||
return true;
|
||||
}
|
||||
self.playback.starting = false;
|
||||
self.playback.waiting = false;
|
||||
// Not on a frame that isn't cached: the stall below would stop the sound at once. The
|
||||
// preview goes on as a stalled one does, and the sound starts once frames are cached.
|
||||
if need.is_some() {
|
||||
self.playback.audio_held = false;
|
||||
self.start_audio(fr.tick_of(cur));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Held sound starts once the frames ahead are cached again after a stall (see
|
||||
/// `Playback::audio_held`): the prefetch window when the silent preview keeps real time, else
|
||||
/// the rest of the preview.
|
||||
fn start_held_audio(&mut self, pass: &PlaybackPass<'_>, fit: i64) {
|
||||
let PlaybackPass { now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass;
|
||||
if !self.playback.audio_held {
|
||||
@@ -334,7 +406,8 @@ impl EffectcraftApp {
|
||||
}
|
||||
let cur = comp.frame_rate.frame_at(self.session.time()).clamp(wa, wb);
|
||||
let fit = self.frames_that_fit(comp, series.scale as f64 / 1000.0).min(n);
|
||||
let slots = (self.frames_parallelism() / 2).max(1);
|
||||
// One thread each (`Frames::request_background`), so every frame thread takes one.
|
||||
let slots = self.frames_parallelism().max(1);
|
||||
let mut queued = self.frames.inflight();
|
||||
let scale = series.scale as f64 / 1000.0;
|
||||
let mut pending = false;
|
||||
@@ -347,7 +420,7 @@ impl EffectcraftApp {
|
||||
if queued >= slots {
|
||||
break;
|
||||
}
|
||||
self.request_frame(ItemId(series.comp), f, scale);
|
||||
self.request_frame_background(ItemId(series.comp), f, scale);
|
||||
queued += 1;
|
||||
}
|
||||
if pending {
|
||||
@@ -365,9 +438,13 @@ impl EffectcraftApp {
|
||||
}
|
||||
|
||||
fn frames_that_fit(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> i64 {
|
||||
((self.frames.budget() as f64 * 0.9 / self.frame_bytes(comp, scale)) as i64).max(1)
|
||||
}
|
||||
|
||||
/// Bytes of one comp-sized viewer frame at `scale` (Extended Viewer frames are larger).
|
||||
fn frame_bytes(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> f64 {
|
||||
let side = |n: u32| (n as f64 * scale).ceil().max(1.0);
|
||||
let frame = side(comp.width) * side(comp.height) * 4.0;
|
||||
((self.frames.budget() as f64 * 0.9 / frame) as i64).max(1)
|
||||
side(comp.width) * side(comp.height) * 4.0
|
||||
}
|
||||
|
||||
fn frames_parallelism(&self) -> usize {
|
||||
@@ -379,3 +456,28 @@ impl EffectcraftApp {
|
||||
self.frames.parallelism()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_tests {
|
||||
use super::startup_frames;
|
||||
|
||||
/// The frames a preview with sound waits for are always requested and fit together (#947).
|
||||
#[test]
|
||||
fn startup_waits_only_for_frames_it_requests_and_can_keep() {
|
||||
// A 64×36 frame: 9216 bytes. A 46 080-byte budget fits 4 of them (90 %); the prefetch
|
||||
// window then requests offsets 0..3, so the start needs those 3, not 4 it never asks for.
|
||||
let frame = 64 * 36 * 4;
|
||||
let fit = 4;
|
||||
let window = (32i64 * 3).min(fit - 1);
|
||||
assert_eq!(startup_frames(32, window, 46_080, frame), 3);
|
||||
// A roomy budget: the prefetch's `ahead` frames.
|
||||
assert_eq!(startup_frames(8, 24, 100_000_000, frame), 8);
|
||||
// Extended Viewer: a 160×120 comp renders 320×248 frames. 952 320 bytes fit 11 comp-sized
|
||||
// frames but only 2 real ones (90 %): wait for 2, not 4.
|
||||
assert_eq!(startup_frames(4, 11, 952_320, 320 * 248 * 4), 2);
|
||||
// Nothing fits, or no window: still the current frame, never 0.
|
||||
assert_eq!(startup_frames(8, 0, 0, frame), 1);
|
||||
// A zero frame size counts as 1 byte: no division by zero.
|
||||
assert_eq!(startup_frames(8, 24, 10, 0), 8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +126,18 @@ impl EffectcraftApp {
|
||||
|
||||
/// Request a prefetch frame render (no-op if cached/in flight).
|
||||
pub fn request_frame(&self, comp: ItemId, frame: i64, scale: f64) {
|
||||
self.request_frame_with(comp, frame, scale, false);
|
||||
self.request_frame_with(comp, frame, scale, frames::Frames::request);
|
||||
}
|
||||
|
||||
/// Request the frame on screen: rendered before any queued prefetch.
|
||||
pub fn request_frame_urgent(&self, comp: ItemId, frame: i64, scale: f64) {
|
||||
self.request_frame_with(comp, frame, scale, true);
|
||||
self.request_frame_with(comp, frame, scale, frames::Frames::request_urgent);
|
||||
}
|
||||
|
||||
/// Request a frame nobody waits for (Cache Frames When Idle): rendered on one thread
|
||||
/// (`Frames::request_background`).
|
||||
pub fn request_frame_background(&self, comp: ItemId, frame: i64, scale: f64) {
|
||||
self.request_frame_with(comp, frame, scale, frames::Frames::request_background);
|
||||
}
|
||||
|
||||
/// Render options of viewer frames of `comp` at `scale`.
|
||||
@@ -152,15 +158,17 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) {
|
||||
fn request_frame_with(
|
||||
&self,
|
||||
comp: ItemId,
|
||||
frame: i64,
|
||||
scale: f64,
|
||||
request: fn(&frames::Frames, &frames::RenderSource, FrameKey, ItemId, Tick, RenderOpts),
|
||||
) {
|
||||
let Some(c) = self.session.project.comp(comp) else { return };
|
||||
let key = self.frame_key(comp, frame, scale);
|
||||
let t = c.frame_rate.tick_of(frame);
|
||||
let opts = self.frame_opts(comp, scale);
|
||||
if urgent {
|
||||
self.frames.request_urgent(&self.render_source(), key, comp, t, opts);
|
||||
} else {
|
||||
self.frames.request(&self.render_source(), key, comp, t, opts);
|
||||
}
|
||||
request(&self.frames, &self.render_source(), key, comp, t, opts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,18 @@ impl EffectcraftApp {
|
||||
&mut self.dialog_state.gradient
|
||||
}
|
||||
|
||||
/// The open settings dialog's draft and Preview state (`panels::dialogs`): Solid, Camera,
|
||||
/// Light and Composition Settings keep what OK would apply here.
|
||||
pub fn dialog_state(&self) -> &panels::dialogs::DialogState {
|
||||
&self.dialog_state
|
||||
}
|
||||
|
||||
/// The open settings dialog's draft, to edit (tests and automation; the dialog's own fields
|
||||
/// do the same).
|
||||
pub fn dialog_state_mut(&mut self) -> &mut panels::dialogs::DialogState {
|
||||
&mut self.dialog_state
|
||||
}
|
||||
|
||||
/// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and
|
||||
/// give it the focus: Window ▸ `<panel>`.
|
||||
pub fn show_panel(&mut self, p: PanelKind) {
|
||||
|
||||
Reference in New Issue
Block a user