Skip to main content

screen_app/
editor_preview_session.rs

1//! Live editor preview session (AUT-510 / ED.6b).
2//!
3//! Holds the wgpu compose pipeline + a seekable decode stream for the
4//! currently-open editor clip, so the webview can render the composed frame
5//! (zoom / crop / background / cursor) at any playhead position — through the
6//! **same** [`compose_project_frame`](crate::editor_export::compose_project_frame)
7//! the export uses, so what you scrub is what you ship.
8//!
9//! **`EditorPreview` is `Send` but `!Sync`.** Its wgpu `Renderer` holds a
10//! `RefCell` (the mask cache), so it is `Send` (proven by the export path
11//! running it inside `spawn_blocking`) but `!Sync`. The session therefore
12//! lives behind a `Mutex`: a `Mutex<T>` is `Sync` when `T: Send`, which lifts
13//! it to the `Send + Sync` that Tauri's `State` requires. Never hand a bare
14//! `EditorPreview` to `State`.
15//!
16//! The two commands mirror the recorder's preview split: `editor_preview_open`
17//! (like `start_preview`) opens/updates the session; `editor_preview_frame`
18//! (like `latest_camera_frame_bgra`) returns the composed BGRA bytes for one
19//! frame as a raw [`tauri::ipc::Response`] (no JSON).
20
21use std::path::PathBuf;
22use std::sync::Mutex;
23
24use decode::EditorVideoStream;
25use edit::EditProject;
26use tauri::State;
27
28use crate::editor_preview::EditorPreview;
29
30/// Decoded-frame cache the seekable stream keeps for scrub locality — enough
31/// that a short back-and-forth scrub stays in cache without re-spawning the
32/// decoder, far below the 300-frame default (which would pin ~2.5 GB at 1080p
33/// for no preview benefit).
34const PREVIEW_CACHE_FRAMES: usize = 24;
35
36/// Open compose pipeline + seekable decode stream for one editor clip.
37struct EditorPreviewSession {
38    preview: EditorPreview,
39    stream: EditorVideoStream,
40    project: EditProject,
41    source_path: PathBuf,
42}
43
44impl EditorPreviewSession {
45    /// Open the decode stream + wgpu compose pipeline for `project`'s source.
46    fn open(project: EditProject) -> Result<Self, String> {
47        let source_path = project.source.path.clone();
48        let stream = EditorVideoStream::open_with_cache(&source_path, PREVIEW_CACHE_FRAMES)
49            .map_err(|e| format!("open source: {e}"))?;
50        // Compose at the project's aspect-ratio canvas (AUT-513) — the source
51        // is aspect-fit into it, so the live preview matches the export.
52        let (canvas_w, canvas_h) = project.canvas_dims();
53        let mut preview = EditorPreview::with_canvas(
54            project.source.width,
55            project.source.height,
56            canvas_w,
57            canvas_h,
58        )
59        .map_err(|e| format!("init compose: {e}"))?;
60        // Background framing is applied once (not per frame), like the export.
61        preview.set_background(&project.background);
62        Ok(Self {
63            preview,
64            stream,
65            project,
66            source_path,
67        })
68    }
69}
70
71/// Tauri-managed state for the live editor preview (AUT-510).
72#[derive(Default)]
73pub struct EditorPreviewState(Mutex<Option<EditorPreviewSession>>);
74
75impl std::fmt::Debug for EditorPreviewState {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        // `EditorPreview` isn't `Debug` (wgpu handles); summarize.
78        f.debug_struct("EditorPreviewState").finish_non_exhaustive()
79    }
80}
81
82impl EditorPreviewState {
83    /// Open or update the preview for `project`.
84    ///
85    /// Re-opens the decode stream + compose pipeline only when the source clip
86    /// or the **output canvas** changed (a new clip, or an aspect-ratio change
87    /// that reshapes the canvas — AUT-513); otherwise it just swaps the stored
88    /// project — re-applying the background only when it changed — so edits
89    /// (zoom / speed / crop / cursor) take effect on the next composed frame
90    /// without rebuilding wgpu on every keystroke.
91    ///
92    /// # Errors
93    ///
94    /// Returns a message if the source can't be opened or the wgpu compose
95    /// pipeline can't be created.
96    pub fn open(&self, project: EditProject) -> Result<(), String> {
97        let mut guard = self
98            .0
99            .lock()
100            .unwrap_or_else(std::sync::PoisonError::into_inner);
101        // The preview renders at the project's aspect canvas, so an aspect
102        // change (which reshapes `canvas_dims`) forces a rebuild too.
103        let same_clip = guard.as_ref().is_some_and(|s| {
104            s.source_path == project.source.path && s.preview.dimensions() == project.canvas_dims()
105        });
106        if same_clip {
107            let s = guard
108                .as_mut()
109                .expect("same_clip implies an open session is present");
110            if s.project.background != project.background {
111                s.preview.set_background(&project.background);
112            }
113            s.project = project;
114        } else {
115            *guard = Some(EditorPreviewSession::open(project)?);
116        }
117        Ok(())
118    }
119
120    /// Compose the stored project's frame `frame` to BGRA bytes, or an empty
121    /// `Vec` when there is no open session or the frame is past the end (the
122    /// webview skips an empty buffer, exactly like the camera preview poll).
123    #[must_use]
124    pub fn frame_bytes(&self, frame: u64) -> Vec<u8> {
125        let mut guard = self
126            .0
127            .lock()
128            .unwrap_or_else(std::sync::PoisonError::into_inner);
129        let Some(s) = guard.as_mut() else {
130            return Vec::new();
131        };
132        crate::editor_export::compose_project_frame(
133            &mut s.preview,
134            &mut s.stream,
135            &s.project,
136            frame,
137        )
138        .map(|c| c.bytes)
139        .unwrap_or_default()
140    }
141}
142
143/// Open / update the live editor preview for `project` (AUT-510). Builds the
144/// wgpu compose pipeline + decode stream the first time (and when the source
145/// clip changes); otherwise updates the project so edits show on the next
146/// frame. Synchronous on purpose — the wgpu device is created on the Tauri
147/// command worker thread, never the webview/main thread.
148///
149/// # Errors
150///
151/// Returns a message if the source can't be opened or wgpu init fails.
152#[tauri::command]
153#[allow(
154    clippy::needless_pass_by_value,
155    reason = "Tauri deserializes `project` + injects `State` by value into #[command] fns"
156)]
157pub fn editor_preview_open(
158    project: EditProject,
159    state: State<'_, EditorPreviewState>,
160) -> Result<(), String> {
161    state.open(project)
162}
163
164/// Composed BGRA bytes for the editor preview at project `frame` (AUT-510),
165/// as a raw [`tauri::ipc::Response`] (an `ArrayBuffer` in JS — no JSON). Empty
166/// when no clip is open or the frame is past the end.
167#[tauri::command]
168#[must_use]
169#[allow(
170    clippy::needless_pass_by_value,
171    reason = "Tauri injects `State` by value into #[command] fns"
172)]
173pub fn editor_preview_frame(
174    frame: u64,
175    state: State<'_, EditorPreviewState>,
176) -> tauri::ipc::Response {
177    tauri::ipc::Response::new(state.frame_bytes(frame))
178}