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}