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
@@ -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) |@@ -45,6 +45,8 @@ web-sys = { version = "0.3", features = [ "Blob", "BlobPropertyBag", "CssStyleDeclaration",+ "CustomEvent",+ "CustomEventInit", "DataTransfer", "Document", "DragEvent",@@ -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| {new file mode 100644@@ -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:?}")))+}new file mode 100644@@ -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)+}@@ -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.@@ -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);new file mode 100644@@ -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);+}@@ -1,2 +1,3 @@ mod download_pacing;+mod hostmode; mod recovery_policy;@@ -88,3 +88,6 @@ sync_github_name = false [people.webdevtodayjason] display_name = "Jason Brashear"++[people.helenkwok]+real_name = "Helen Kwok"@@ -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 sets@@ -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