collective

Browse / github.com/storytold/filmcraft / patch

feat(web): a host bridge for embedding pages, ?host=parent and data-host="page" (#3341)

Web: a host bridge for embedding pages (?host=parent and same-document data-host="page") The FilmCraft web build has no host bridge, so a page that embeds it can't hand it a file or get its saves back: every save becomes a browser download, and the page can't tell whether there are unsaved changes. GridCraft, PdfCraft, WordCraft and CADCraft already have this as `?host=parent` (an iframe bridge), and storytold/photocraft#6450 asks for an embedding API across the suite. **Suggestion.** The same bridge as those apps, with both transports from the start: - `?host=parent`: a same-origin page that frames FilmCraft talks over `postMessage`, exactly like the other apps' bridges (no bri

Status
open
Score
0.00
Adopters
0 other operators
Author
helenkwok (imported from pull request #3343, open)
Base
70c1fae84063cd9c4bccad0fa7cbebcfe44213b7
Size
+686 −16 in 12 files
Review
pending
Shared
2026-10-28
Id
1301dbd7b11ec25c08e264a6

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md apps/filmcraft-web/Cargo.toml apps/filmcraft-web/src/fs.rs apps/filmcraft-web/src/host.rs apps/filmcraft-web/src/hostmode.rs apps/filmcraft-web/src/import.rs apps/filmcraft-web/src/lib.rs apps/filmcraft-web/tests/it/hostmode.rs apps/filmcraft-web/tests/it/main.rs contributors/people.toml docs/web.md packaging/web/README.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -5,7 +5,7 @@  **Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate)) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ProRes alpha final-run conformance and native macOS validation) · **Target:** Adobe Premiere Pro 2026 (26.5.2)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (web host bridge for embedding pages) · **Target:** Adobe Premiere Pro 2026 (26.5.2)  Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in [docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),@@ -107,6 +107,8 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g  ## Progress log +- **2026-10-26 (web host bridge):** the web build can be embedded: `?host=parent` (a same-origin `<iframe>`, `postMessage`) or `data-host="page"` on the canvas (the host document itself, DOM `CustomEvent`s) — the same five messages as the other Craft apps' bridges (`filmcraft:ready/open/save/dirty/failed`). A file the host sends opens like the app's own File › Open (`.fcproj`) or drop (media); every file the app would otherwise download — saves, exports, captions, interchange — goes to the host instead, with a `saveAs` flag saying whether it becomes the document's file; the host hears when unsaved changes appear or go away. Host sessions start without the demo project and without recovering an earlier visit. Not a Premiere-parity item: it exists for the Craft family's embedders (a Nextcloud app, a web desktop, an agent dashboard).+ - **2026-10-22:** ProRes 4444 / XQ alpha slices include the final singleton run code required by RDD 36:2022. Six HD fixtures rejected by native macOS decoding now pass; syntax regression tests cover final runs and slice widths ([details](crates/prores/README.md#alpha-bitstream-conformance), EffectCraft #293).  - **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088)@@ -168,6 +170,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Web host bridge: `?host=parent` / `data-host="page"` embedding protocol (`filmcraft:ready/open/save/dirty/failed`); saves, exports and opens go to the embedding page instead of downloads | | 2026-10-22 | minor | ProRes alpha: write the final singleton run code; 4444/XQ HD clips that failed native macOS decoding now decode, with syntax regression tests (EffectCraft #293) | | 2026-10-13 | minor | Language table: German covers the whole interface (~100%) | | 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |diff --git a/apps/filmcraft-web/Cargo.toml b/apps/filmcraft-web/Cargo.toml--- a/apps/filmcraft-web/Cargo.toml+++ b/apps/filmcraft-web/Cargo.toml@@ -45,6 +45,8 @@ web-sys = { version = "0.3", features = [     "Blob",     "BlobPropertyBag",     "CssStyleDeclaration",+    "CustomEvent",+    "CustomEventInit",     "DataTransfer",     "Document",     "DragEvent",diff --git a/apps/filmcraft-web/src/fs.rs b/apps/filmcraft-web/src/fs.rs--- a/apps/filmcraft-web/src/fs.rs+++ b/apps/filmcraft-web/src/fs.rs@@ -104,6 +104,16 @@ pub fn put(path: &str, data: Vec<u8>) {     FS.with(|fs| fs.borrow_mut().entries.insert(path.to_string(), Entry::Mem(data.into()))); } +/// The in-memory bytes written at `path` (saved projects and exports are held here; host mode+/// hands them to the host at the next frame instead of downloading them). Blob entries — opened+/// media — are never written, only registered, so a written path is always in memory.+pub fn mem_bytes(path: &str) -> Option<Arc<[u8]>> {+    FS.with(|fs| match fs.borrow().entries.get(path) {+        Some(Entry::Mem(b)) => Some(b.clone()),+        _ => None,+    })+}+ /// (path, size, kind) of every file. pub fn list() -> Vec<(String, u64, &'static str)> {     FS.with(|fs| {diff --git a/apps/filmcraft-web/src/host.rs b/apps/filmcraft-web/src/host.rsnew file mode 100644--- /dev/null+++ b/apps/filmcraft-web/src/host.rs@@ -0,0 +1,386 @@+//! Host modes: a page that runs FilmCraft (as an `<iframe>`, or in its own document) opens+//! projects in it and stores what it saves instead of the browser downloading. The same+//! messages as the other Craft apps' bridges (WordCraft, GridCraft, PdfCraft), with the+//! `filmcraft:` prefix.+//!+//! ## `?host=parent` — an `<iframe>` (such as a Nextcloud app)+//!+//! The framing page and FilmCraft talk over `postMessage` between same-origin windows. Messages+//! are plain objects with a `type`:+//!+//! - `filmcraft:ready` (to the parent): `{ version }`, sent once the app is running and+//!   listening; send a file now.+//! - `filmcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or+//!   `Uint8Array`. A `.fcproj` opens like a project picked with File › Open or dropped on the+//!   page; any other file imports like a dropped one. Over [`MAX_OPEN_BYTES`] (512 MiB, refused+//!   before any byte is copied) or malformed: a message in the app's status bar, never a crash.+//! - `filmcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`)+//!   for every file FilmCraft would otherwise download — see the `saveAs` rule below. The+//!   parent stores it and reports failures itself.+//! - `filmcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away+//!   (the session's own unsaved-changes state, reported only when it changes).+//! - `filmcraft:failed` (to the parent): `{ error }` when the app couldn't start.+//!+//! Only the parent window on the page's own origin is heard or answered. With no parent, or a+//! parent on another origin (messages to it are dropped silently, so saves would vanish while+//! the document looked saved), there is no bridge: the normal app with downloads.+//!+//! ## `data-host="page"` — the page's own document+//!+//! A page that loads the module into its own document (its own `<canvas id="filmcraft_canvas"+//! data-host="page">`, no iframe) gets the same behaviour over DOM `CustomEvent`s on the canvas+//! instead of `postMessage`: the same four events outward (`ready`, `save`, `dirty`, `failed`,+//! with the message fields as the event's `detail`), and the page dispatches `filmcraft:open`+//! on the canvas with `detail: { name, bytes }` to open a file. Events don't bubble, so two+//! editors on one page can't hear each other, and the canvas is the only target FilmCraft+//! listens on.+//!+//! ## The `saveAs` rule+//!+//! Every file the app writes for the user — saved projects, video/frame/audio exports,+//! captions, interchange documents (AAF, OMF, EDL, markers), extracted audio, the schema+//! backup kept beside an upgraded project — is queued from the write hook+//! ([`Bridge::install`]) and handed to the host at the next frame ([`Bridge::sync`]), when the+//! frame's commands have settled and the session's state is final. Nothing reaches the+//! browser's download in host mode. `saveAs` says whether the file becomes the document's file+//! — the project the next Save writes ([`is_save_as`], pure and unit-tested):+//!+//! - A write the session adopts as its project path (Save As; also the first Save of an+//!   untitled project, which the UI runs as Save As under a picked name) that the host does+//!   not already have as the document: `true`. From then on, Saves of that document carry its+//!   name with `false`.+//! - A Save of the document under its own name — including one the host opened, and a Save As+//!   back to the file the host already has as the document: `false`.+//! - Everything else — Save a Copy, exports, captions, interchange, extracted audio, the+//!   schema backup: `false`. They are copies; the document's file stays the same.+//!+//! "The document the host has" is what the host can know: the project it opened over this+//! bridge, and every file it received with `saveAs: true`. A project opened inside the app+//! (File › Open) is new to the host, so its first Save goes out with `true`.+//!+//! In host mode the host sends the document to edit, so no demo project opens and nothing from+//! an earlier visit is recovered (no OPFS snapshot, no restored media), saves are handed to the+//! host rather than downloaded, and the host guards leaving the page while changes are+//! unsaved. FilmCraft has no user names that work could be signed with, so there is no+//! `?author=`.++use std::cell::{Cell, RefCell};+use std::rc::Rc;++use filmcraft_engine::Session;+use wasm_bindgen::JsCast as _;+use wasm_bindgen::JsValue;+use wasm_bindgen::closure::Closure;++use crate::hostmode::{MAX_OPEN_BYTES, is_project, is_save_as, open_name, open_size_ok};++/// How a host page is reached. The modes behave the same; they differ only in how an event goes+/// out ([`Transport::send`]) and how an open request comes in ([`Bridge::install`]).+#[derive(Clone)]+enum Transport {+    /// `?host=parent`: `postMessage` between same-origin windows.+    Parent { window: web_sys::Window, parent: web_sys::Window, origin: String },+    /// `data-host="page"`: DOM `CustomEvent`s on the editor's own canvas.+    Page { canvas: web_sys::HtmlCanvasElement },+}++impl Transport {+    /// Hand an event to the host. In parent mode this is a `postMessage` whose object carries a+    /// `type` field; in page mode a `CustomEvent` of that name on the canvas, whose `detail`+    /// holds the fields and which never bubbles (two editors on one page must not hear each+    /// other). `transfer` hands an `ArrayBuffer` to the parent instead of copying it (parent+    /// mode only; the page's document already shares the buffer's bytes).+    fn send(&self, event: &str, fields: &[(&str, JsValue)], transfer: Option<&js_sys::ArrayBuffer>) -> Result<(), String> {+        match self {+            Transport::Parent { parent, origin, .. } => {+                let mut message_fields = Vec::with_capacity(fields.len() + 1);+                message_fields.push(("type", JsValue::from_str(event)));+                message_fields.extend_from_slice(fields);+                let message = message(&message_fields).ok_or_else(|| "couldn't build the message".to_string())?;+                let sent = match transfer {+                    Some(buffer) => parent.post_message_with_transfer(&message, origin, &js_sys::Array::of1(buffer)),+                    None => parent.post_message(&message, origin),+                };+                sent.map_err(|e| js_error(&e, "couldn't post the message"))+            }+            Transport::Page { canvas } => {+                let detail = message(fields).ok_or_else(|| "couldn't build the event detail".to_string())?;+                let init = web_sys::CustomEventInit::new();+                // The builder methods (`bubbles(..)`, `detail(..)`) are deprecated in web-sys+                // 0.3.106 and fail clippy -D warnings; use the setters.+                init.set_bubbles(false);+                init.set_detail(&detail);+                let custom = web_sys::CustomEvent::new_with_event_init_dict(event, &init).map_err(|e| js_error(&e, "couldn't create the event"))?;+                // The bool says whether a listener called `preventDefault`; not a send failure.+                canvas.dispatch_event(&custom).map_err(|e| js_error(&e, "couldn't dispatch the event"))?;+                Ok(())+            }+        }+    }+}++/// The host bridge in host mode. Cloned into the write hook and the open listener; the state+/// they share is behind `Rc`.+#[derive(Clone)]+pub struct Bridge {+    transport: Transport,+    /// Files written since the last frame: their paths (the bytes stay in the file table,+    /// [`crate::fs::mem_bytes`], so a large export is never copied a second time).+    written: Rc<RefCell<Vec<String>>>,+    /// The document file the host knows: the project it opened here, and every file it was+    /// sent with `saveAs: true`.+    host_document: Rc<RefCell<Option<String>>>,+    /// The unsaved-changes state last reported to the host.+    reported: Rc<Cell<Option<bool>>>,+}++impl Bridge {+    /// The parent bridge for `?host=parent`. `None` when the page isn't framed (there is no+    /// parent to talk to), so opening that URL directly stays the normal app with downloads, or+    /// when the parent is on another origin: messages only go to this origin, so the browser+    /// would drop every save without an error while the document showed as saved.+    pub fn new() -> Option<Self> {+        let window = web_sys::window()?;+        let parent = window.parent().ok().flatten()?;+        if js_sys::Object::is(&parent, &window) {+            return None;+        }+        let origin = window.location().origin().ok()?;+        // An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.+        if origin == "null" {+            return None;+        }+        // Reading another origin's location throws, which is `None` too.+        if parent.location().origin().ok()? != origin {+            return None;+        }+        Some(Self { transport: Transport::Parent { window, parent, origin }, written: Rc::default(), host_document: Rc::default(), reported: Rc::default() })+    }++    /// The page bridge for `data-host="page"`: the host page runs FilmCraft in its own document+    /// and hears the editor's DOM `CustomEvent`s on this canvas.+    pub fn for_page(canvas: &web_sys::HtmlCanvasElement) -> Self {+        Self { transport: Transport::Page { canvas: canvas.clone() }, written: Rc::default(), host_document: Rc::default(), reported: Rc::default() }+    }++    /// Install the bridge: hear the host's files, and hand the app's writes to it instead of+    /// the browser's download.+    pub fn install(&self) {+        self.listen();+        let bridge = self.clone();+        crate::fs::set_on_write(move |path: &str, _bytes: &[u8]| bridge.wrote(path));+    }++    /// After each frame's commands (the web app's per-frame logic pass): files written this+    /// frame go to the host, with the `saveAs` decision from the session's state (the module+    /// doc has the rule), and the host hears when unsaved changes appear or go away.+    pub fn sync(&self, session: &Session) {+        self.flush_saves(session);+        let dirty = session.is_dirty();+        if self.reported.get() != Some(dirty) && self.transport.send("filmcraft:dirty", &[("dirty", dirty.into())], None).is_ok() {+            self.reported.set(Some(dirty));+        }+    }++    pub fn post_ready(&self) {+        let _ = self.transport.send("filmcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None);+    }++    /// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.+    pub fn post_failed(&self, error: &str) {+        let _ = self.transport.send("filmcraft:failed", &[("error", error.into())], None);+    }++    /// The host's project opened: the host has this file as the document, so a Save of it goes+    /// out under its own name (`saveAs: false`).+    fn opened_document(&self, path: &str) {+        *self.host_document.borrow_mut() = Some(path.to_string());+    }++    /// A file the app wrote (saved project, export, …): queued for [`Bridge::sync`], and a+    /// repaint in case the write happened outside a frame.+    fn wrote(&self, path: &str) {+        self.written.borrow_mut().push(path.to_string());+        crate::repaint();+    }++    /// Hand this frame's written files to the host. The `saveAs` decision needs the session's+    /// state *after* the frame's commands (a Save As adopts its path only once the write has+    /// happened), so it is made here, not in the write hook, one file at a time against the+    /// host's document as the earlier hand-overs left it.+    fn flush_saves(&self, session: &Session) {+        let written = std::mem::take(&mut *self.written.borrow_mut());+        if written.is_empty() {+            return;+        }+        let current = session.path.clone();+        for path in written {+            let Some(bytes) = crate::fs::mem_bytes(&path) else {+                log::warn!("host save {path}: the written bytes are gone");+                continue;+            };+            let name = file_name(&path);+            // Each save is classified against the host's document as it is now, after the+            // previous hand-overs (a failed one changed nothing), and the borrow is dropped before+            // sending: in page mode a host listener reacts to a save synchronously, and may open+            // a file right back.+            let before = self.host_document.borrow().clone();+            let save_as = is_save_as(&path, before.as_deref(), current.as_deref());+            match self.post_save(&name, &bytes, save_as) {+                // The host has this file as the document now, unless it opened another one while+                // handling the save; a failed hand-over changes nothing, so a retry is still a Save As.+                Ok(()) if save_as => {+                    let mut document = self.host_document.borrow_mut();+                    if *document == before {+                        *document = Some(path);+                    }+                }+                Ok(()) => {}+                Err(e) => log::error!("couldn't hand {name} to the host: {e}"),+            }+        }+    }++    fn post_save(&self, name: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {+        // `filmcraft:save` carries the bytes as an `ArrayBuffer`; the parent bridge transfers it.+        let buffer = js_sys::Uint8Array::from(bytes).buffer();+        let fields: [(&str, JsValue); 3] = [("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())];+        self.transport.send("filmcraft:save", &fields, Some(&buffer))+    }++    /// Hear `filmcraft:open` — from the same-origin parent (a `message` on the window) or from+    /// the host document (a `filmcraft:open` event on the canvas) — and open or import the file+    /// like one the user picked or dropped.+    fn listen(&self) {+        let bridge = self.clone();+        match &self.transport {+            Transport::Parent { window, parent, origin } => {+                let parent = parent.clone();+                let origin = origin.clone();+                let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {+                    if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {+                        return;+                    }+                    let data = event.data();+                    if string(&data, "type").as_deref() != Some("filmcraft:open") {+                        return;+                    }+                    deliver_open(&bridge, &data);+                });+                if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {+                    // The listener lives as long as the page.+                    on_message.forget();+                }+            }+            Transport::Page { canvas } => {+                let on_open = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {+                    // A plain `Event` of the same name carries no detail; only a `CustomEvent`.+                    let Some(open) = event.dyn_ref::<web_sys::CustomEvent>() else { return };+                    deliver_open(&bridge, &open.detail());+                });+                if canvas.add_event_listener_with_callback("filmcraft:open", on_open.as_ref().unchecked_ref()).is_ok() {+                    // The listener lives as long as the page.+                    on_open.forget();+                }+            }+        }+    }+}++/// Handle a `filmcraft:open` payload: `{ name, bytes }`, bytes an `ArrayBuffer` or+/// `Uint8Array` — checked, then opened or imported like a file the user picked or dropped.+fn deliver_open(bridge: &Bridge, data: &JsValue) {+    let Ok(raw) = js_sys::Reflect::get(data, &JsValue::from_str("bytes")) else { return };+    if !raw.is_instance_of::<js_sys::ArrayBuffer>() && raw.dyn_ref::<js_sys::Uint8Array>().is_none() {+        return rejected_open("the bytes are neither an ArrayBuffer nor a Uint8Array".to_string());+    }+    let Some(len) = byte_length(&raw) else { return rejected_open("the bytes have no readable length".to_string()) };+    if !open_size_ok(len) {+        return rejected_open(format!("{len} bytes is over the {} MiB host open limit", MAX_OPEN_BYTES >> 20));+    }+    let name = open_name(string(data, "name").as_deref());+    if is_project(&name) {+        let Some(bytes) = bytes_from(&raw) else { return rejected_open("couldn't read the bytes".to_string()) };+        let path = format!("/projects/{name}");+        let opened = bridge.clone();+        wasm_bindgen_futures::spawn_local(async move {+            match crate::import::open_project_bytes(path.clone(), bytes).await {+                // The host has this file as the document now: a Save of it goes out under its+                // own name (`saveAs: false`).+                Ok(_) => opened.opened_document(&path),+                Err(e) => crate::post(move |w, _| w.app.status(e)),+            }+        });+    } else {+        // Media registers like a dropped file: a `Blob` over the bytes, never copied whole.+        let Some(blob) = blob_from_bytes(&raw) else { return rejected_open("couldn't wrap the bytes in a Blob".to_string()) };+        wasm_bindgen_futures::spawn_local(async move { crate::import::import_blob(name, blob).await });+    }+}++/// An open request the app can't act on: said in the app's status bar, never a panic. Takes+/// the reason owned: it reaches the status bar a frame later.+fn rejected_open(why: String) {+    crate::post(move |w, _| w.app.status(format!("the host's file was not opened: {why}")));+}++/// The payload's `byteLength`, read as a number: the bindings' `u32` would wrap a 4 GiB view+/// to 0 and let it past the size check.+fn byte_length(v: &JsValue) -> Option<u64> {+    let n = js_sys::Reflect::get(v, &JsValue::from_str("byteLength")).ok()?.as_f64()?;+    if !n.is_finite() || n < 0.0 {+        return None;+    }+    Some(n as u64)+}++/// The payload as Rust bytes (a project opens from them): one copy, from an `ArrayBuffer` or a+/// `Uint8Array`.+fn bytes_from(v: &JsValue) -> Option<Vec<u8>> {+    if let Some(array) = v.dyn_ref::<js_sys::Uint8Array>() {+        Some(array.to_vec())+    } else if v.is_instance_of::<js_sys::ArrayBuffer>() {+        Some(js_sys::Uint8Array::new(v).to_vec())+    } else {+        None+    }+}++/// The payload as a `Blob` (media registers like a dropped file): a view over the bytes, no+/// copy on the wasm side — the `Blob` constructor copies into its own store.+fn blob_from_bytes(v: &JsValue) -> Option<web_sys::Blob> {+    let parts = js_sys::Array::new();+    if let Some(view) = v.dyn_ref::<js_sys::Uint8Array>() {+        parts.push(view);+    } else if v.is_instance_of::<js_sys::ArrayBuffer>() {+        parts.push(&js_sys::Uint8Array::new(v));+    } else {+        return None;+    }+    let opts = web_sys::BlobPropertyBag::new();+    opts.set_type("application/octet-stream");+    web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).ok()+}++fn file_name(path: &str) -> String {+    std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())+}++/// A message object or `CustomEvent` detail with the given fields, or `None` if one couldn't be+/// built (a field that isn't a valid property name, say).+fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {+    let m = js_sys::Object::new();+    for (key, value) in fields {+        js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;+    }+    Some(m)+}++fn string(data: &JsValue, key: &str) -> Option<String> {+    js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()+}++/// A `JsValue` error as a string, after `what` went wrong.+fn js_error(e: &JsValue, what: &str) -> String {+    format!("{what}: {}", e.as_string().unwrap_or_else(|| format!("{e:?}")))+}diff --git a/apps/filmcraft-web/src/hostmode.rs b/apps/filmcraft-web/src/hostmode.rsnew file mode 100644--- /dev/null+++ b/apps/filmcraft-web/src/hostmode.rs@@ -0,0 +1,52 @@+//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser:+//! what opts a page into hosting FilmCraft in its own document, and the name and kind the+//! bridge reads out of an open request. Pure, so it is tested natively in `tests/it/hostmode.rs`.++/// The one `data-host` value that opts a page in: the exact string `"page"`.+pub const PAGE_HOST: &str = "page";++/// The file name used when an open request carries no usable one: an untitled project — what+/// a host that never names a file still opens (FilmCraft's document is its project file).+pub const DEFAULT_OPEN_NAME: &str = "Untitled.fcproj";++/// The extension that makes a host's file open like File › Open; anything else imports like a+/// file dropped on the page.+pub const PROJECT_EXT: &str = ".fcproj";++/// Does this `data-host` value ask for page host mode? Only the exact value [`PAGE_HOST`]+/// counts; anything else is ignored, so the page host mode stays opt-in. `?host=parent`, the+/// parent bridge's URL opt-in, is answered before this is asked.+pub fn page_host_requested(data_host: Option<&str>) -> bool {+    data_host == Some(PAGE_HOST)+}++/// A usable file name for an open request: the host's name when it isn't blank, else the+/// [`DEFAULT_OPEN_NAME`]. `/` and `\` become `_`, as [`crate::fs::register_blob`] does for+/// dropped names — the name becomes a path in the virtual file table.+pub fn open_name(name: Option<&str>) -> String {+    let name = name.filter(|n| !n.trim().is_empty()).unwrap_or(DEFAULT_OPEN_NAME);+    name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect()+}++/// Is this host file a project (open like File › Open) rather than media to import (like a+/// drop)? The app's own drop rule: the name's extension, case-insensitive.+pub fn is_project(name: &str) -> bool {+    name.to_ascii_lowercase().ends_with(PROJECT_EXT)+}++/// The largest `filmcraft:open` payload, refused before any byte is copied. FilmCraft has no+/// single open limit (media is read through a chunked cache, projects whole at 256 MiB), so+/// the bridge takes the family's sane default.+pub const MAX_OPEN_BYTES: u64 = 512 << 20;++/// Whether a payload may be copied: the check runs before any byte is copied.+pub fn open_size_ok(len: u64) -> bool {+    len <= MAX_OPEN_BYTES+}++/// Whether a written file becomes the document's file (`filmcraft:save` with `saveAs: true`):+/// a write the session adopted as its project path that the host did not already have as the+/// document.+pub fn is_save_as(write: &str, host_document: Option<&str>, project_path: Option<&str>) -> bool {+    host_document != Some(write) && project_path == Some(write)+}diff --git a/apps/filmcraft-web/src/import.rs b/apps/filmcraft-web/src/import.rs--- a/apps/filmcraft-web/src/import.rs+++ b/apps/filmcraft-web/src/import.rs@@ -135,9 +135,34 @@ pub async fn import_files(files: Vec<web_sys::File>) -> Value { /// Open a `.fcproj` file picked or dropped by the user. pub async fn open_project_file(file: web_sys::File) -> Result<Value, String> {     let buf = JsFuture::from(file.array_buffer()).await.map_err(|e| format!("{e:?}"))?;-    let path = format!("/projects/{}", file.name());-    fs::put(&path, js_sys::Uint8Array::new(&buf).to_vec());-    on_ui(move |w, _| w.app.session.execute("file.open", json!({"path": path})).map_err(|e| e.to_string())).await+    open_project_bytes(format!("/projects/{}", file.name()), js_sys::Uint8Array::new(&buf).to_vec()).await+}++/// Open a `.fcproj` handed over as bytes (a host's `filmcraft:open`) at a virtual `/projects/`+/// path, like a picked or dropped project: into the file table, then `file.open`.+pub async fn open_project_bytes(path: String, bytes: Vec<u8>) -> Result<Value, String> {+    fs::put(&path, bytes);+    on_ui(move |w, _| w.app.session.execute("file.open", json!({ "path": path })).map_err(|e| e.to_string())).await+}++/// Import a file handed over by a host (`filmcraft:open`): registered like a dropped file (a+/// `Blob` under `/files`, never copied whole), the container index prefetched, then+/// `file.import`. Media from the host is not kept for crash recovery: host sessions don't+/// recover.+pub async fn import_blob(name: String, blob: web_sys::Blob) {+    let bin = on_ui(|w, _| w.app.import_bin().0).await;+    let size = blob.size() as u64;+    let path = fs::register_blob(&name, blob);+    prewarm(&path, size).await;+    match import_path(path, bin).await {+        Ok(v) => {+            if let Some(errors) = v.get("errors").and_then(Value::as_array).filter(|e| !e.is_empty()) {+                let msg = errors.iter().filter_map(Value::as_str).collect::<Vec<_>>().join("; ");+                crate::post(move |w, _| w.app.status(msg));+            }+        }+        Err(e) => crate::post(move |w, _| w.app.status(e)),+    } }  /// Handle files from a picker or a drop: projects open, everything else is imported.diff --git a/apps/filmcraft-web/src/lib.rs b/apps/filmcraft-web/src/lib.rs--- a/apps/filmcraft-web/src/lib.rs+++ b/apps/filmcraft-web/src/lib.rs@@ -22,6 +22,8 @@ pub mod api; pub mod audio; pub mod download_pacing; pub mod fs;+pub mod host;+pub mod hostmode; pub mod import; pub mod opfs; pub mod recovery;@@ -87,6 +89,10 @@ pub struct WebApp {     pub app: FilmcraftApp,     pub audio: Option<audio::Handle>,     pub autosave: recovery::Autosave,+    /// The host bridge in host mode (`?host=parent` / `data-host="page"`): written files go to+    /// the host instead of downloads, it hears about unsaved changes, and it opens the files it+    /// sends.+    pub host: Option<host::Bridge>, }  impl eframe::App for WebApp {@@ -94,7 +100,7 @@ impl eframe::App for WebApp {         self.app.raw_input_hook(ctx, raw_input);     } -    /// Hidden logic pass: host actions, app logic, API replies, jobs, audio, autosave, then IME reset; it does not draw UI.+    /// Hidden logic pass: host actions, app logic, API replies, jobs, host saves and dirty reports, audio, autosave, then IME reset; it does not draw UI.     fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {         let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));         for f in actions {@@ -106,6 +112,11 @@ impl eframe::App for WebApp {         if self.app.session.pump_jobs(std::time::Duration::from_millis(30)) {             ctx.request_repaint();         }+        // In host mode: files written this frame (commands, exports) go to the host, and it+        // hears when unsaved changes appear or go away.+        if let Some(host) = &self.host {+            host.sync(&self.app.session);+        }         if let Some(a) = &self.audio {             a.pump();         }@@ -122,6 +133,10 @@ fn query_flag(name: &str) -> bool {     web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).is_some_and(|p| p.has(name)) } +fn query_param(name: &str) -> Option<String> {+    web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).and_then(|p| p.get(name))+}+ /// Entry point (called by the page's bootstrap script once the wasm module is instantiated). #[wasm_bindgen] pub async fn start(canvas_id: String) -> Result<(), JsValue> {@@ -130,6 +145,21 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {     let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;     let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?; +    // Host modes: `?host=parent`, a same-origin page framing FilmCraft in an `<iframe>`, opens+    // files in it and stores what it saves (`host`); unframed there is no parent, so that URL+    // on its own stays the normal app with downloads. `data-host="page"` on the canvas opts a+    // page in instead: it runs FilmCraft in its own document and talks over DOM `CustomEvent`s+    // on the canvas. In host mode the host sends the document to edit, so no demo project+    // opens and nothing from an earlier visit is recovered.+    let host = if query_param("host").as_deref() == Some("parent") {+        host::Bridge::new()+    } else if hostmode::page_host_requested(canvas.get_attribute("data-host").as_deref()) {+        Some(host::Bridge::for_page(&canvas))+    } else {+        None+    };+    let in_host_mode = host.is_some();+     let isolated = js_sys::Reflect::get(&js_sys::global(), &"crossOriginIsolated".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);     // Frame rendering runs cooperatively on this thread. wasm threads need a cross-origin     // isolated page *and* a build with atomics (nightly `build-std`); this build has none.@@ -139,14 +169,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {     set_info("opfs", json!(opfs::available()));      // Media kept in OPFS by earlier sessions come back under their paths (recovery).-    let restored = if opfs::available() && !query_flag("fresh") { recovery::restore_media().await } else { 0 };-    let recovered = if query_flag("fresh") || query_flag("norecover") { None } else { recovery::load_snapshot().await };+    let restored = if !in_host_mode && opfs::available() && !query_flag("fresh") { recovery::restore_media().await } else { 0 };+    let recovered = if in_host_mode || query_flag("fresh") || query_flag("norecover") { None } else { recovery::load_snapshot().await };     webcodecs::probe().await;      api::install()?;     import::install_drop_handlers()?; -    let demo = !query_flag("empty");+    let demo = !in_host_mode && !query_flag("empty");     let mut web_options = eframe::WebOptions::default();     // `?webgl`: WebGL2 only. The page reloads with it when starting on WebGPU fails (an adapter     // whose device can't be created, a driver the browser rejects…); see `index.html`.@@ -156,7 +186,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {         c.instance_descriptor.backends = eframe::wgpu::Backends::GL;     }     let runner = eframe::WebRunner::new();-    runner+    let host_for_app = host.clone();+    let started = runner         .start(             canvas,             web_options,@@ -165,11 +196,19 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {                 set_context(cc.egui_ctx.clone());                 let ctx = cc.egui_ctx.clone();                 fs::set_on_data(move || ctx.request_repaint());-                fs::set_on_write(|path, data| {-                    if let Err(e) = fs::download(path, data) {-                        log::warn!("download {path}: {e:?}");+                // Host mode: written files (saved projects, exports, …) are handed to the host+                // instead of downloading, and it opens the files it sends. Otherwise the app+                // offers what it writes as a browser download.+                match &host_for_app {+                    Some(bridge) => bridge.install(),+                    None => {+                        fs::set_on_write(|path, data| {+                            if let Err(e) = fs::download(path, data) {+                                log::warn!("download {path}: {e:?}");+                            }+                        });                     }-                });+                }                 webcodecs::install();                 let mut session = Session::new(Arc::new(fs::WebServices));                 let mut toast = None;@@ -217,10 +256,18 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {                 }                 set_info("audio", json!(audio.is_some()));                 set_info("restoredMedia", json!(restored));-                Ok(Box::new(WebApp { app, audio, autosave: recovery::Autosave::default() }))+                Ok(Box::new(WebApp { app, audio, autosave: recovery::Autosave::default(), host: host_for_app }))             }),         )-        .await?;+        .await;+    // Ready only once the runner is up: starting can still fail after the app is made.+    if let Some(bridge) = &host {+        match &started {+            Ok(()) => bridge.post_ready(),+            Err(e) => bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}"))),+        }+    }+    started?;     set_info("startupMs", json!(t0.elapsed().as_secs_f64() * 1000.0));     // keep the runner alive for the page's lifetime     std::mem::forget(runner);diff --git a/apps/filmcraft-web/tests/it/hostmode.rs b/apps/filmcraft-web/tests/it/hostmode.rsnew file mode 100644--- /dev/null+++ b/apps/filmcraft-web/tests/it/hostmode.rs@@ -0,0 +1,57 @@+//! The page host mode's pure rules (the `data-host` opt-in, open names, project or media). They run+//! natively even though the rest of the crate is wasm32-only.++#[path = "../../src/hostmode.rs"]+mod rules;++use rules::{DEFAULT_OPEN_NAME, MAX_OPEN_BYTES, is_project, is_save_as, open_name, open_size_ok, page_host_requested};++#[test]+fn only_the_exact_page_value_opts_in() {+    assert!(page_host_requested(Some("page")));+    for off in [None, Some(""), Some("Page"), Some("PAGE"), Some(" page"), Some("page "), Some("parent")] {+        assert!(!page_host_requested(off), "{off:?} must keep the page host mode off");+    }+}++#[test]+fn open_names_fall_back_to_the_default_and_lose_path_separators() {+    assert_eq!(open_name(Some("Trailer v2.fcproj")), "Trailer v2.fcproj");+    assert_eq!(open_name(Some(" Sheet2/../../etc.fcproj ")), " Sheet2_.._.._etc.fcproj ");+    assert_eq!(open_name(Some("")), DEFAULT_OPEN_NAME);+    assert_eq!(open_name(Some("   ")), DEFAULT_OPEN_NAME);+    assert_eq!(open_name(None), DEFAULT_OPEN_NAME);+}++#[test]+fn only_project_files_open_as_projects() {+    assert!(is_project("Trailer.fcproj"));+    assert!(is_project("TRAILER.FCPROJ"));+    assert!(!is_project("clip.mp4"));+    assert!(!is_project("notes.txt"));+    assert!(!is_project("fcproj"));+    assert!(!is_project(""));+}++#[test]+fn save_as_only_when_the_host_gains_a_new_document_file() {+    // Save of the document the host opened, under its own name — and a Save As back to the+    // file the host already has as the document: it stays the document, so `false`.+    assert!(!is_save_as("/projects/Movie.fcproj", Some("/projects/Movie.fcproj"), Some("/projects/Movie.fcproj")));+    // Save As (the session adopts the written path), and the first Save of an untitled+    // project: the file becomes the document's file, new to the host.+    assert!(is_save_as("/projects/Trailer.fcproj", Some("/projects/Movie.fcproj"), Some("/projects/Trailer.fcproj")));+    assert!(is_save_as("/projects/Untitled.fcproj", None, Some("/projects/Untitled.fcproj")));+    // Save a Copy, exports, captions, the schema backup: copies; the document's file stays.+    assert!(!is_save_as("/projects/Movie copy.fcproj", Some("/projects/Movie.fcproj"), Some("/projects/Movie.fcproj")));+    assert!(!is_save_as("/exports/trailer.mp4", Some("/projects/Movie.fcproj"), Some("/projects/Movie.fcproj")));+    assert!(!is_save_as("/exports/captions.srt", None, None));+}++#[test]+fn open_sizes_up_to_the_limit_are_accepted() {+    assert!(open_size_ok(0));+    assert!(open_size_ok(MAX_OPEN_BYTES));+    assert!(!open_size_ok(MAX_OPEN_BYTES + 1));+    assert_eq!(MAX_OPEN_BYTES >> 20, 512);+}diff --git a/apps/filmcraft-web/tests/it/main.rs b/apps/filmcraft-web/tests/it/main.rs--- a/apps/filmcraft-web/tests/it/main.rs+++ b/apps/filmcraft-web/tests/it/main.rs@@ -1,2 +1,3 @@ mod download_pacing;+mod hostmode; mod recovery_policy;diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -88,3 +88,6 @@ sync_github_name = false  [people.webdevtodayjason] display_name = "Jason Brashear"++[people.helenkwok]+real_name = "Helen Kwok"diff --git a/docs/web.md b/docs/web.md--- a/docs/web.md+++ b/docs/web.md@@ -31,7 +31,9 @@ unaffected; `cargo xtask wasm` (part of `cargo xtask ci`) checks it, with `filmc URL flags: `?empty` (no demo project), `?norecover` (don't reopen the auto-saved project), `?fresh` (also don't restore media kept in OPFS), `?cpu` (CPU compositor), `?webgl` (WebGL2 only; the page reloads with it when WebGPU is present but fails to start), `?nowebcodecs` (decode with-FilmCraft's own decoders only).+FilmCraft's own decoders only), `?host=parent` (host mode: a same-origin page embedding the app+hands it files and takes its saves back instead of downloads; `data-host="page"` on the canvas+is the no-iframe form — [hosting](../packaging/web/README.md)).  If the app panics after start-up (or runs out of memory), `index.html` shows a "FilmCraft stopped working" overlay with the panic message and a Reload button instead of a frozen canvas, and setsdiff --git a/packaging/web/README.md b/packaging/web/README.md--- a/packaging/web/README.md+++ b/packaging/web/README.md@@ -75,6 +75,88 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo   downloads) are blocked. - Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page. +## The parent bridge (`?host=parent`)++A page framing FilmCraft in a **same-origin** iframe can add `?host=parent` to the iframe's URL+(`https://example.com/filmcraft/?host=parent`) and have FilmCraft hand its files over instead of+downloading them. The two sides talk `postMessage` with plain window messages that carry a `type`:++| Message | Direction | Fields |+|---|---|---|+| `filmcraft:ready` | app → page | `version`: the app is running and listening; send a file now |+| `filmcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): a `.fcproj` opens like File › Open, anything else imports like a file dropped on the page. Over 512 MiB or malformed: refused with a message in the app, nothing crashes |+| `filmcraft:save` | app → page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download — Save, Save As, Save a Copy, video / frame / audio exports, captions, interchange (AAF, OMF, EDL, markers), extracted audio, the schema backup. The page stores it and reports failures itself. `saveAs` says whether the file becomes the document's file — the project the next Save writes: `true` when the app adopts the file and the page didn't already have it as the document (Save As, and the first Save of a project the page never sent); `false` for a Save of the document under its own name and for every copy (Save a Copy, exports, the schema backup beside an upgraded project) |+| `filmcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |+| `filmcraft:failed` | app → page | `error`: the app couldn't start (no WebGPU or WebGL2, for example) |++In host mode the app opens no demo project and restores nothing from an earlier visit (no OPFS+snapshot, no restored media), so the framing page should send the file to edit and guard leaving+the page while changes are unsaved. Written files are also kept in the app's virtual file table+(`filmcraft.files()` / `readFile`), but the host page is their destination. Opening `?host=parent`+outside an iframe gives the normal app with downloads.++## Same-document host mode (no iframe)++A host application can also run FilmCraft from its own document: it loads the wasm-bindgen module+itself and gives FilmCraft one of its own canvases, so there is no iframe and no `?host=parent`.+It opts in with `data-host="page"` on that canvas — the exact value `page` — and the canvas keeps+the id the app looks up (`id="filmcraft_canvas"`).++```html+<canvas id="filmcraft_canvas" data-host="page" style="width: 100%; height: 720px; display: block;"></canvas>+```++The page and the app then talk with DOM `CustomEvent`s on the canvas instead of `postMessage`.+The app dispatches the same messages it would post to a parent — `filmcraft:ready`,+`filmcraft:save`, `filmcraft:dirty`, `filmcraft:failed` — with the message fields (without the+`type`) as the event's `detail`, and it opens a file when the page dispatches a `filmcraft:open`+event with `detail: { name, bytes }`. Events do not bubble, so listen on the canvas itself (two+editors on one page can't hear each other).++Add the listeners before `init`: the app announces itself with `filmcraft:ready` while `init` is+still running, and a file sent in reply would otherwise be missed. And unlike the standalone+page, which starts itself, a host calls the glue's exported `start` with the canvas id after+`init`.++```js+import init, { start } from "./filmcraft_web.js"; // the wasm-bindgen glue shipped with the app++const canvas = document.getElementById("filmcraft_canvas");++canvas.addEventListener("filmcraft:ready", async () => {+  // The app is listening; hand it a project now, if you have one.+  const bytes = await projectBytes(); // an ArrayBuffer or Uint8Array+  canvas.dispatchEvent(new CustomEvent("filmcraft:open", {+    detail: { name: "Trailer.fcproj", bytes },+  }));+});++// Every file the app would otherwise download (Save, Save As, exports, …) arrives here.+canvas.addEventListener("filmcraft:save", (event) => {+  const { name, saveAs, bytes } = event.detail; // bytes is an ArrayBuffer+  // Store the file under `name`; `saveAs` says whether it becomes the document's file.+  // Report your own errors.+  storeFile(name, bytes, saveAs);+});+canvas.addEventListener("filmcraft:dirty", (event) => {+  unsaved = event.detail.dirty; // warn before leaving, as you like+});+canvas.addEventListener("filmcraft:failed", (event) => {+  showError(event.detail.error); // the app couldn't start+});++await init({ module_or_path: "./filmcraft_web_bg.wasm?v=<build hash>" });+await start("filmcraft_canvas"); // the canvas id the app renders on+```++- Saves are not downloads in this mode: the `bytes` in `filmcraft:save` hold the saved file, and+  the host decides where the file goes.+- The assets carry their build hash as a `?v=` query (as `index.html` loads them), so they can+  be cached forever.+- FilmCraft has no user names that work could be signed with, so there is no `?author=`.+- An unframed page that uses `?host=parent` keeps the normal app with downloads; only the exact+  `data-host` value `page` turns this mode on.+ ## Renderer selection and fallback flags  FilmCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to