Skip to main content

app_ui/
editor_preview_canvas.rs

1//! Live editor preview canvas wiring (AUT-510 / ED.6b).
2//!
3//! Replaces the old `"Preview renders here."` placeholder. Paints the composed
4//! frame (zoom / crop / background / cursor) at the playhead by polling the
5//! `editor_preview_frame` Tauri command — the **same** compose the export uses
6//! — and `putImageData`-ing it into the editor canvas, mirroring
7//! [`crate::camera_preview`]'s loop. Two hooks installed once per editor mount:
8//!
9//! - an [`Effect`] on the project signal that calls
10//!   [`editor_ipc::editor_preview_open`](crate::editor_ipc::editor_preview_open)
11//!   so the backend (re)builds the compose pipeline + sees each edit;
12//! - a `setInterval` poll (~15 fps) that requests the composed frame at the
13//!   current playhead and paints it.
14//!
15//! Unlike the fixed-size camera preview, the editor frame is the **clip's**
16//! dimensions, so the canvas is sized from `project.source` and the poll
17//! computes the expected byte length per clip (`putImageData` does not scale).
18
19use edit::EditProject;
20use leptos::prelude::*;
21
22use crate::editor_ipc::EditorStatus;
23
24#[cfg(target_arch = "wasm32")]
25use leptos::task::spawn_local;
26#[cfg(target_arch = "wasm32")]
27use wasm_bindgen::JsCast;
28#[cfg(target_arch = "wasm32")]
29use wasm_bindgen::prelude::*;
30
31/// Stable DOM id for the editor preview `<canvas>`. The poll finds it by id
32/// (the canvas element itself is rendered by the editor surface).
33pub const EDITOR_PREVIEW_CANVAS_ID: &str = "editor-preview-canvas";
34
35/// Repaint cadence (ms) — ~15 fps, matching the camera preview poll.
36pub const EDITOR_PREVIEW_POLL_MS: i32 = 66;
37
38/// Install the live-preview hooks: re-open on project change + the repaint
39/// poll. Call **once** from the editor surface body (not inside a reactive
40/// block, or each rebuild would leak another `setInterval`).
41#[cfg(target_arch = "wasm32")]
42pub fn install_editor_preview(
43    project: Option<RwSignal<Option<EditProject>>>,
44    status: RwSignal<EditorStatus>,
45) {
46    // (Re)build the compose pipeline + push edits whenever the project changes
47    // (initial load + every edit op). The backend re-opens the stream only
48    // when the source clip changes; otherwise it just swaps the project.
49    Effect::new(move |_| {
50        if let Some(sig) = project
51            && let Some(p) = sig.get()
52        {
53            crate::editor_ipc::editor_preview_open(&p);
54        }
55    });
56
57    let closure = Closure::wrap(Box::new(move || {
58        spawn_local(async move {
59            paint_one_editor_frame(project, status).await;
60        });
61    }) as Box<dyn FnMut()>);
62    if let Some(window) = web_sys::window() {
63        let _ = window.set_interval_with_callback_and_timeout_and_arguments_0(
64            closure.as_ref().unchecked_ref(),
65            EDITOR_PREVIEW_POLL_MS,
66        );
67    }
68    closure.forget();
69}
70
71/// Native stub — the editor preview runs only in the wasm webview.
72#[cfg(not(target_arch = "wasm32"))]
73pub fn install_editor_preview(
74    _project: Option<RwSignal<Option<EditProject>>>,
75    _status: RwSignal<EditorStatus>,
76) {
77}
78
79/// One paint tick: request the composed frame at the current playhead, swap
80/// BGRA→RGBA, and `putImageData` it into the editor canvas at the clip's
81/// dimensions. Best-effort — every failure path silently no-ops so a missing
82/// canvas / closed session can't crash the UI (mirrors the camera preview).
83#[cfg(target_arch = "wasm32")]
84#[allow(
85    clippy::cast_precision_loss,
86    reason = "the playhead frame index is well under 2^53, so u64→f64 is exact"
87)]
88async fn paint_one_editor_frame(
89    project: Option<RwSignal<Option<EditProject>>>,
90    status: RwSignal<EditorStatus>,
91) {
92    use js_sys::{Reflect, Uint8ClampedArray};
93    use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData};
94
95    // Clip dimensions (the composed frame size) come from the loaded project.
96    // The composed frame is the project's aspect-ratio canvas (AUT-513), which
97    // differs from the source when a non-16:9 aspect is selected — size the
98    // poll buffer to the canvas so `putImageData` matches the backend frame.
99    let Some((width, height)) = project
100        .and_then(|sig| sig.get_untracked())
101        .map(|p| p.canvas_dims())
102    else {
103        return;
104    };
105    let bytes_len = (width as usize) * (height as usize) * 4;
106    if bytes_len == 0 {
107        return;
108    }
109    let frame = status.get_untracked().current_frame;
110
111    let Some(window) = web_sys::window() else {
112        return;
113    };
114    let Ok(invoke_fn) = Reflect::get(&window, &JsValue::from_str("__screenEditorPreviewFrame"))
115    else {
116        return;
117    };
118    if !invoke_fn.is_function() {
119        return;
120    }
121    let invoke: js_sys::Function = invoke_fn.unchecked_into();
122    let Ok(promise) = invoke.call1(&JsValue::NULL, &JsValue::from_f64(frame as f64)) else {
123        return;
124    };
125    let promise: js_sys::Promise = match promise.dyn_into() {
126        Ok(p) => p,
127        Err(_) => return,
128    };
129    let Ok(buf) = wasm_bindgen_futures::JsFuture::from(promise).await else {
130        return;
131    };
132
133    let bytes = if let Ok(array_buffer) = buf.clone().dyn_into::<js_sys::ArrayBuffer>() {
134        Uint8ClampedArray::new(&array_buffer)
135    } else if let Ok(typed_array) = buf.dyn_into::<Uint8ClampedArray>() {
136        typed_array
137    } else {
138        return;
139    };
140    // Empty / no-frame (no open session, or past the end): skip.
141    if (bytes.length() as usize) < bytes_len {
142        return;
143    }
144
145    let mut rgba = vec![0u8; bytes_len];
146    bytes.copy_to(&mut rgba[..bytes_len]);
147    for px in rgba.chunks_exact_mut(4) {
148        // BGRA → RGBA (the composed frame is BGRA, like the camera frame).
149        px.swap(0, 2);
150    }
151
152    let Some(document) = window.document() else {
153        return;
154    };
155    let Some(canvas_el) = document.get_element_by_id(EDITOR_PREVIEW_CANVAS_ID) else {
156        return;
157    };
158    let Ok(canvas) = canvas_el.dyn_into::<HtmlCanvasElement>() else {
159        return;
160    };
161    let Ok(Some(ctx)) = canvas.get_context("2d") else {
162        return;
163    };
164    let Ok(ctx) = ctx.dyn_into::<CanvasRenderingContext2d>() else {
165        return;
166    };
167    let Ok(image_data) = ImageData::new_with_u8_clamped_array_and_sh(
168        wasm_bindgen::Clamped(&rgba[..]),
169        width,
170        height,
171    ) else {
172        return;
173    };
174    let _ = ctx.put_image_data(&image_data, 0.0, 0.0);
175}