diff --git a/docs/zh/plans/riir/M15-render-process-isolation.md b/docs/zh/plans/riir/M15-render-process-isolation.md index 749c3e8b4..bf7ac8ddc 100644 --- a/docs/zh/plans/riir/M15-render-process-isolation.md +++ b/docs/zh/plans/riir/M15-render-process-isolation.md @@ -3,6 +3,7 @@ > 状态:已批准(用户 2026-08-18 提出,作为独立追加任务,不阻塞 M12 其余阶段)。 > 前置调研:见会话调研报告(oak-worker/ipc.rs 传输层已完整、TicketArena 投递口收敛、上屏链路 6 处拷贝点)。 > 进度:S1 完成(2026-08,协议 v2 + ProcessDispatcher + PreviewScheduler + worker 真实渲染,与线程池并存);S2 完成(默认 Processes、删除 WorkerPool、oaktask/oak-cli/app 接入、上屏零拷贝、播放预渲染窗口);S3 完成(2026-08-19:音频票走共享内存 + 播放异步预取 + 崩溃隔离覆盖音频、按票槽格式 F32 + 段按需扩容、worker/槽/批自适应策略 + 基准 bench_process)。 +> 上屏拷贝现状(2026-08-19 修正):预览链路为 **1 次 CPU staging 拷贝 + GPU 上传**——shm 槽 → `RenderImage`(gpui 的精灵图集要求 owned 缓冲与稳定 ImageId,借用型零拷贝需改造 vendored gpui 的 `RenderImage`/atlas 类型,暂记为后续项)→ `write_texture`。gpui_wgpu atlas 的 identity 格式二次拷贝(`swizzle_upload_data` 的 `to_vec()`)已移除:非 swizzle 格式直接用调用方切片上传。真·零拷贝上屏的可行路径是 macOS IOSurface 跨进程共享 + `SurfaceSource` 通道,工作量与平台耦合大,单独立项再议。 ## 1. 目标(用户原文要求) diff --git a/gpui b/gpui index 050d5ba22..a761d96c8 160000 --- a/gpui +++ b/gpui @@ -1 +1 @@ -Subproject commit 050d5ba22de8b541dfd42111bb063c1ce757cd7d +Subproject commit a761d96c826155211c21e6a13f420252757d0ee7 diff --git a/src/app.rs b/src/app.rs index e353a3c95..4c78d81d5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1555,7 +1555,37 @@ impl OakApp { /// `WindowHandle::update`): the nested `update_window` below would fail /// and the modal would silently not open. Drive the root entity instead /// (menu actions and entity updates are fine). + /// Builds and installs a modal. When the caller is already inside a + /// window update (an action listener / menu dispatch / tick), a nested + /// `update_window` would silently fail and drop the dialog — the phase-7 + /// fix for Preferences / Action Search, applied centrally: probe first, + /// and defer the build to the end of the current app update when nested. + /// The fast path stays synchronous so callers can rely on `self.modal` + /// right after the call. fn spawn_modal( + &mut self, + cx: &mut Context, + build: impl FnOnce(&mut Window, &mut App) -> ModalState + 'static, + ) { + let handle = cx.windows().first().copied(); + let nested = match handle { + Some(handle) => cx.update_window(handle, |_root, _window, _app| ()).is_err(), + None => false, + }; + if nested { + let this = cx.weak_entity(); + cx.defer(move |app| { + let _ = this.update(app, |this, cx| this.spawn_modal_now(cx, build)); + }); + return; + } + self.spawn_modal_now(cx, build); + } + + /// Builds and installs a modal immediately (the deferred half of + /// [`Self::spawn_modal`]; also called directly when the caller already + /// runs outside a window update). + fn spawn_modal_now( &mut self, cx: &mut Context, build: impl FnOnce(&mut Window, &mut App) -> ModalState, diff --git a/src/oakui/renderops.rs b/src/oakui/renderops.rs index 01c887497..230500372 100644 --- a/src/oakui/renderops.rs +++ b/src/oakui/renderops.rs @@ -94,7 +94,15 @@ pub fn preview_footage_media( f: &oaknode::footage::FootageBehavior, is_video: bool, ) -> (String, i32) { - let original_stream = if is_video { 0 } else { 1 }; + // The original media decodes from the footage's actual first stream of + // the kind (C++ maps per-stream); the 0/1 fallbacks cover footage + // whose streams were never probed (legacy project files). + let original_stream = f + .streams + .iter() + .find(|s| s.is_video == is_video) + .map(|s| s.index) + .unwrap_or(if is_video { 0 } else { 1 }); let original = (f.filename.clone(), original_stream); if !use_proxy_media() || !f.proxy_enabled || f.proxy.is_empty() { return original; @@ -886,6 +894,32 @@ mod tests { let _ = std::fs::remove_file(&media); } + /// The original media decodes from the footage's actual first stream of + /// the kind (not the hardcoded 0/1 of a typical layout): a file whose + /// video stream is not stream 0 must still decode its own video when + /// the proxy switch is off or the proxy is not ready. + #[test] + fn preview_media_uses_the_probed_stream_indices() { + let stream = |index: i32, is_video: bool| oaknode::footage::StreamInfo { + index, + is_video, + video: None, + audio: None, + duration: oakcore_rs::Rational::new(0, 1), + }; + // An audio-first container: audio at 0, video at 1… plus a second + // audio track at 2. The video must come from stream 1 and the audio + // from stream 0, not the legacy 0/1 assumption. + let mut f = oaknode::footage::FootageBehavior::new("/tmp/audio-first.mov"); + f.streams = vec![stream(0, false), stream(1, true), stream(2, false)]; + assert_eq!(preview_footage_media(&f, true).1, 1); + assert_eq!(preview_footage_media(&f, false).1, 0); + // Unprobed footage (no stream metadata) keeps the 0/1 fallbacks. + let unprobed = oaknode::footage::FootageBehavior::new("/tmp/legacy.mov"); + assert_eq!(preview_footage_media(&unprobed, true).1, 0); + assert_eq!(preview_footage_media(&unprobed, false).1, 1); + } + /// The multicam angle montage ([`single_track_video_montage`]) carries /// ONLY the clip on the requested track — the whole-stack `video_montage` /// is the parity reference. This is the montage the angle-frame ticket