From 09c7e7de32efc7ea8f4025ca8b90a4bb9563e412 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 26 Aug 2026 04:22:44 +0800 Subject: [PATCH] app: auto-generate proxies in the background and badge them in the bin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the global proxy switch on, footage that needs a proxy now gets one generated in the background automatically — on import, on project open, and when proxy use is enabled for a footage — instead of only through the manual Generate menu action. Footage explicitly opted out (a recorded proxy path with use disabled) and footage whose last generation failed are not restarted; the generation path enables proxy use so preview switches to the proxy as soon as it is ready. The bin shows the lifecycle as a corner badge on each footage entry (green P = ready, amber P = generating, red ! = failed; the widget half lives in the gpui fork, submodule bumped here). Includes a real-media test driving ProxyTask end-to-end on 4K H.265 4:2:2 10-bit (the media class the 4K playback lag was reported on): 180 s transcodes to a 720p proxy in ~25 s. --- crates/oak-app/src/oakui/real.rs | 92 ++++++++++++++++++++++++++++++++ crates/oak-task/src/proxy.rs | 52 ++++++++++++++++++ gpui | 2 +- 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index 0840c6375..38e2470bd 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -1895,6 +1895,23 @@ impl RealEngine { self.clear_multicam_frames(); } + /// The bin entry's proxy badge (角标): the footage's proxy lifecycle + /// mapped onto the widget's badge enum; folders and footage without + /// proxy state get none. + fn proxy_badge_of(&self, id: u64) -> Option { + use gpui_widgets::project_explorer::ProxyBadge; + let project = self.project.as_ref()?; + let node = graphops::id_of(id)?; + let guard = graphops::lock(project); + let f = graphops::footage_behavior(&guard.graph, node)?; + match self.proxy_state_of(f, node) { + super::engine::ProxyMediaState::Ready => Some(ProxyBadge::Ready), + super::engine::ProxyMediaState::Generating => Some(ProxyBadge::Generating), + super::engine::ProxyMediaState::Failed => Some(ProxyBadge::Failed), + super::engine::ProxyMediaState::Missing => None, + } + } + /// Attaches cached thumbnails to the bin entries, spawning a background /// generation job for every footage that has none yet. Entries without a /// renderable frame keep the widget's placeholder. @@ -1908,6 +1925,12 @@ impl RealEngine { if entry.is_dir { return entry; } + // The proxy badge rides every bin row (with or without a + // thumbnail), so a Ready proxy is visible at a glance. + let entry = match self.proxy_badge_of(entry.id) { + Some(badge) => entry.with_proxy_badge(badge), + None => entry, + }; let mut thumbs = self.thumbnails.lock().unwrap(); if let Some(path) = thumbs.done.get(&entry.id) { return entry.with_thumbnail(path.to_string_lossy().into_owned()); @@ -2118,6 +2141,64 @@ impl RealEngine { } } + /// Auto-start proxy generation in the background for every footage + /// that needs one (设计:全局 UseProxyMedia 开启时素材代理在后台 + /// 自动生成,不要求手动点 Generate;触发点:导入素材、打开工程)。 + /// + /// 逐素材规则: + /// - 已有 Ready 代理 / 已在生成 / 上次失败(state 3,避免失败 + /// 循环)→ 跳过; + /// - 显式关过代理的素材(记录了代理路径但未启用)→ 跳过 + /// (尊重每个素材的 opt-out); + /// - 其余(从未触碰过代理的,或已启用但未就绪的)→ 启动生成, + /// 生成路径会同时把 proxy_enabled 置位,完成后预览即走代理。 + fn autostart_proxies(&mut self, cx: &mut Context) { + if !super::renderops::use_proxy_media() { + return; + } + let Some(project) = self.project.clone() else { + return; + }; + let candidates: Vec = { + let guard = graphops::lock(&project); + graphops::footage_ids(&guard) + .into_iter() + .filter(|&node| { + let Some(f) = graphops::footage_behavior(&guard.graph, node) else { + return false; + }; + if !f.streams.iter().any(|s| s.is_video) { + return false; + } + if f.proxy_state == 3 { + return false; // 上次失败:不自动重试 + } + if !f.proxy.is_empty() { + if !f.proxy_enabled { + return false; // 显式关过代理 + } + // 已启用:磁盘上就绪则无需再生成。 + if oak_codec::proxymanager::ProxyManager::get_proxy_state(&f.proxy) + == oak_codec::proxymanager::ProxyState::Ready + { + return false; + } + } + self.proxy_runs.iter().all(|run| run.footage != node) + }) + .map(|node| node.identity()) + .collect() + }; + for id in candidates { + // proxy_generate 自己处理路径构建/任务线程/状态标记;失败 + // 只记日志(自动路径不打扰用户)。 + if let Err(err) = self.proxy_generate(id, cx) { + println!("[real engine] auto proxy generation skipped {id}: {err}"); + } + } + } + + /// The proxy lifecycle state of one footage row: an in-flight run /// wins, then the disk state of the recorded proxy path, with a /// recorded failure (state 3) preserved while no file is on disk. @@ -2565,6 +2646,10 @@ impl RealEngine { // color pipeline from here on. Self::apply_project_color_config(Some(&project)); + // 全局代理开关开启时,工程里未就绪素材的代理在后台自动生成 + // (打开长素材工程不等待:生成走任务线程)。 + self.autostart_proxies(cx); + cx.notify(); } @@ -4121,6 +4206,8 @@ impl AppEngine for RealEngine { // The material bin reads the folder tree live from the graph, so a // notify is enough for the explorer to list the new entry. cx.notify(); + // 全局代理开关开启时,新素材的代理在后台自动生成。 + self.autostart_proxies(cx); Ok(()) } @@ -4695,6 +4782,11 @@ impl AppEngine for RealEngine { f.proxy_enabled = enabled; } } + // 启用而代理未就绪时立即后台生成(禁用则是 opt-out, + // autostart_proxies 对有记录路径但未启用的素材不会重启)。 + if enabled { + self.autostart_proxies(cx); + } self.invalidate_preview_frames(cx); } diff --git a/crates/oak-task/src/proxy.rs b/crates/oak-task/src/proxy.rs index 190d5a8a5..a9b62cc47 100644 --- a/crates/oak-task/src/proxy.rs +++ b/crates/oak-task/src/proxy.rs @@ -347,3 +347,55 @@ fn probe_source_duration_seconds(ffmpeg_path: &str, source_filename: &str) -> f6 _ => 0.0, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// End-to-end: transcode a real 4K H.265 4:2:2 10-bit file (the + /// class of media the 4K playback lag was reported on) to a 720p + /// proxy. Skipped when the fixture or ffmpeg is missing. + #[test] + fn proxy_task_transcodes_hevc_422_10bit() { + let src = "/tmp/oakperf/hevc422-4k.mp4"; + if !std::path::Path::new(src).exists() { + eprintln!("fixture missing; skipping"); + return; + } + let ffmpeg = ProxyManager::find_ffmpeg(""); + if ffmpeg.is_empty() { + eprintln!("ffmpeg not found; skipping"); + return; + } + let out = "/tmp/oakperf/proxy-720p.mp4"; + let _ = std::fs::remove_file(out); + let request = oak_codec::task::TaskRequest { + kind: oak_codec::task::TaskKind::Proxy, + input_filename: src, + output_filename: out, + stream_index: 0, + sample_rate: 0, + channel_layout: 0, + sample_format: 0, + proxy_width: 1280, + proxy_height: 720, + }; + let params = ProxyParams { + width: 1280, + height: 720, + divider: 1, + version: 1, + crf: 23, + include_audio: true, + extension: "mp4".to_string(), + preset: "veryfast".to_string(), + }; + let mut proxy_task = ProxyTask::new(&request, params); + let mut task = Task::new("probe", None); + proxy_task + .run(&mut task) + .expect("the proxy transcode succeeds on 4K HEVC 4:2:2 10-bit"); + let meta = std::fs::metadata(out).expect("the proxy file exists"); + assert!(meta.len() > 0, "the proxy file is non-empty"); + } +} diff --git a/gpui b/gpui index 30aa052ca..b6d3e70cb 160000 --- a/gpui +++ b/gpui @@ -1 +1 @@ -Subproject commit 30aa052ca6b802847e5f46d271b8bd62640f9569 +Subproject commit b6d3e70cbb8b9312beef3ef8028256230bf13738