multicam: wizard + AFV + node-graph preview + performance
CI / Build & test (Linux) (push) Failing after 24s
CI / Build & test (Windows) (push) Failing after 3m38s

- wizard: angle multi-select, sync modes, auto-align, create sequence;
  keeps the host sequence current (host clip = the multicam clip)
- build_multicam_sequence: per-angle clip -> SOURCES_INPUT[element],
  array slot growth, audio angle tracks for AFV
- AFV: host linked audio clip follows the switched source (one undo),
  muted host audio track disables it
- node-graph preview: inline producer renders through the traverser
  (viewer/project-matched graph frames); sequence viewer uses the graph
- multicam node value(): element-tagged row keys (sources_in[i]),
  reads the current source; build_row keys array inputs by element
- angle grid: viewer=0 (single-track montage, not whole-graph)
- project explorer: rename (dialog) + delete (undoable) real items
- performance: decoded-frame LRU, per-process NVDEC quota (1 session,
  evict before open), GPU composite fail-once fallback, snapshot
  upload debounced on the engine tick, worker vram budget headroom
- timeline clip: multicam overlay via ClipDecorator
- wizard menu item moved to Sequence menu
This commit is contained in:
2026-09-01 20:27:59 +08:00
parent 8de9af705e
commit 1b0a15192e
25 changed files with 3528 additions and 65 deletions
+14
View File
@@ -145,6 +145,20 @@
"panel.timeline": "Zeitleiste"
"panel.effect_library": "Effektbibliothek"
"panel.multicam": "Multikamera"
"panel.multicam": "Multikamera"
"menu.sequence.multicam_wizard": "Multikamera-Assistent"
"multicam.wizard.title": "Multikamera-Assistent"
"multicam.wizard.default_name": "Multikamera"
"multicam.wizard.name": "Sequenzname"
"multicam.wizard.sync": "Synchronisierung"
"multicam.sync.audio": "Audiowellenform"
"multicam.sync.timecode": "Quellzeitcode"
"multicam.sync.none": "Keine (keine Ausrichtung)"
"multicam.wizard.angles_label": "Kameras (mindestens zwei auswählen):"
"multicam.wizard.no_footage": "Kein Material im Projekt für eine Multikamera."
"multicam.wizard.need_two": "Bitte mindestens zwei Kameras auswählen."
"multicam.wizard.create": "Erstellen"
"multicam.no_multicam": "Kein Multikamera-Clip erkannt"
"multicam.no_multicam": "Kein Multikamera-Clip erkannt"
"multicam.switch_1": "Zu Kamera 1 wechseln"
"multicam.switch_2": "Zu Kamera 2 wechseln"
+12
View File
@@ -145,6 +145,18 @@
"panel.timeline": "Timeline"
"panel.effect_library": "Effect Library"
"panel.multicam": "Multi-Cam"
"menu.sequence.multicam_wizard": "Multi-Cam Wizard"
"multicam.wizard.title": "Multi-Cam Wizard"
"multicam.wizard.default_name": "Multi-Cam"
"multicam.wizard.name": "Sequence Name"
"multicam.wizard.sync": "Sync Method"
"multicam.sync.audio": "Audio Waveform"
"multicam.sync.timecode": "Source Timecode"
"multicam.sync.none": "None (No Alignment)"
"multicam.wizard.angles_label": "Angles (select at least two):"
"multicam.wizard.no_footage": "No footage in the project to create a multicam from."
"multicam.wizard.need_two": "Select at least two angles."
"multicam.wizard.create": "Create"
"multicam.no_multicam": "No multi-camera clip detected"
"multicam.switch_1": "Switch to Camera 1"
"multicam.switch_2": "Switch to Camera 2"
+14
View File
@@ -145,6 +145,20 @@
"panel.timeline": "Línea de tiempo"
"panel.effect_library": "Biblioteca de efectos"
"panel.multicam": "Multicámara"
"panel.multicam": "Multicámara"
"menu.sequence.multicam_wizard": "Asistente multicámara"
"multicam.wizard.title": "Asistente multicámara"
"multicam.wizard.default_name": "Multicámara"
"multicam.wizard.name": "Nombre de la secuencia"
"multicam.wizard.sync": "Método de sincronización"
"multicam.sync.audio": "Forma de onda de audio"
"multicam.sync.timecode": "Código de tiempo de origen"
"multicam.sync.none": "Ninguna (sin alineación)"
"multicam.wizard.angles_label": "Cámaras (selecciona al menos dos):"
"multicam.wizard.no_footage": "No hay material en el proyecto para crear una multicámara."
"multicam.wizard.need_two": "Selecciona al menos dos cámaras."
"multicam.wizard.create": "Crear"
"multicam.no_multicam": "No se ha detectado ningún clip multicámara"
"multicam.no_multicam": "No se ha detectado ningún clip multicámara"
"multicam.switch_1": "Cambiar a cámara 1"
"multicam.switch_2": "Cambiar a cámara 2"
+14
View File
@@ -145,6 +145,20 @@
"panel.timeline": "Montage"
"panel.effect_library": "Bibliothèque d'effets"
"panel.multicam": "Multicam"
"panel.multicam": "Multicam"
"menu.sequence.multicam_wizard": "Assistant multicaméra"
"multicam.wizard.title": "Assistant multicaméra"
"multicam.wizard.default_name": "Multicaméra"
"multicam.wizard.name": "Nom de la séquence"
"multicam.wizard.sync": "Méthode de synchronisation"
"multicam.sync.audio": "Forme donde audio"
"multicam.sync.timecode": "Code temporel source"
"multicam.sync.none": "Aucune (pas dalignement)"
"multicam.wizard.angles_label": "Caméras (sélectionnez au moins deux) :"
"multicam.wizard.no_footage": "Aucun média dans le projet pour créer une multicaméra."
"multicam.wizard.need_two": "Sélectionnez au moins deux caméras."
"multicam.wizard.create": "Créer"
"multicam.no_multicam": "Aucun clip multicaméra détecté"
"multicam.no_multicam": "Aucun clip multicaméra détecté"
"multicam.switch_1": "Passer à la caméra 1"
"multicam.switch_2": "Passer à la caméra 2"
+14
View File
@@ -145,6 +145,20 @@
"panel.timeline": "タイムライン"
"panel.effect_library": "エフェクトライブラリ"
"panel.multicam": "マルチカム"
"panel.multicam": "マルチカム"
"menu.sequence.multicam_wizard": "マルチカムウィザード"
"multicam.wizard.title": "マルチカムウィザード"
"multicam.wizard.default_name": "マルチカム"
"multicam.wizard.name": "シーケンス名"
"multicam.wizard.sync": "同期方法"
"multicam.sync.audio": "オーディオ波形"
"multicam.sync.timecode": "ソースタイムコード"
"multicam.sync.none": "なし(整列しない)"
"multicam.wizard.angles_label": "カメラ(2つ以上選択):"
"multicam.wizard.no_footage": "プロジェクトにマルチカムを作成できる素材がありません。"
"multicam.wizard.need_two": "少なくとも2つのカメラを選択してください。"
"multicam.wizard.create": "作成"
"multicam.no_multicam": "マルチカメラクリップが検出されませんでした"
"multicam.no_multicam": "マルチカメラクリップが検出されませんでした"
"multicam.switch_1": "カメラ1に切り替え"
"multicam.switch_2": "カメラ2に切り替え"
+14
View File
@@ -145,6 +145,20 @@
"panel.timeline": "Linha do tempo"
"panel.effect_library": "Biblioteca de efeitos"
"panel.multicam": "Multicâmera"
"panel.multicam": "Multicâmera"
"menu.sequence.multicam_wizard": "Assistente multicâmera"
"multicam.wizard.title": "Assistente multicâmera"
"multicam.wizard.default_name": "Multicâmera"
"multicam.wizard.name": "Nome da sequência"
"multicam.wizard.sync": "Método de sincronização"
"multicam.sync.audio": "Forma de onda de áudio"
"multicam.sync.timecode": "Código de tempo de origem"
"multicam.sync.none": "Nenhuma (sem alinhamento)"
"multicam.wizard.angles_label": "Câmeras (selecione pelo menos duas):"
"multicam.wizard.no_footage": "Nenhum material no projeto para criar uma multicâmera."
"multicam.wizard.need_two": "Selecione pelo menos duas câmeras."
"multicam.wizard.create": "Criar"
"multicam.no_multicam": "Nenhum clipe multicâmera detectado"
"multicam.no_multicam": "Nenhum clipe multicâmera detectado"
"multicam.switch_1": "Alternar para a câmera 1"
"multicam.switch_2": "Alternar para a câmera 2"
+14
View File
@@ -145,6 +145,20 @@
"panel.timeline": "Таймлайн"
"panel.effect_library": "Библиотека эффектов"
"panel.multicam": "Мультикамера"
"panel.multicam": "Мультикамера"
"menu.sequence.multicam_wizard": "Мастер мультикамеры"
"multicam.wizard.title": "Мастер мультикамеры"
"multicam.wizard.default_name": "Мультикамера"
"multicam.wizard.name": "Имя последовательности"
"multicam.wizard.sync": "Метод синхронизации"
"multicam.sync.audio": "Аудиосигнал (форма волны)"
"multicam.sync.timecode": "Исходный таймкод"
"multicam.sync.none": "Нет (без выравнивания)"
"multicam.wizard.angles_label": "Камеры (выберите не менее двух):"
"multicam.wizard.no_footage": "В проекте нет материала для создания мультикамеры."
"multicam.wizard.need_two": "Выберите не менее двух камер."
"multicam.wizard.create": "Создать"
"multicam.no_multicam": "Не обнаружен клип мультикамеры"
"multicam.no_multicam": "Не обнаружен клип мультикамеры"
"multicam.switch_1": "Переключиться на камеру 1"
"multicam.switch_2": "Переключиться на камеру 2"
+12
View File
@@ -145,6 +145,18 @@
"panel.timeline": "时间线"
"panel.effect_library": "效果库"
"panel.multicam": "多机位"
"menu.sequence.multicam_wizard": "多机位向导"
"multicam.wizard.title": "多机位向导"
"multicam.wizard.default_name": "多机位"
"multicam.wizard.name": "序列名称"
"multicam.wizard.sync": "同步方式"
"multicam.sync.audio": "音频波形"
"multicam.sync.timecode": "源时间码"
"multicam.sync.none": "无(不自动对齐)"
"multicam.wizard.angles_label": "机位(至少选择两个):"
"multicam.wizard.no_footage": "项目中没有可用于创建多机位的素材。"
"multicam.wizard.need_two": "请至少选择两个机位。"
"multicam.wizard.create": "创建"
"multicam.no_multicam": "未检测到多机位片段"
"multicam.switch_1": "切换到机位 1"
"multicam.switch_2": "切换到机位 2"
+3
View File
@@ -207,6 +207,9 @@ define_actions! {
FocusTimeline { cpp: "focustimeline", i18n: "menu.window.timeline", keys: [], route: Global, menu_id: 607 };
FocusEffectLibrary { cpp: "focuseffectlibrary", i18n: "menu.window.effect_library", keys: [], route: Global, menu_id: 608 };
FocusMulticam { cpp: "focusmulticam", i18n: "menu.window.multicam", keys: [], route: Global, menu_id: 609 };
// The multicam wizard (窗口 → 多机位向导; create a multi-angle
// sequence from selected footage with auto-sync alignment).
MulticamWizard { cpp: "multicamwizard", i18n: "menu.sequence.multicam_wizard", keys: [], route: Global, menu_id: 613 };
MaximizePanel { cpp: "maximizepanel", i18n: "menu.window.maximize_panel", keys: ["`"], route: Global, menu_id: 1070 };
ResetDefaultLayout { cpp: "resetdefaultlayout", i18n: "menu.window.reset_layout", keys: [], route: Global, menu_id: 1071 };
+274 -6
View File
@@ -136,6 +136,10 @@ mod modal_ids {
/// The drop-onto-empty-timeline choice (probe the footage's params as
/// the sequence's, or set them up manually).
pub const DROP_SEQUENCE_CHOICE: usize = 16;
/// The multicam wizard (窗口 → 多机位向导).
pub const MULTICAM_WIZARD: usize = 17;
/// The rename dialog (project explorer 重命名).
pub const RENAME: usize = 18;
}
/// What a picked platform-dialog path should do.
@@ -221,6 +225,20 @@ enum ModalState<E: AppEngine> {
/// sequence exists (the paused drop lives in [`OakApp::pending_drop`]).
/// The buttons carry the decision, so the state keeps only the modal.
DropSequenceChoice { modal: Entity<Modal> },
/// The multicam wizard (窗口 → 多机位向导): pick the angle footage,
/// choose the sync mode, then create the multicam sequence. The OK
/// button runs the sync + creation through the content.
MulticamWizard {
modal: Entity<Modal>,
content: Entity<crate::dialogs::MulticamWizardContent<E>>,
},
/// The project-explorer rename dialog (right-click → 重命名): the
/// content's field is prefilled with the entry's current name.
EntryRename {
modal: Entity<Modal>,
content: Entity<crate::dialogs::RenameContent>,
entry_id: u64,
},
}
/// A running export: the session the tick loop drains for progress.
@@ -245,7 +263,9 @@ impl<E: AppEngine> ModalState<E> {
| ModalState::About { modal }
| ModalState::NewSequence { modal, .. }
| ModalState::SequenceProperties { modal, .. }
| ModalState::DropSequenceChoice { modal, .. } => Some(modal.clone()),
| ModalState::DropSequenceChoice { modal, .. }
| ModalState::MulticamWizard { modal, .. }
| ModalState::EntryRename { modal, .. } => Some(modal.clone()),
}
}
}
@@ -558,6 +578,30 @@ impl<E: AppEngine> OakApp<E> {
)
.detach();
// The project explorer's 重命名 / 删除 context items operate on
// the project's entry (rename prompts; delete removes the node).
cx.subscribe(
&panels.project,
|this,
_panel,
event: &crate::panels::project_explorer::RenameRequested,
cx| {
this.open_entry_rename(event.0, cx);
},
)
.detach();
cx.subscribe(
&panels.project,
|this,
_panel,
event: &crate::panels::project_explorer::DeleteRequested,
cx| {
this.engine
.update(cx, |engine, cx| engine.delete_entry(event.0, cx));
},
)
.detach();
// The project explorer's 新建序列 button opens the new-sequence
// dialog (the same one File > New > Sequence… uses).
cx.subscribe(
@@ -663,13 +707,16 @@ impl<E: AppEngine> OakApp<E> {
}),
cx,
);
// The multicam panel tabs behind the program viewer (the C++
// The multicam panel tabs behind the SOURCE viewer (the C++
// default is hidden; the 窗口 menu's Focus Multicam brings it
// forward). The program viewer stays the group's active tab.
// forward). Sharing the source viewer's pane lets the user
// toggle between inspecting source media and switching angles
// in the same window-half — the multicam is NOT tabbed with
// the node editor.
dock.add_panel(
PanelHandle::new(multicam_panel, cx),
Some(DropTarget {
panel: Some(PROGRAM_VIEWER),
panel: Some(SOURCE_VIEWER),
zone: DropZone::Center,
}),
cx,
@@ -1157,6 +1204,7 @@ impl<E: AppEngine> OakApp<E> {
}
A::ProxySettings => self.open_proxy_dialog(cx),
A::ProjectProperties => self.open_project_properties(cx),
A::MulticamWizard => self.open_multicam_wizard(cx),
A::NewSequence => self.open_new_sequence(cx),
A::NewFolder => {
if let Err(err) = self
@@ -2458,6 +2506,155 @@ impl<E: AppEngine> OakApp<E> {
});
}
/// Opens the rename dialog for the project-entry `id` (the project
/// explorer's 重命名 context item), prefilled with the current name.
fn open_entry_rename(&mut self, id: u64, cx: &mut Context<Self>) {
if !matches!(self.modal, ModalState::None) {
return;
}
let current = self
.engine
.read(cx)
.project_entry_name(id)
.unwrap_or_default();
self.spawn_modal(cx, move |window, app| {
let content = app.new(|cx| {
crate::dialogs::RenameContent::new(current.clone(), window, cx)
});
let modal = app.new(|cx| {
Modal::new(
modal_ids::RENAME,
ModalOptions::new(
crate::i18n::tr("project.context.rename"),
px(420.0),
)
.with_button(DialogButton::primary(crate::i18n::tr("dialog.ok")))
.with_button(DialogButton::new(
crate::i18n::tr("dialog.cancel"),
gpui_widgets::dialog::DialogButtonRole::Secondary,
)),
window,
cx,
)
.with_content(content.clone())
});
ModalState::EntryRename { modal, content, entry_id: id }
});
}
/// Opens the multicam wizard (窗口 → 多机位向导): pick the angle
/// footage, choose the sync mode, then create the multicam sequence.
fn open_multicam_wizard(&mut self, cx: &mut Context<Self>) {
if !matches!(self.modal, ModalState::None) {
return;
}
let engine = self.engine.clone();
self.spawn_modal(cx, move |window, app| {
let content = app.new(|cx| {
crate::dialogs::MulticamWizardContent::new(engine, window, cx)
});
let modal = app.new(|cx| {
Modal::new(
modal_ids::MULTICAM_WIZARD,
ModalOptions::new(
crate::i18n::tr("multicam.wizard.title"),
px(480.0),
)
.with_button(DialogButton::primary(crate::i18n::tr("multicam.wizard.create")))
.with_button(DialogButton::new(
crate::i18n::tr("dialog.cancel"),
gpui_widgets::dialog::DialogButtonRole::Secondary,
)),
window,
cx,
)
.with_content(content.clone())
});
ModalState::MulticamWizard { modal, content }
});
}
/// The wizard's OK: run the sync (or use timecode/no-align per the
/// chosen mode), create the multicam sequence, then close. A rejected
/// create keeps the dialog open with a message row.
fn commit_multicam_wizard(
&mut self,
content: &Entity<crate::dialogs::MulticamWizardContent<E>>,
cx: &mut Context<Self>,
) -> Result<(), String> {
let selection = content.read(cx).selection();
if selection.len() < 2 {
return Err(crate::i18n::tr("multicam.wizard.need_two").to_string());
}
let sync_mode = content.read(cx).sync_mode(cx);
let name = content.read(cx).name(cx).to_string();
// Sync: audio-waveform correlation (0), source timecode (1), or
// no alignment (2 — all angles start at 0). The engine's sync
// fails when the reference angle has no decodable audio: the
// wizard then falls back to source timecode deltas (absolute
// timestamps, so the first angle is the alignment reference).
let offsets: Vec<f64> = match sync_mode {
0 => {
let synced = self
.engine
.read(cx)
.multicam_wizard_sync_offsets(&selection);
match synced {
Ok(offsets) => {
let reference_tc = selection[0].source_timecode;
let mut by_id = std::collections::HashMap::new();
for off in &offsets {
by_id.insert(off.footage, off.offset_s);
}
selection
.iter()
.map(|f| {
by_id.get(&f.id).copied().unwrap_or_else(|| {
// No audio-sync answer for this angle:
// the source timecode delta relative to
// the reference (the fallback).
let ref_tc = reference_tc.unwrap_or(0);
f.source_timecode.unwrap_or(0) as f64 / 1000.0
- ref_tc as f64 / 1000.0
})
})
.collect()
}
Err(_) => {
let reference_tc = selection[0].source_timecode.
map(|t| t as f64 / 1000.0).unwrap_or(0.0);
selection
.iter()
.map(|f| {
f.source_timecode.map(|t| t as f64 / 1000.0)
.unwrap_or(0.0)
- reference_tc
})
.collect()
}
}
}
1 => {
let reference_tc = selection[0].source_timecode.map(|t| t as f64 / 1000.0)
.unwrap_or(0.0);
selection
.iter()
.map(|f| {
f.source_timecode.map(|t| t as f64 / 1000.0).unwrap_or(0.0)
- reference_tc
})
.collect()
}
_ => vec![0.0; selection.len()],
};
let result = self
.engine
.update(cx, |engine, cx| {
engine.multicam_create_sequence(selection, offsets, name, cx)
});
result.map(|_| ())
}
/// Opens the export dialog.
fn open_export_dialog(&mut self, cx: &mut Context<Self>) {
if self.engine.read(cx).current_sequence().is_none() {
@@ -2716,6 +2913,39 @@ impl<E: AppEngine> OakApp<E> {
}
}
}
modal_ids::MULTICAM_WIZARD => {
if let ModalState::MulticamWizard { content, .. } = &self.modal {
let content = content.clone();
if *button == 0 {
// OK: create the multicam; a rejected create
// keeps the dialog open.
match self.commit_multicam_wizard(&content, cx) {
Ok(()) => self.close_modal(cx),
Err(err) => {
println!("[multicam wizard] {err}");
}
}
} else {
self.close_modal(cx);
}
}
}
modal_ids::RENAME => {
if let ModalState::EntryRename { content, entry_id, .. } = &self.modal {
if *button == 0 {
// OK: apply the new name.
let name = content.read(cx).value(cx);
let id = *entry_id;
let engine = self.engine.clone();
engine.update(cx, |engine, cx| {
engine.rename_entry(id, name.to_string(), cx)
});
self.close_modal(cx);
} else {
self.close_modal(cx);
}
}
}
modal_ids::SEQUENCE_PROPERTIES => {
if let ModalState::SequenceProperties { content, .. } = &self.modal {
let content = content.clone();
@@ -2867,7 +3097,7 @@ fn default_dock_target(id: PanelId) -> Option<DropTarget> {
panel: None,
zone: DropZone::Bottom,
}),
MULTICAM => Some(t(PROGRAM_VIEWER, DropZone::Center)),
MULTICAM => Some(t(SOURCE_VIEWER, DropZone::Center)),
_ => Some(t(PROJECT, DropZone::Center)),
}
}
@@ -3114,6 +3344,7 @@ fn make_menus(state: MenuState) -> Vec<MenuBarEntry> {
menu_item(A::SeqCacheInOut),
menu_item(A::SeqCacheClear).separated(),
menu_item(A::SequenceSettings).disabled(),
menu_item(A::MulticamWizard).separated(),
]),
),
MenuBarEntry::new(
@@ -3132,7 +3363,7 @@ fn make_menus(state: MenuState) -> Vec<MenuBarEntry> {
last.separator_after = true;
}
window_items.push(menu_item(A::MaximizePanel));
window_items.push(menu_item(A::ResetDefaultLayout));
window_items.push(menu_item(A::ResetDefaultLayout).separated());
window_items
}),
),
@@ -4455,6 +4686,43 @@ mod tests {
);
}
/// The 窗口/Window → 多机位向导 menu item opens the wizard modal with
/// the engine's wizard footage rows (the mock offers a demo list), and
/// the created modal carries the content entity (the "click freezes"
/// regression guard).
#[gpui::test]
async fn multicam_wizard_menu_opens_the_modal(cx: &mut TestAppContext) {
let _guard = crate::actions::shortcuts_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _lang = crate::i18n::lang_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let (window, root) = mock_shell(cx);
cx.update(|app| {
root.update(app, |app, cx| {
app.on_menu(ActionId::MulticamWizard.menu_id(), cx)
})
});
cx.run_until_parked();
cx.update_window(window.into(), |_root, window, cx| {
window.draw(cx).clear();
})
.expect("window is still open");
let modal_rows = cx.read(|app| match &root.read(app).modal {
ModalState::MulticamWizard { content, .. } => content.read(app).selection().len(),
_ => panic!("the multicam wizard modal should be open"),
});
// The mock offers four demo footage rows.
let footage = cx.read(|app| {
let engine = root.read(app).engine.read(app);
engine.multicam_wizard_footage().unwrap_or_default()
});
assert_eq!(footage.len(), 4, "the mock exposes the demo footage list");
assert_eq!(modal_rows, 0, "no angles are pre-checked");
}
/// Arrow keys move the search selection and Enter runs the selected action
/// through the same dispatch path the menu clicks take (here the dialog
/// closes because the action dispatched successfully).
+229 -3
View File
@@ -36,8 +36,8 @@ use gpui::colors::DefaultColors;
use gpui::prelude::*;
use gpui::timeline::FrameRate;
use gpui::{
div, px, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Keystroke,
PathPromptOptions, Render, SharedString, Window,
div, px, App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, Keystroke, PathPromptOptions, Render, SharedString, Window,
};
use gpui_elements::editable_text::{EditableTextState, StringStorage, TextChanged};
@@ -3630,10 +3630,236 @@ impl Render for DropSequenceChoiceContent {
}
}
/// The multicam wizard dialog content: pick the angle footage, choose
/// the sync mode, name the multicam sequence. The host reads the inputs
/// through [`Self::selection`] / [`Self::sync_mode`] / [`Self::name`]
/// when the OK button fires.
pub struct MulticamWizardContent<E: crate::oakui::engine::AppEngine> {
engine: Entity<E>,
/// The name field (prefilled "Multi-Cam").
name: Entity<TextValue>,
/// Sync mode combo (0 = audio waveform, 1 = source timecode, 2 = no
/// alignment).
sync: Entity<ComboBox>,
/// Selection state per footage row: `(entry, checked)`.
rows: Vec<(crate::oakui::engine::WizardFootage, bool)>,
/// The wheel to scroll the long angle list.
scrolled: bool,
}
/// The wizard's sync mode combo values (display order).
const WIZARD_SYNC_MODES: &[&str] = &[
"multicam.sync.audio",
"multicam.sync.timecode",
"multicam.sync.none",
];
impl<E: crate::oakui::engine::AppEngine> MulticamWizardContent<E> {
/// Builds the content seeded with the engine's wizard footage.
pub fn new(
engine: Entity<E>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let name = cx.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
TextValue { editor }
});
name.update(cx, |field, cx| {
field.set_value(i18n::tr("multicam.wizard.default_name"), cx)
});
let sync = cx.new(|cx| {
let options = WIZARD_SYNC_MODES
.iter()
.enumerate()
.map(|(i, key)| ComboBoxOption::new(i, i18n::tr(key)))
.collect();
ComboBox::new(60, options, window, cx)
});
sync.update(cx, |combo, cx| combo.set_selected(Some(0), cx));
let rows = engine
.read(cx)
.multicam_wizard_footage()
.unwrap_or_default()
.into_iter()
.map(|entry| (entry, false))
.collect();
Self {
engine,
name,
sync,
rows,
scrolled: false,
}
}
/// Toggles the checked state of row `index`.
pub fn toggle_row(&mut self, index: usize, cx: &mut Context<Self>) {
if let Some(row) = self.rows.get_mut(index) {
row.1 = !row.1;
cx.notify();
}
}
/// The angle entries currently checked, in row order.
pub fn selection(&self) -> Vec<crate::oakui::engine::WizardFootage> {
self.rows
.iter()
.filter(|row| row.1)
.map(|row| row.0.clone())
.collect()
}
/// The selected sync mode (0 = audio, 1 = timecode, 2 = none).
pub fn sync_mode(&self, cx: &App) -> usize {
self.sync.read(cx).selected().unwrap_or(0)
}
/// The sequence name entered.
pub fn name(&self, cx: &App) -> SharedString {
self.name.read(cx).value(cx)
}
/// Whether the engine offers any wizard footage (drives the empty
/// hint row).
pub fn has_rows(&self) -> bool {
!self.rows.is_empty()
}
/// The engine entity (the host needs it to run the sync + create).
pub fn engine(&self) -> &Entity<E> {
&self.engine
}
}
impl<E: crate::oakui::engine::AppEngine> Render for MulticamWizardContent<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let mut rows_div = div().flex().flex_col().gap_1().w_full();
for (index, (entry, checked)) in self.rows.iter().enumerate() {
let is_checked = *checked;
let row = div()
.id(ElementId::named_usize("multicam-wizard-angle", index))
.flex()
.items_center()
.gap_2()
.px_1()
.py_0p5()
.rounded_sm()
.hover(|style| style.bg(colors.container))
.cursor_pointer()
.on_click(cx.listener(
move |this: &mut Self, _event: &ClickEvent, _window, cx| {
this.toggle_row(index, cx);
},
))
.child(
div()
.size(px(12.0))
.flex()
.items_center()
.justify_center()
.bg(if is_checked { colors.selected } else { colors.background })
.text_color(colors.text)
.child(if is_checked { "" } else { "" }),
)
.child(
div()
.flex_1()
.text_color(colors.text)
.child(entry.name.clone()),
)
.child(
div()
.text_xs()
.text_color(colors.disabled)
.child(entry.duration_s.map(|d| format!("{d:.1}s")).unwrap_or_default()),
);
rows_div = rows_div.child(row);
}
let angles = div()
.id("multicam-wizard-angles")
.max_h(px(220.0))
.overflow_y_scroll()
.child(rows_div);
let stock = if self.rows.is_empty() {
div()
.text_color(colors.disabled)
.text_xs()
.child(i18n::tr("multicam.wizard.no_footage"))
.into_any_element()
} else {
div().into_any_element()
};
div()
.flex()
.flex_col()
.gap_3()
.w_full()
.child(form_row(
&colors,
i18n::tr("multicam.wizard.name").into(),
self.name.clone(),
))
.child(form_row(
&colors,
i18n::tr("multicam.wizard.sync").into(),
self.sync.clone(),
))
.child(
div()
.text_sm()
.text_color(colors.text)
.child(i18n::tr("multicam.wizard.angles_label")),
)
.child(angles)
.child(stock)
}
}
/// The rename dialog: a single text field prefilled with the entry's
/// current name (the project-explorer 重命名 context item; the host reads
/// [`Self::value`] on OK and calls the engine's `rename_entry`).
pub struct RenameContent {
field: Entity<TextValue>,
}
impl RenameContent {
/// Builds the dialog seeded with the current name.
pub fn new(current: SharedString, window: &mut Window, cx: &mut Context<Self>) -> Self {
let field = cx.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
TextValue { editor }
});
field.update(cx, |field, cx| field.set_value(current, cx));
Self { field }
}
/// The name to apply.
pub fn value(&self, cx: &App) -> SharedString {
self.field.read(cx).value(cx)
}
}
impl Render for RenameContent {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
div()
.flex()
.flex_col()
.gap_3()
.w_full()
.child(form_row(
&colors,
i18n::tr("project.context.rename").into(),
self.field.clone(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn keystroke(key: &str) -> Keystroke {
gpui::Keystroke::parse(key).unwrap()
}
+106 -1
View File
@@ -40,7 +40,7 @@ use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent};
use gpui::timeline::{
ClipId, Frame, FrameRate, TimelineDataSource, TimelineEvent, TrackData, TrackKind,
};
use gpui::{App, Context, Entity, Pixels, Point, Rgba, RenderImage};
use gpui::{App, Context, Entity, Pixels, Point, Rgba, RenderImage, SharedString};
use gpui_widgets::audio_meter::AudioMeterDataSource;
use gpui_widgets::project_explorer::ProjectDataSource;
use gpui_widgets::viewer::PlaybackClock;
@@ -532,6 +532,12 @@ pub trait AppEngine:
None
}
/// The display name of project-entry `id` (the rename dialog's seed).
fn project_entry_name(&self, id: u64) -> Option<SharedString> {
let _ = id;
None
}
/// Replaces the footage of project-explorer entry `id` with the media
/// file at `path` (the C++ `ReplaceFootage` flow). Default:
/// unsupported.
@@ -545,6 +551,19 @@ pub trait AppEngine:
Err("replace footage not supported".into())
}
/// Renames the project-explorer entry `id` (footage / folder /
/// sequence) in the project model. Default: unsupported.
fn rename_entry(&mut self, id: u64, new_name: String, cx: &mut Context<Self>) {
let _ = (id, new_name, cx);
}
/// Deletes the project-explorer entry `id` (footage / folder /
/// sequence) and its graph neighborhoods — ONE undoable entry. Default:
/// unsupported.
fn delete_entry(&mut self, id: u64, cx: &mut Context<Self>) {
let _ = (id, cx);
}
// -------------------------------------------------------------------
// Sequence markers & work area (M12 P4): the facade surfaces are
// undoable, mirroring Olive (MarkerAdd/MarkerRemove/WorkareaSet*).
@@ -974,6 +993,13 @@ pub trait AppEngine:
false
}
/// Opens the sequence `id` in the timeline (the project-explorer
/// double-click on a sequence entry). Default no-op for engines
/// without sequence tabs.
fn open_sequence_id(&mut self, id: u64, cx: &mut Context<Self>) {
let _ = (id, cx);
}
/// The sequence parameters of `id` (the sequence-properties dialog
/// seed), or `None` when the entry is not a sequence.
fn sequence_parameters(&self, id: u64) -> Option<SequenceParameters> {
@@ -1210,6 +1236,50 @@ pub trait AppEngine:
/// the status bar.
fn backend_name(&self) -> &'static str;
// -------------------------------------------------------------------
// Multicam wizard (the C++ Multicam dialog + auto-sync): creating a
// new multicam sequence from a set of footage angles, with optional
// audio-waveform auto alignment. Defaults degrade to "unsupported",
// so engines without the real project surface keep compiling.
// -------------------------------------------------------------------
/// The footage entries the multicam wizard can offer (project-root
/// footage not already inside a multicam). `None` = no wizard data
/// source (the dialog's angle picker stays empty).
fn multicam_wizard_footage(&self) -> Option<Vec<WizardFootage>> {
None
}
/// Estimate the audio alignment offsets between the wizard's selected
/// angles (the first selected footage is the reference; each other
/// angle gets a sample offset relative to it, estimated by the RMS
/// envelope correlation). Returns `Err` when the engine cannot run
/// the correlation (missing audio / too short) — the wizard then falls
/// back to timecode alignment.
fn multicam_wizard_sync_offsets(
&self,
_selected: &[WizardFootage],
) -> Result<Vec<WizardSyncOffset>, String> {
Err("offline sync not supported".to_string())
}
/// Creates the multicam sequence: a new sequence with one video track
/// per selected angle (top track = angle 0), each track carrying a
/// clip of the corresponding footage placed at `offsets[i]` seconds
/// (waveform-sync offsets, or the source timecode delta, or 0), plus a
/// multicam node feeding a fresh clip on the CURRENT timeline's top
/// video track at the playhead. One undo entry. Returns the sequence
/// identity (the app opens it).
fn multicam_create_sequence(
&mut self,
_selected: Vec<WizardFootage>,
_offsets: Vec<f64>,
_name: String,
_cx: &mut Context<Self>,
) -> Result<u64, String> {
Err("multicam wizard not supported".to_string())
}
/// Arms or disarms the program viewer's eyedropper. While armed the
/// viewer samples the pixel under the cursor on click and reports it via
/// [`Self::eyedropper_picked`]; the OFX color picker drives both.
@@ -1260,6 +1330,41 @@ pub struct MulticamState {
pub current_source: i32,
}
/// One angle candidate of the multicam wizard: a root-level footage entry
/// the user can pick for the new multicam sequence.
#[derive(Debug, Clone, PartialEq)]
pub struct WizardFootage {
/// The project-explorer entry id (the footage node identity).
pub id: u64,
/// Display name (the explorer label — usually the file name).
pub name: SharedString,
/// The footage's source timecode, if the media carries one (the
/// absolute reference for the no-audio sync fallback).
pub source_timecode: Option<i64>,
/// The footage's duration in seconds (`None` = not probed).
pub duration_s: Option<f64>,
/// Whether the footage has a decodable audio stream (sync via RMS
/// envelope requires one; `None` = not probed).
pub has_audio: Option<bool>,
}
/// The audio-sync alignment result of one angle relative to the wizard's
/// reference angle (`estimate_offset` semantics: the candidate's media
/// time that matches the reference's media time 0 — a NEGATIVE offset
/// means the candidate starts earlier).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WizardSyncOffset {
/// The angle's project-explorer entry id (the same value the wizard
/// selected).
pub footage: u64,
/// Offset in seconds relative to the reference (the first selected
/// footage gets 0.0).
pub offset_s: f64,
/// Normalized correlation confidence in `[0, 1]` (`None` when the
/// offset came from timecode alignment, not the audio correlation).
pub confidence: Option<f64>,
}
/// The lifecycle state of one footage's proxy (the UI mirror of
/// `oak_codec::proxymanager::ProxyState`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+93
View File
@@ -389,6 +389,19 @@ pub fn node_type_id(g: &Graph, id: NodeId) -> String {
.unwrap_or_default()
}
/// The project's multicam nodes (the wizard/sequence drop rewire uses the
/// one whose source sequence matches).
pub fn multicam_nodes(g: &Graph) -> Vec<NodeId> {
g.node_ids()
.into_iter()
.filter(|&id| {
g.get(id)
.map(|e| e.behavior.type_id() == "org.olivevideoeditor.Olive.multicam")
.unwrap_or(false)
})
.collect()
}
/// The sequence's video format `(width, height, rate)` from its first
/// video stream.
pub fn sequence_video_params(g: &Graph, seq: NodeId) -> Option<(i32, i32, Rational)> {
@@ -1492,6 +1505,86 @@ pub fn place_footage_clip(
Ok(clip)
}
/// Places ONE video clip fed by a sequence node — a nested-sequence clip
/// (drag a sequence entry from the project explorer onto the timeline).
/// The clip reads the sequence's output during playback; the sequence's
/// own tracks stay in the source sequence (a frame, not a folder of
/// tracks, on the host timeline).
pub fn place_nested_sequence_clip(
p: &ProjectRef,
host_seq: NodeId,
source_seq: NodeId,
track_index: usize,
in_ts: i64,
out_ts: i64,
) -> Result<NodeId, String> {
if in_ts < 0 || out_ts <= in_ts {
return Err("invalid clip range (need 0 <= in < out)".to_string());
}
let (tb, list, source_behavior) = {
let g = lock(p);
let s = sequence_behavior(&g.graph, source_seq)
.ok_or_else(|| "the source sequence is not in the project".to_string())?;
let tb = sequence_time_base(&g.graph, host_seq)
.ok_or_else(|| "host sequence has no valid frame rate".to_string())?;
let list = track_list_of(&g.graph, host_seq, TrackType::Video)
.ok_or_else(|| "host sequence has no video track list".to_string())?;
let label = format!("{}(序列)", node_label(&g.graph, source_seq));
(tb, list, label)
};
let track_count = {
let g = lock(p);
track_list_behavior(&g.graph, list)
.map(|l| l.tracks.len())
.unwrap_or(0)
};
if track_index >= track_count {
return Err(format!(
"track index {track_index} out of range ({track_count} tracks)"
));
}
let in_r = ts_to_rational(in_ts, tb);
let out_r = ts_to_rational(out_ts, tb);
let length = out_r - in_r;
let clip = {
let mut g = lock(p);
let (ccore, cbehavior) = oak_node::block::clip_create();
let mut core = ccore;
core.label = source_behavior;
g.graph.add_node(core, cbehavior)
};
{
let mut g = lock(p);
let Some(clip_behavior) = g
.graph
.get_mut(clip)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<oak_node::block::ClipBlockBehavior>())
else {
return Err("clip node is not a clip".to_string());
};
clip_behavior.core.range = oak_core::TimeRange::new(in_r, length);
}
let place = oak_timeline::undopointer::TrackPlaceBlockCommand::new(
node_ref(p, list),
track_index as i32,
node_ref(p, clip),
in_r,
)
.to_command();
let edge = connect_command(
p,
source_seq,
clip,
oak_node::block::clip_input::TEXTURE_INPUT,
)?;
push_multi(vec![place, edge], "Add Clip")?;
Ok(clip)
}
/// Place linked clips of one footage on several tracks in ONE undoable
/// "Add Clip" entry (the NLE A/V-drop: a video-with-audio file lands as
/// a video clip plus a linked audio clip). `placements` lists the
+76 -1
View File
@@ -68,7 +68,7 @@ use oak_timeline::util::{block_clip_create, track_append_block};
use super::engine::{
AppEngine, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, MulticamState,
Project, ScopeData, Sequence, VideoFormat,
Project, ScopeData, Sequence, VideoFormat, WizardFootage, WizardSyncOffset,
};
use super::graphops;
use super::transport::TransportState;
@@ -2339,6 +2339,81 @@ impl AppEngine for MockEngine {
cx.notify();
}
// --- multicam wizard (mock: the demo footage entries) ---------------
fn multicam_wizard_footage(&self) -> Option<Vec<WizardFootage>> {
Some(vec![
WizardFootage {
id: 3,
name: "第一稿.mp4".into(),
source_timecode: Some(0),
duration_s: Some(30.0),
has_audio: Some(true),
},
WizardFootage {
id: 10,
name: "intro.mov".into(),
source_timecode: Some(1000),
duration_s: Some(45.0),
has_audio: Some(true),
},
WizardFootage {
id: 11,
name: "b-roll.mp4".into(),
source_timecode: Some(2000),
duration_s: Some(60.0),
has_audio: Some(true),
},
WizardFootage {
id: 12,
name: "interview.mov".into(),
source_timecode: Some(500),
duration_s: Some(90.0),
has_audio: Some(true),
},
])
}
fn multicam_wizard_sync_offsets(
&self,
selected: &[WizardFootage],
) -> Result<Vec<WizardSyncOffset>, String> {
// The mock's correlation is a demo: each angle's offset is derived
// from its source timecode relative to the reference (angle 0).
let reference_tc = selected
.first()
.and_then(|f| f.source_timecode)
.unwrap_or(0) as f64;
Ok(selected
.iter()
.map(|f| WizardSyncOffset {
footage: f.id,
offset_s: f.source_timecode.map(|t| t as f64 / 1000.0).unwrap_or(0.0)
- reference_tc / 1000.0,
confidence: f.has_audio.unwrap_or(false).then_some(0.95),
})
.collect())
}
fn multicam_create_sequence(
&mut self,
selected: Vec<WizardFootage>,
offsets: Vec<f64>,
name: String,
cx: &mut Context<Self>,
) -> Result<u64, String> {
let _ = (&selected, offsets);
// The mock has no project graph for a NEW sequence outside its
// demo: report the creation by switching the demo's name and
// returning a stable identity (the panel already shows the demo
// multicam — the wizard's create is a verbose success).
if self.sequence.name.is_empty() {
self.sequence.name = name;
}
cx.notify();
Ok(1)
}
fn backend_name(&self) -> &'static str {
"mock"
}
+450 -2
View File
@@ -40,8 +40,10 @@ use oak_node::nodes::multicamnode::{SEQUENCE_INPUT, SEQUENCE_TYPE_INPUT};
use oak_node::sequence::SequenceBehavior;
use oak_node::track::TrackType;
use super::engine::MulticamState;
use super::graphops::{self, lock, ProjectRef};
use super::engine::{MulticamState, WizardFootage};
use super::graphops::{
self, lock, sequence_behavior, track_list_behavior, track_list_of, ProjectRef,
};
/// Whether `id` names a sequence node (C++ `dynamic_cast<Sequence*>` /
/// the facade's `oakengine_node_is_sequence`).
@@ -57,6 +59,19 @@ fn connected_output(g: &Graph, node: NodeId, input: &str, element: i32) -> Optio
g.connected_output(node, input, element)
}
/// Whether the clip's texture chain feeds directly from a multicam node —
/// the timeline overlay's flag (`true` for the wizard host clip and the
/// dropped multicam source clip; both get the accented treatment).
pub fn clip_is_multicam(g: &Graph, clip: NodeId) -> bool {
connected_output(g, clip, clip_input::TEXTURE_INPUT, -1)
.map(|source| {
g.get(source)
.map(|e| e.behavior.type_id() == "org.olivevideoeditor.Olive.multicam")
.unwrap_or(false)
})
.unwrap_or(false)
}
/// The C++ `find_input_node_internal` walk: check `node`'s input
/// connections for a match, recursing into each source. Collects the first
/// sequence found (stopping at `maximum` matches, `0` = unlimited).
@@ -198,6 +213,377 @@ pub fn clip_at_playhead_with_multicam(p: &ProjectRef, seq: NodeId, time: Rationa
})
}
/// The footage entries the multicam wizard offers: root-level footage
/// nodes (skipping folders). Display labels are the node labels (file
/// names); source timecode and duration come from the probed behavior.
pub fn wizard_footage(p: &ProjectRef) -> Vec<WizardFootage> {
// The root identity first, with the guard dropped BEFORE children
// runs: `children` takes the project lock again internally (through
// `find_by_identity`), and a still-live guard from the argument
// expression re-enters the non-recursive std Mutex — the wizard
// freeze regression.
let root_id = graphops::lock(p).root.identity();
let entries = super::projectbrowser::children(p, root_id);
let mut out = Vec::new();
for entry in entries {
if entry.is_dir {
continue;
}
let Some(node) = graphops::id_of(entry.id) else {
continue;
};
let g = graphops::lock(p);
let Some(f) = graphops::footage_behavior(&g.graph, node) else {
continue;
};
if f.filename.is_empty() {
continue;
}
let name = graphops::node_label(&g.graph, node);
let source_timecode = f.has_source_start_time.then(|| {
let t = f.source_start_time;
(t.numerator() as f64 / t.denominator().max(1) as f64).round() as i64
});
let duration_s = {
let d = f.duration();
(d.denominator() != 0).then(|| d.numerator() as f64 / d.denominator() as f64)
};
let has_audio = Some(f.audio_stream_count() > 0);
out.push(WizardFootage {
id: entry.id,
name: name.into(),
source_timecode,
duration_s,
has_audio,
});
}
out
}
/// Builds the multicam sequence graph for the wizard (non-undoable —
/// the caller pushes the whole construction as one undo entry).
///
/// * a new sequence `source` with one VIDEO track per selected footage
/// (top track = angle 0); the clip on each track starts at
/// `offsets[i]` seconds (media-in 0 — the sync offset lands on the
/// timeline, so switching angles aligns the clips);
/// * a multicam node wired `sequence_in` = the source sequence and the
/// source's video track list as its `sources_in`.
///
/// The timeline clip that FEEDS the multicam output is the caller's job
/// (the app places it on the current sequence at the playhead and
/// pushes everything as one undo entry). Returns
/// `(source_sequence, multicam_node)`.
pub fn build_multicam_sequence(
p: &ProjectRef,
selected: &[WizardFootage],
offsets: &[f64],
name: &str,
) -> Result<(NodeId, NodeId), String> {
if selected.is_empty() {
return Err("no angles selected".to_string());
}
if selected.len() != offsets.len() {
return Err("angle/offset count mismatch".to_string());
}
let mut seq: Option<NodeId> = None;
let mut mc: Option<NodeId> = None;
{
let mut g = lock(p);
let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create();
let mut core = oak_node::node::NodeCore::new();
core.label = name.to_string();
let seq_id = g.graph.add_node(core, sbehavior);
seq = Some(seq_id);
// The video track list (the vec-shape course: `track_lists` is a
// Vec<NodeId> on the sequence, `tracks` a Vec on the list — not
// graph edges; mirror `find_or_create_track_list`).
let list = if let Some(list) = track_list_of(&g.graph, seq_id, TrackType::Video) {
list
} else {
let (lcore, lbehavior) = oak_node::track::TrackListBehavior::create();
let mut behavior = lbehavior;
if let Some(a) = behavior.as_any_mut() {
if let Some(list) = a.downcast_mut::<oak_node::track::TrackListBehavior>() {
list.kind = TrackType::Video;
list.array_base =
sequence_behavior(&g.graph, seq_id).map(|s| s.track_lists.len()).unwrap_or(0)
as i32;
}
}
let list = g.graph.add_node(lcore, behavior);
if let Some(s) = g
.graph
.get_mut(seq_id)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
{
s.track_lists.push(list);
}
list
};
// One video track per angle, pushed onto the list's `tracks`.
let mut track_ids = Vec::new();
for _ in 0..selected.len() {
let (tcore, tbehavior) = oak_node::track::TrackBehavior::create();
let track = g.graph.add_node(tcore, tbehavior);
if let Some(l) = g
.graph
.get_mut(list)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<oak_node::track::TrackListBehavior>())
{
l.tracks.push(track);
}
track_ids.push(track);
}
let tb_den = 25i64; // 25 fps default wizard rate
// The source's AUDIO track list (built only when one or more
// angles carries audio — the AFV half of the multicam: each
// angle's audio clip sits on its own track, and the host audio
// clip re-points at the CURRENT source's audio on switch).
let mut audio_list: Option<NodeId> = None;
let mut audio_track_ids: Vec<NodeId> = Vec::new();
if selected.iter().any(|e| e.has_audio.unwrap_or(false)) {
audio_list = Some(if let Some(list) =
track_list_of(&g.graph, seq_id, TrackType::Audio)
{
list
} else {
let (lcore, lbehavior) = oak_node::track::TrackListBehavior::create();
let mut behavior = lbehavior;
if let Some(a) = behavior.as_any_mut() {
if let Some(list) = a.downcast_mut::<oak_node::track::TrackListBehavior>() {
list.kind = TrackType::Audio;
list.array_base =
sequence_behavior(&g.graph, seq_id).map(|s| s.track_lists.len()).unwrap_or(0)
as i32;
}
}
let list = g.graph.add_node(lcore, behavior);
if let Some(s) = g
.graph
.get_mut(seq_id)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
{
s.track_lists.push(list);
}
list
});
let list = audio_list.unwrap();
for _ in 0..selected.len() {
let (tcore, tbehavior) = oak_node::track::TrackBehavior::create();
let track = g.graph.add_node(tcore, tbehavior);
if let Some(l) = g
.graph
.get_mut(list)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<oak_node::track::TrackListBehavior>())
{
l.tracks.push(track);
}
audio_track_ids.push(track);
}
}
let mut angle_clip_ids: Vec<NodeId> = Vec::new();
for (i, entry) in selected.iter().enumerate() {
let footage = graphops::id_of(entry.id)
.ok_or_else(|| format!("angle {i} is not a footage node"))?;
if graphops::footage_behavior(&g.graph, footage).is_none() {
return Err(format!("angle {i} is not a footage node"));
}
let offset_ticks = ((offsets[i].max(0.0)) * tb_den as f64).round() as i64;
let length_ticks = ((entry.duration_s.unwrap_or(10.0) * tb_den as f64).round() as i64)
.max(1);
let in_r = Rational::new(offset_ticks, tb_den);
let out_r = Rational::new(offset_ticks + length_ticks, tb_den);
let (ccore, cbehavior) = oak_node::block::clip_create(); let clip = g.graph.add_node(ccore, cbehavior);
if let Some(clip_behavior) = g
.graph
.get_mut(clip)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<oak_node::block::ClipBlockBehavior>())
{
clip_behavior.core.range = oak_core::TimeRange::new(in_r, out_r);
}
// The clip is owned by its track's `blocks` vec (not an edge).
if let Some(track) = track_ids.get(i) {
let idx = &mut g
.graph
.get_mut(*track)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<oak_node::track::TrackBehavior>());
if let Some(t) = idx {
t.index = i as i32;
}
if let Some(t) = g
.graph
.get_mut(*track)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<oak_node::track::TrackBehavior>())
{
t.blocks.push(clip);
}
}
// The footage feeds the clip through the texture-input EDGE.
g.graph
.connect(
footage,
clip,
oak_node::block::clip_input::TEXTURE_INPUT,
-1,
)
.map_err(|e| format!("connect footage to clip: {e:?}"))?;
// AFV: the angle's audio clip on its own source audio track —
// same range as the video angle (the sync offset aligns them),
// fed from the footage's audio stream.
if let (Some(audio_list), Some(audio_track)) = (audio_list, audio_track_ids.get(i)) {
if entry.has_audio.unwrap_or(false) {
let (acore, abehavior) = oak_node::block::clip_create();
let aclip = g.graph.add_node(acore, abehavior);
if let Some(clip_behavior) = g
.graph
.get_mut(aclip)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<oak_node::block::ClipBlockBehavior>())
{
clip_behavior.core.range = oak_core::TimeRange::new(in_r, out_r);
}
if let Some(t) = g
.graph
.get_mut(*audio_track)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<oak_node::track::TrackBehavior>())
{
t.blocks.push(aclip);
}
g.graph
.connect(
footage,
aclip,
oak_node::block::clip_input::TEXTURE_INPUT,
-1,
)
.map_err(|e| format!("connect footage to audio clip: {e:?}"))?;
// The angle's audio + video clips are linked (grouped
// edits stay together).
if let Some(a) = g.graph.get_mut(aclip) {
a.core.links.push(clip);
}
if let Some(v) = g.graph.get_mut(clip) {
v.core.links.push(aclip);
}
}
}
angle_clip_ids.push(clip);
let _ = audio_list;
}
let (mcore, mbehavior) = oak_node::nodes::multicamnode::create();
let mc_id = g.graph.add_node(mcore, mbehavior);
g.graph
.connect(
seq_id,
mc_id,
oak_node::nodes::multicamnode::SEQUENCE_INPUT,
-1,
)
.map_err(|e| format!("connect multicam sequence: {e:?}"))?;
// The multicam's source array input picks the CURRENT source's
// video track clip: one edge per angle, at the element index (the
// C++ shape — `sources_in[element]` is the angle's texture, and
// the traverser's `active_elements_at_time` retains ONLY the
// current source's element, which multi's `value()` forwards). The
// sequence + track-list connections alone cannot feed a texture
// (see the "value() always read None" debug report).
for (i, clip) in angle_clip_ids.iter().enumerate() {
// Grow the array slot so the element index is addressable
// (C++ `MakeArraySlot`; `source_count` reads the array size).
g.graph
.input_array_insert(mc_id, oak_node::nodes::multicamnode::SOURCES_INPUT, i as i32)
.map_err(|e| format!("multicam array slot {i}: {e:?}"))?;
g.graph
.connect(
*clip,
mc_id,
oak_node::nodes::multicamnode::SOURCES_INPUT,
i as i32,
)
.map_err(|e| format!("connect multicam source {i}: {e:?}"))?;
}
mc = Some(mc_id);
}
let (seq, mc) = (seq.ok_or("sequence")?, mc.ok_or("multicam")?);
Ok((seq, mc))
}
/// Normalized cross-correlation over two windowed peak envelopes: for
/// each candidate, slide it against the reference over a ±5 s window in
/// 0.1 s steps and pick the shift with the best score. Pure wrapper
/// over [`oak_audio::waveformsync`]-style correlation, kept testable
/// without audio decoding.
pub fn estimate_wizard_offsets(
reference: &[f64],
candidates: &[Vec<f64>],
) -> Vec<f64> {
const WINDOW_S: f64 = 0.1;
let mut out = Vec::with_capacity(candidates.len());
for candidate in candidates {
let (offset_s, _confidence) = best_correlation_offset(reference, candidate, WINDOW_S);
out.push(offset_s);
}
out
}
/// Raw sliding correlation: the candidate shifted by `offset_s * 10`
/// windows (integer windows) against the reference. Returns
/// `(offset_seconds, score)`.
fn best_correlation_offset(reference: &[f64], candidate: &[f64], window_s: f64) -> (f64, f64) {
if reference.len() < 2 || candidate.len() < 2 {
return (0.0, 0.0);
}
let mut best = (0.0f64, f64::NEG_INFINITY);
let max_shift = (5.0 / window_s).round() as i64;
for shift in -max_shift..=max_shift {
let mut dot = 0.0;
let mut ref_norm = 0.0;
let mut cand_norm = 0.0;
let mut count = 0usize;
let ref_slice = reference;
// `j = i + shift` (the candidate sample aligning to reference i).
for i in 0..ref_slice.len() {
let j = i as i64 + shift;
if j < 0 {
continue;
}
let Some(&c) = candidate.get(j as usize) else {
continue;
};
let r = ref_slice[i];
dot += r * c;
ref_norm += r * r;
cand_norm += c * c;
count += 1;
}
if count < 2 {
continue;
}
let denom = (ref_norm * cand_norm).sqrt();
let score = if denom > 1e-9 { dot / denom } else { 0.0 };
if score > best.1 {
best = (-(shift as f64) * window_s, score);
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
@@ -424,4 +810,66 @@ mod tests {
let _ = plain;
assert_eq!(block_in(&mc_clip), Rational::new(0, 1));
}
/// `estimate_wizard_offsets` recovers each candidate's lag against the
/// reference: a noise envelope shifted by +2 s must come back as +2 s
/// (the candidate's clip then starts 2 s later on the timeline).
#[test]
fn wizard_offsets_recover_known_lags() {
// Deterministic pseudo-random envelope (positive vs. silence).
let window_s = 0.1;
let n = (20.0 / window_s) as usize;
let mut state = 0x2545_F491_4F6C_DD1Du64;
let reference: Vec<f64> = (0..n)
.map(|_| {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let v = (state >> 33) as f64 / (1u64 << 31) as f64;
if v < 0.5 {
1.0
} else {
0.0
}
})
.collect();
// candidate[i] = reference[i + k] (content runs k windows ahead,
// e.g. the candidate started k windows earlier in real time).
let shift_of = |k: isize| {
let mut s = state;
let mut shifted: Vec<f64> = (0..n)
.map(|_| {
s = s
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((((s >> 33) as f64) / ((1u64 << 31) as f64)) < 0.5) as u8 as f64
})
.collect();
for i in 0..n {
let src = i as isize + k;
shifted[i] = if (0..n as isize).contains(&src) {
reference[src as usize]
} else {
shifted[i]
};
}
shifted
};
let cand_plus2 = shift_of(20);
let cand_minus3 = shift_of(-30);
let offsets = estimate_wizard_offsets(&reference, &[cand_plus2, cand_minus3]);
assert_eq!(offsets.len(), 2);
assert!((offsets[0] - 2.0).abs() < 0.35, "got {}", offsets[0]);
assert!((offsets[1] + 3.0).abs() < 0.35, "got {}", offsets[1]);
}
/// `estimate_wizard_offsets` is robust to empty or tiny inputs: it
/// returns 0 offsets rather than panicking.
#[test]
fn wizard_offsets_tolerate_short_inputs() {
assert_eq!(estimate_wizard_offsets(&[], &[vec![]]), vec![0.0]);
assert_eq!(estimate_wizard_offsets(&[1.0], &[vec![2.0, 3.0]]), vec![0.0]);
}
}
File diff suppressed because it is too large Load Diff
+432 -7
View File
@@ -147,6 +147,67 @@ fn clip_preview_media(
Some(preview_footage_media(f, is_video))
}
/// A multi-cam host clip's playable media: the CURRENT source's angle clip
/// (footage + media_in) rendered across the host clip's span.
///
/// `None` when `clip` is not fed from a multicam node (or the resolved
/// angle has no footage). The montage clip's `media_in` maps the host
/// timeline position into the angle's media: `angle_media_in + (host_in -
/// angle_in)`.
///
/// CALLER HOLDS THE PROJECT LOCK: this is pure `Graph` walking — the
/// `multicam::*` project-locking helpers MUST NOT be called from here
/// (non-recursive mutex).
fn clip_multicam_media(
g: &oak_node::graph::Graph,
clip: NodeId,
host_in: oak_core::Rational,
) -> Option<(String, i32, oak_core::Rational)> {
use oak_node::nodes::multicamnode::{CURRENT_INPUT, SEQUENCE_INPUT, SEQUENCE_TYPE_INPUT};
// The clip's texture source must be a multicam node.
let mc = g.connected_output(clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)?;
let mc_entry = g.get(mc)?;
if mc_entry.behavior.type_id() != "org.olivevideoeditor.Olive.multicam" {
return None;
}
let source = mc_entry.core.standard_value(CURRENT_INPUT, -1).to_double() as i32;
if source < 0 {
return None;
}
// The source sequence and the current source's track.
let seq = g.connected_output(mc, SEQUENCE_INPUT, -1)?;
let kind = {
let t = mc_entry.core.standard_value(SEQUENCE_TYPE_INPUT, -1).to_double() as i32;
oak_node::track::TrackType::from_c(t).unwrap_or(oak_node::track::TrackType::Video)
};
let seq_pos = super::graphops::track_list_of(g, seq, kind)?;
let list = super::graphops::track_list_behavior(g, seq_pos)?;
let track = *list.tracks.get(source as usize)?;
let track_behavior = super::graphops::track_behavior(g, track)?;
// The angle clip covering the host position.
let angle = track_behavior.blocks.iter().find_map(|&block_id| {
let clip = super::graphops::clip_behavior(g, block_id)?;
let in_ = clip.core.in_();
let out = clip.core.out();
if host_in < in_ || host_in >= out {
return None;
}
let footage_id = super::graphops::find_input_footage(g, block_id)?;
let f = super::graphops::footage_behavior(g, footage_id)?;
if f.filename.is_empty() {
return None;
}
let (filename, stream_index) = preview_footage_media(f, true);
// Host position → angle media position: the host sits at angle
// media_in when the host's in == the angle's in; the offset shifts
// it by the difference.
let host_delta = host_in - in_;
let media_in = clip.core.media_in + host_delta;
Some((filename, stream_index, media_in))
})?;
Some(angle)
}
/// The clip block's effect stack as montage effect descriptors
/// (source-first order — the chain walk's signal order, so the renderer
/// applies them media-side first). The chain walk runs all the way to
@@ -214,17 +275,30 @@ pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<Montage
if time < in_ || time >= out {
continue;
}
let Some((filename, stream_index)) =
clip_preview_media(&g.graph, block_id, true)
else {
continue;
// A multi-cam host clip decodes through its multicam node's
// CURRENT source: the montage carries that angle's angle-clip
// media (footage + the angle clip's media_in), rendered at the
// host clip's timeline span. Without this the host clip's
// texture chain has no footage to preview (the sequence viewer
// painted black).
let montage_media = clip_multicam_media(&g.graph, block_id, in_);
let (filename, stream_index, media_in) = match montage_media {
Some(mc) => mc,
None => {
let Some((filename, stream_index)) =
clip_preview_media(&g.graph, block_id, true)
else {
continue;
};
(filename, stream_index, clip.core.media_in)
}
};
clips.push(MontageClip {
filename,
stream_index,
in_time: in_,
out_time: out,
media_in: clip.core.media_in,
media_in,
gain: 1.0,
effects: clip_effects(&g.graph, block_id),
});
@@ -309,7 +383,16 @@ pub fn multicam_angle_frame_params(
// initializers and deadlock the reentrant lock in `single_track_video_montage`.
let project = lock(p).uuid.clone();
Ok(VideoTicketParams {
viewer: seq.identity(),
// The angle grid is a single-track MONTAGE render, not a full
// sequence-graph frame: viewer stays 0 so the worker takes the
// montage path. A nonzero viewer (the source sequence identity)
// made every angle request re-evaluate the WHOLE source sequence
// through the traverser — three grid cells × every tick of
// playback ground the machine to a halt ("switch then everything
// crawls"), and the graph render of the source sequence returned
// the stacked composite (all angles at once) rather than this
// one angle, which is why the cells showed the wrong/no picture.
viewer: 0,
project,
time,
force_size: Some((width, height)),
@@ -1359,7 +1442,349 @@ mod tests {
let _ = std::fs::remove_file(&media);
}
/// The original media decodes from the footage's actual first stream of
/// A multi-cam HOST clip's montage media is the CURRENT source's angle
/// clip (footage + media-in mapped from the host position) — without
/// this the host clip's texture chain has no footage and the sequence
/// viewer paints black.
#[test]
fn clip_multicam_media_resolves_the_current_angle() {
let _media = media_lock();
let media =
std::env::temp_dir().join(format!("oakapp_mcmedia_{}.mp4", std::process::id()));
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media");
let (project, seq, footage) = project_with_clip(&media);
// Build the multicam shape: source seq (the angle track), multicam
// node, host seq with a clip fed from the multicam output.
let source_seq = graphops::create_sequence(&project, "Angles");
graphops::add_track(&project, source_seq, TrackType::Video).expect("angle track");
graphops::place_footage_clip(&project, source_seq, footage, TrackType::Video, 0, 0, 10, 0)
.expect("place the angle clip");
let angle_clip = {
let g2 = graphops::lock(&project);
let track = graphops::track_ids(&g2.graph, source_seq, TrackType::Video)[0];
graphops::track_behavior(&g2.graph, track)
.expect("angle track")
.blocks
.first()
.copied()
.expect("angle clip")
};
let mc = {
let mut g = graphops::lock(&project);
let (core, behavior) = oak_node::nodes::multicamnode::create();
let id = g.graph.add_node(core, behavior);
// Default current source: 0.
if let Some(core) = g.graph.get_mut(id).map(|e| &mut e.core) {
core.set_standard_value(
oak_node::nodes::multicamnode::CURRENT_INPUT,
-1,
oak_node::value::NodeValue::Combo(0),
);
}
g.graph
.connect(source_seq, id, oak_node::nodes::multicamnode::SEQUENCE_INPUT, -1)
.unwrap();
// The angle CLIP feeds the source array at element 0 (the
// traverser's `active_elements_at_time` keeps only the current
// source, so multi `value()` forwards exactly it).
g.graph
.input_array_insert(
id,
oak_node::nodes::multicamnode::SOURCES_INPUT,
0,
)
.unwrap();
g.graph
.connect(
angle_clip,
id,
oak_node::nodes::multicamnode::SOURCES_INPUT,
0,
)
.unwrap();
id
};
// A host clip on the host sequence (default layout V1) fed from the
// multicam output; the clip's own input must first be connected.
let host_clip = graphops::place_footage_clip(&project, seq, footage, TrackType::Video, 0, 0, 10, 0)
.expect("place the host clip");
{
let mut g = graphops::lock(&project);
let _ = g
.graph
.disconnect(footage, host_clip, oak_node::block::clip_input::TEXTURE_INPUT, -1);
g.graph
.connect(mc, host_clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
.unwrap();
}
// The montage at host time 0 resolves to the angle's media.
let tb = graphops::sequence_time_base(&lock(&project).graph, seq).unwrap();
let montage = {
let g = graphops::lock(&project);
clip_multicam_media(&g.graph, host_clip, graphops::ts_to_rational(0, tb))
};
let (filename, _stream, _media_in) = montage.expect("the current angle resolves");
assert_eq!(
filename,
media.to_string_lossy(),
"the angle's footage decodes for the host clip"
);
// The FULL sequence-viewer path: video_montage of the host sequence
// -> render_montage_frame_into must produce a non-black frame (the
// host clip's angle media reaches the pixels — the "multi-cam clip
// shows black in the sequence viewer" report).
let montage = video_montage(&project, seq, graphops::ts_to_rational(0, tb));
assert_eq!(montage.len(), 1, "the host clip is the only video entry");
let params = VideoTicketParams {
viewer: 0,
project: String::new(),
time: graphops::ts_to_rational(0, tb),
force_size: Some((64, 64)),
force_format: Some(oak_core::PixelFormat::F32),
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: None,
montage,
};
let mut dst = vec![0u8; 64 * 64 * 16];
oak_render::eval::render_montage_frame_into(
graphops::ts_to_rational(0, tb),
&params,
(64, 64),
&mut dst,
64 * 16,
)
.expect("montage render");
let off = (8 * 64 + 8) * 16;
let r = f32::from_le_bytes(dst[off..off + 4].try_into().unwrap());
assert!(
r > 0.05,
"the sequence viewer shows the angle's frame, not black (r={r})"
);
// The NODE-GRAPH evaluation path: `render_graph_frame` must also
// produce the current angle (footage → angle clip → multi →
// host clip → composite, all through `traverser::evaluate` + the
// value() chain — the "preview bypasses the node graph" fix).
// Needs the render manager initialized? No — this inline eval is
// pure (render_footage_frame decodes directly).
let graph_frame = oak_render::eval::render_graph_frame(
&project,
seq,
graphops::ts_to_rational(0, tb),
(64, 64),
oak_core::PixelFormat::F32,
)
.expect("graph render");
let grow;
let gdata;
let goff;
{
let oak_render::texture::Texture::Cpu(ref gf) = &graph_frame else {
panic!("graph render produced a non-CPU frame");
};
grow = gf.linesize_bytes();
gdata = gf.data.clone();
}
goff = (8 * grow as usize + 8 * 16) as usize;
let gr = f32::from_le_bytes(gdata[goff..goff + 4].try_into().unwrap());
assert!(
gr > 0.05,
"the node-graph preview shows the angle's frame, not black (r={gr})"
);
oak_undo::global::clear().unwrap();
let _ = std::fs::remove_file(&media);
}
/// The multi-cam NODE-GRAPH source switch: two solid-color angles
/// (red/blue), the traverser evaluates `sources_in[source]` — the
/// rendered frame turns blue when `current_in` switches to source 1
/// (the "value() always read None / the node graph is bypassed"
/// report's full regression).
#[test]
fn graph_render_switches_multicam_source() {
let _media = media_lock();
let red = std::env::temp_dir().join(format!("oakapp_mcsw_r_{}.mp4", std::process::id()));
let blue = std::env::temp_dir().join(format!("oakapp_mcsw_b_{}.mp4", std::process::id()));
oak_codec::testmedia::write_test_clip_solid(&red, 64, 64, 10, 10, [1.0, 0.0, 0.0, 1.0])
.expect("red angle");
oak_codec::testmedia::write_test_clip_solid(&blue, 64, 64, 10, 10, [0.0, 0.0, 1.0, 1.0])
.expect("blue angle");
let (project, seq, _rfoot) = project_with_clip(&red);
let bfoot = graphops::import_footage(&project, &blue).expect("import blue");
graphops::add_track(&project, seq, TrackType::Video).expect("track 1");
graphops::place_footage_clip(&project, seq, _rfoot, TrackType::Video, 1, 0, 10, 0)
.expect("red angle slot");
// The multicam: order matters — source 0 = red, source 1 = blue.
let track_red = {
let g = graphops::lock(&project);
graphops::track_ids(&g.graph, seq, TrackType::Video)[0]
};
let red_clip = {
let g = graphops::lock(&project);
graphops::track_behavior(&g.graph, track_red)
.expect("track 0")
.blocks
.first()
.copied()
.expect("red clip")
};
let blue_clip = graphops::place_footage_clip(&project, seq, bfoot, TrackType::Video, 1, 0, 10, 0)
.expect("blue angle slot");
let mc = {
let mut g = graphops::lock(&project);
let (core, behavior) = oak_node::nodes::multicamnode::create();
let id = g.graph.add_node(core, behavior);
if let Some(core) = g.graph.get_mut(id).map(|e| &mut e.core) {
core.set_standard_value(
oak_node::nodes::multicamnode::CURRENT_INPUT,
-1,
oak_node::value::NodeValue::Combo(0),
);
}
g.graph
.input_array_insert(id, oak_node::nodes::multicamnode::SOURCES_INPUT, 0)
.unwrap();
g.graph
.connect(red_clip, id, oak_node::nodes::multicamnode::SOURCES_INPUT, 0)
.unwrap();
g.graph
.input_array_insert(id, oak_node::nodes::multicamnode::SOURCES_INPUT, 1)
.unwrap();
g.graph
.connect(blue_clip, id, oak_node::nodes::multicamnode::SOURCES_INPUT, 1)
.unwrap();
id
};
// Host clip fed from the multicam output.
let host_clip =
graphops::place_footage_clip(&project, seq, _rfoot, TrackType::Video, 2, 0, 10, 0)
.expect("host clip");
{
let mut g = graphops::lock(&project);
let _ = g.graph.disconnect(
_rfoot,
host_clip,
oak_node::block::clip_input::TEXTURE_INPUT,
-1,
);
g.graph
.connect(mc, host_clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
.unwrap();
}
let tb = graphops::sequence_time_base(&lock(&project).graph, seq).unwrap();
let at = graphops::ts_to_rational(0, tb);
let render_rgb = |mc_id: u64| -> (f32, f32) {
let pixel = {
let texture = oak_render::eval::render_graph_frame(
&project,
seq,
at,
(64, 64),
oak_core::PixelFormat::F32,
)
.expect("graph render");
let oak_render::texture::Texture::Cpu(ref gf) = texture else {
panic!("non-CPU frame");
};
let stride = gf.linesize_bytes();
let off = (8 * stride as usize + 8 * 16) as usize;
(
f32::from_le_bytes(gf.data[off..off + 4].try_into().unwrap()),
f32::from_le_bytes(gf.data[off + 8..off + 12].try_into().unwrap()),
)
};
let _ = mc_id;
pixel
};
// Source 0 = red angle.
let (r0, b0) = render_rgb(0);
assert!(r0 > 0.4 && b0 < 0.4, "source 0 is the red angle (r={r0} b={b0})");
// Switch to source 1: current_in drives the element the multi
// `value()` forwards — the frame must turn blue.
{
let mut g = graphops::lock(&project);
if let Some(core) = g.graph.get_mut(mc).map(|e| &mut e.core) {
core.set_standard_value(
oak_node::nodes::multicamnode::CURRENT_INPUT,
-1,
oak_node::value::NodeValue::Combo(1),
);
}
}
let (r1, b1) = render_rgb(0);
assert!(
b1 > 0.4 && r1 < 0.4,
"source 1 switches the node-graph frame to blue (r={r1} b={b1})"
);
// Playback load profile: 30 sequential graph frames vs the montage
// shortcut — the "now that the graph renders, previews crawl"
// report's baseline (in-process, no worker). A wide gap means the
// graph path needs a frame/decoder cache, not just correctness.
let measure_frames = |graph_mode: bool| -> f64 {
let start = std::time::Instant::now();
let mut frames = 0;
for f in 1..=30 {
if graph_mode {
let texture = oak_render::eval::render_graph_frame(
&project,
seq,
graphops::ts_to_rational(f, tb),
(64, 64),
oak_core::PixelFormat::F32,
)
.expect("graph frame");
let _ = texture;
} else {
let montage = video_montage(&project, seq, graphops::ts_to_rational(f, tb));
let params = VideoTicketParams {
viewer: 0,
project: String::new(),
time: graphops::ts_to_rational(f, tb),
force_size: Some((64, 64)),
force_format: Some(oak_core::PixelFormat::F32),
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: None,
montage,
};
let mut dst = vec![0u8; 64 * 64 * 16];
oak_render::eval::render_montage_frame_into(
graphops::ts_to_rational(f, tb),
&params,
(64, 64),
&mut dst,
64 * 16,
)
.expect("montage frame");
}
frames += 1;
}
start.elapsed().as_secs_f64() / frames as f64
};
let graph_ms = measure_frames(true) * 1000.0;
let montage_ms = measure_frames(false) * 1000.0;
eprintln!(
"[perf] graph {:?} ms/frame vs montage {:?} ms/frame (30 frames, 64x64)",
graph_ms, montage_ms
);
oak_undo::global::clear().unwrap();
let _ = std::fs::remove_file(&red);
let _ = std::fs::remove_file(&blue);
}
/// 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.
+35
View File
@@ -143,6 +143,41 @@ impl WaveformCache {
})
}
/// Extract an audio RMS peak envelope directly from a media file's
/// audio stream — the multicam wizard's sync correlation input. Uses
/// the oakaudio waveform extractor's min/max points (per 256 source
/// samples), keeps the first channel, and maps each point onto a tiny
/// norm that the envelope correlator can compare across angles (the
/// point's peak = max(|min|, |max|), windowed 1/10 s).
///
/// Returns `None` when the file has no decodable audio (the wizard then
/// falls back to timecode alignment).
pub fn extract_audio_envelope(filename: &str, stream_index: i32) -> Option<Vec<f64>> {
let cname = std::ffi::CString::new(filename).ok()?;
const SAMPLES_PER_POINT: i32 = 256;
const WINDOW_POINTS: usize = 10; // ~100 ms windows at 48 kHz
let outcome = oak_audio::waveform::extract(&cname, stream_index, SAMPLES_PER_POINT).ok()?;
if outcome.channels <= 0 || outcome.points.is_empty() {
return None;
}
let channel_count = outcome.channels.max(1) as usize;
let count = outcome.points.len() / channel_count;
// Peaks of the first channel.
let mut peaks: Vec<f64> = (0..count)
.map(|i| {
let p = outcome.points[i * channel_count];
f64::max(f64::from(p.min).abs(), f64::from(p.max).abs())
})
.collect();
// Window: every WINDOW_POINTS points collapses to one max (the
// envelope the correlator slides).
let mut envelope = Vec::with_capacity(count.div_ceil(WINDOW_POINTS));
for chunk in peaks.chunks_mut(WINDOW_POINTS) {
envelope.push(chunk.iter().copied().fold(0.0, f64::max));
}
Some(envelope)
}
/// The timeline's clip decorator: draws extracted waveforms into audio
/// clips (M12 P4). Reads the engine-populated cache.
pub struct OakClipDecorator {
+49 -12
View File
@@ -56,10 +56,16 @@ impl<E: AppEngine> ProjectExplorerPanel<E> {
&explorer,
|this, _explorer, event: &ProjectExplorerEvent, cx| match event {
ProjectExplorerEvent::OpenRequested { id, .. } => {
// Open the item in the engine's model (double-click on a
// footage entry selects it for the source viewer).
this.engine
.update(cx, |engine, cx| engine.select_item(*id, cx));
// A sequence entry opens in the timeline (the
// double-click on a sequence acts like opening it);
// footage entries select into the source viewer.
if this.engine.read(cx).entry_is_sequence(*id) {
this.engine
.update(cx, |engine, cx| engine.open_sequence_id(*id, cx));
} else {
this.engine
.update(cx, |engine, cx| engine.select_item(*id, cx));
}
}
ProjectExplorerEvent::FileDropRequested { paths, .. } => {
// Drag-and-drop import: probe and add each dropped file
@@ -152,7 +158,19 @@ impl<E: AppEngine> ProjectExplorerPanel<E> {
})
.detach();
}
LOCAL_RENAME | LOCAL_DELETE | LOCAL_OPEN_IN_NEW_TAB => {
LOCAL_RENAME => {
let Some(id) = self.context_entry else {
return;
};
cx.emit(RenameRequested(id));
}
LOCAL_DELETE => {
let Some(id) = self.context_entry else {
return;
};
cx.emit(DeleteRequested(id));
}
LOCAL_OPEN_IN_NEW_TAB => {
println!("[project explorer] menu action {item} (not implemented yet)");
}
LOCAL_PROPERTIES => {
@@ -289,6 +307,18 @@ pub struct NewSequenceRequested;
impl<E: AppEngine> EventEmitter<NewSequenceRequested> for ProjectExplorerPanel<E> {}
/// The project explorer asked the shell to rename entry `id`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenameRequested(pub u64);
impl<E: AppEngine> EventEmitter<RenameRequested> for ProjectExplorerPanel<E> {}
/// The project explorer asked the shell to delete entry `id`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeleteRequested(pub u64);
impl<E: AppEngine> EventEmitter<DeleteRequested> for ProjectExplorerPanel<E> {}
impl<E: AppEngine> DockPanel for ProjectExplorerPanel<E> {
fn panel_id(&self) -> gpui::dock::PanelId {
PROJECT
@@ -395,15 +425,22 @@ pub(crate) fn footage_menu(
}
/// A non-footage entry's context menu (folder / sequence): open-in-new-tab,
/// then rename / delete / properties.
/// then rename / delete / properties. Open-in-new-tab and the window
/// variant are pending the sequence tabs.
pub(crate) fn entry_menu() -> Menu {
let open_tab = MenuItem::new(
LOCAL_OPEN_IN_NEW_TAB,
crate::i18n::tr("project.context.open_in_new_tab"),
)
.disabled();
let open_window = MenuItem::new(
LOCAL_OPEN_IN_NEW_WINDOW,
crate::i18n::tr("project.context.open_in_new_window"),
)
.disabled();
Menu::new(vec![
MenuItem::new(LOCAL_OPEN_IN_NEW_TAB, crate::i18n::tr("project.context.open_in_new_tab")),
MenuItem::new(
LOCAL_OPEN_IN_NEW_WINDOW,
crate::i18n::tr("project.context.open_in_new_window"),
)
.separated(),
open_tab,
open_window.separated(),
MenuItem::new(LOCAL_RENAME, crate::i18n::tr("project.context.rename")).separated(),
MenuItem::new(LOCAL_DELETE, crate::i18n::tr("project.context.delete")),
MenuItem::new(LOCAL_PROPERTIES, crate::i18n::tr("menu.context.properties")).separated(),
+20 -9
View File
@@ -18,6 +18,7 @@
//! `src/node/src/input/multicam/multicamnode.{h,cpp}`,
//! `olive::MultiCamNode`).
use std::any::Any;
use crate::factory::NodeMeta;
use crate::id::NodeId;
use crate::node::{Category, NodeBehavior, NodeCore};
@@ -256,14 +257,12 @@ impl NodeBehavior for MultiCamNode {
}
}
/// Evaluate outputs (C++ `value()`): pushes the first value of the
/// `sources_in` value array (which, per
/// `active_elements_at_time`, is the currently selected source);
/// pushes nothing when the array is empty.
///
/// The Rust row carries no array payload (the `sources_in` value is
/// the single active element), so the connected value is pushed
/// through as-is; when the input is absent nothing is pushed.
/// Evaluate outputs (C++ `value()`): pushes the ARRAY element of
/// `sources_in` selected by `current_in` (the current source) — the
/// traverser stores array elements under `{input}[{element}]` keys
/// ([`crate::traverser`]), so the selected source's texture is the
/// value at `sources_in[source]`. A missing element (or an absent
/// array) pushes nothing.
fn value(
&self,
core: &NodeCore,
@@ -271,7 +270,19 @@ impl NodeBehavior for MultiCamNode {
time: Rational,
table: &mut NodeValueTable,
) {
let _ = (core, time);
let _ = time;
let source = core.standard_value(CURRENT_INPUT, -1).to_double() as i32;
let key = if source >= 0 {
format!("{SOURCES_INPUT}[{source}]")
} else {
SOURCES_INPUT.to_string()
};
if let Some(v) = inputs.get(&key) {
table.push(v.value_type(), v.clone(), None);
return;
}
// Fallback: an unindexed source value (single-source wiring from
// before the element-tagged rows).
if let Some(v) = inputs.get(SOURCES_INPUT) {
table.push(v.value_type(), v.clone(), None);
}
+11 -1
View File
@@ -254,6 +254,11 @@ fn build_row(
// which elements are live at this time (C++
// `GetActiveElementsAtTime` — a track pulls only the blocks
// covering the frame). An empty answer means "no restriction".
// Element-tagged keys: an array input's per-element values coexist
// in the row under `{input}[{element}]` (C++ `GetValueAtTime`
// indexes the array; the multi-cam node reads exactly the element
// of its current source — a plain `id` key would collapse the
// array to its last element).
if conns.iter().any(|(_, e)| *e >= 0) {
let active = entry.behavior.active_elements_at_time(id, time);
if !active.is_empty() {
@@ -271,7 +276,12 @@ fn build_row(
.get(&(from, upstream_time))
.map(|t| pick_value(t, entry.core.input_data_type(id)))
.unwrap_or(NodeValue::None);
row.insert(id.to_string(), value);
let key = if element >= 0 {
format!("{id}[{element}]")
} else {
id.to_string()
};
row.insert(key, value);
}
}
row
+135 -14
View File
@@ -801,11 +801,48 @@ static DECODERS: std::sync::OnceLock<
/// "4K 切换后大量 CUDA_ERROR_OUT_OF_MEMORY 报错" log flood). Software
/// sessions (system RAM only) are evicted only when nothing else is
/// available.
const MAX_CACHED_DECODERS: usize = 16;
const MAX_CACHED_DECODERS: usize = 6;
/// LRU tick source for [`DECODERS`].
static DECODER_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// Cap on decoded FRAMES cached per process (release builds of the graph
/// renderer re-decode every frame the pre-render window pulls — each is a
/// seek+decode on the shared decoder session, which serializes the graph
/// path behind the montage baseline. A small frame LRU lets playback pull
/// the forward window from cache instead of thrashing the decoder: the
/// window is sequential, so a short FIFO of recently decoded frames hits
/// on every pre-render restart and on repeat plays).
const MAX_CACHED_FRAMES: usize = 24;
/// Decoded-frame LRU: `(filename, stream, time, w, h)` -> F32 CPU frame.
/// Frame data is the expensive part (a 1080p frame ≈ 31 MB); the decoder
/// session cache alone still re-decodes every `render_footage_frame`.
/// The size is part of the key: the same media at a different target
/// resolution is a different frame (an interleaved source-monitor/proxy
/// request must not reuse a wrongly-sized pixel buffer).
static DECODED_FRAMES: std::sync::OnceLock<
std::sync::Mutex<
std::collections::HashMap<(String, i32, (i64, i64), i32, i32), (Frame, u64)>,
>,
> = std::sync::OnceLock::new();
fn decoded_frames(
) -> std::sync::MutexGuard<
'static,
std::collections::HashMap<(String, i32, (i64, i64), i32, i32), (Frame, u64)>,
> {
DECODED_FRAMES
.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
/// Time-key rational (the frame-LRU's deterministic key half).
fn time_key(t: &Rational) -> (i64, i64) {
(t.numerator(), t.denominator())
}
fn decoders(
) -> std::sync::MutexGuard<
'static,
@@ -853,6 +890,33 @@ fn open_decoder(filename: &str, stream_index: i32) -> Result<Arc<dyn oak_codec::
}
let decoder: Arc<dyn oak_codec::decoder::Decoder> = Arc::new(FFmpegDecoder::new());
let stream = CodecStream::with_block(filename.to_string(), stream_index, None);
// NVDEC surface-pool budget BEFORE opening: each HARDWARE session
// pins big GPU memory chunks (a 1080p-4K HEVC pool ≈ 300-1000 MB).
// Several worker processes × sessions each exceed even a 16 GB card —
// the `CUDA_ERROR_OUT_OF_MEMORY` log flood. Per process O N L Y one
// hardware session may coexist (the video being played is shared, so
// one NVDEC context serves every montage/graph frame); when one is
// already open, evict it now so the new open has the VRAM budget.
// Eviction before open matters: post-open eviction is too late — the
// failing open was already trying to allocate.
{
let mut cache = decoders();
const MAX_HARDWARE_SESSIONS: usize = 1;
let hw_count = cache
.iter()
.filter(|(_, (d, _))| d.hardware_decoding())
.count();
if hw_count >= MAX_HARDWARE_SESSIONS {
if let Some(victim) = cache
.iter()
.filter(|(_, (d, _))| d.hardware_decoding())
.min_by_key(|(_, (_, t))| *t)
.map(|(k, _)| k.clone())
{
cache.remove(&victim);
}
}
}
decoder
.open(&stream)
.map_err(|e| Error::Failed(format!("footage decode open: {e:?}")))?;
@@ -874,15 +938,32 @@ fn open_decoder(filename: &str, stream_index: i32) -> Result<Arc<dyn oak_codec::
/// Decode the footage frame at `time` and copy/scale it into an
/// oakrender F32 frame of `(w, h)`.
pub fn render_footage_frame(
filename: &str,
stream_index: i32,
time: Rational,
size: (i32, i32),
format: PixelFormat,
) -> Result<Texture> {
let decoder = open_decoder(filename, stream_index)?;
let (w, h) = size;
pub fn render_footage_frame(
filename: &str,
stream_index: i32,
time: Rational,
size: (i32, i32),
format: PixelFormat,
) -> Result<Texture> {
// Decoded frame LRU: playback asks for the same (or nearby)
// (file, stream, time) repeatedly (pre-render restarts, repeated
// scale-up at the same time, graph + montage interleaving); the
// decode itself is the expensive part and must not re-run per
// request. The target size is part of the key (see the cache
// type's doc).
let (w, h) = size;
let cache_size = if w > 0 && h > 0 { (w, h) } else { (-1, -1) };
let cache_key = (filename.to_string(), stream_index, time_key(&time), cache_size.0, cache_size.1);
{
let mut cache = decoded_frames();
let tick = DECODER_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let frame = cache.get(&cache_key).map(|(f, _)| f.clone());
if let Some(frame) = frame {
cache.insert(cache_key.clone(), (frame.clone(), tick));
return Ok(Texture::wrap_frame(frame));
}
}
let decoder = open_decoder(filename, stream_index)?;
let params = RetrieveVideoParams {
stream: CodecStream::with_block(filename.to_string(), stream_index, None),
time,
@@ -943,6 +1024,24 @@ pub fn render_footage_frame(
// Input node: source colorspace → the pipeline working space (ACEScg
// by default; the legacy sRGB working space keeps the pass-through).
convert_decoded_to_working(&mut dst, &decoded);
// Memoize the finished working-space frame (LRU-capped).
{
let mut cache = decoded_frames();
let tick = DECODER_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if !cache.contains_key(&cache_key) {
if cache.len() >= MAX_CACHED_FRAMES {
if let Some(victim) = cache
.iter()
.filter(|(_, (_, t))| *t > 0)
.min_by_key(|(_, (_, t))| *t)
.map(|(k, _)| k.clone())
{
cache.remove(&victim);
}
}
cache.insert(cache_key.clone(), (dst.clone(), tick));
}
}
Ok(Texture::wrap_frame(dst))
}
@@ -1056,15 +1155,37 @@ fn composite_tracks_gpu(
/// CPU composite of `frames` into one `size` frame — the fallback when no
/// GPU device is available (or the pass fails): bottom (last) to top
/// (first) via [`composite_over`].
///
/// GPU failures are remembered: a device whose wgpu pipeline fails
/// validation (e.g. an adapter that advertises ComputePipeline yet lacks
/// the required features) fails EVERY frame otherwise — each attempt
/// recompiles the shader, surfaces a validation error and stalls the
/// playback tick (the "picture barely updates on NVIDIA" report). After
/// one failure the composite stays on the CPU path for the process.
static GPU_COMPOSITE_FAILED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn composite_tracks(frames: Vec<Frame>, size: (i32, i32)) -> Frame {
let (w, h) = size;
if w <= 0 || h <= 0 {
return Frame::dummy();
}
if let Some(ctx) = crate::backend::GpuContext::shared() {
match composite_tracks_gpu(&ctx, &frames, (w, h)) {
Ok(frame) => return frame,
Err(err) => eprintln!("GPU track composite failed, using CPU: {err:#}"),
if !GPU_COMPOSITE_FAILED.load(std::sync::atomic::Ordering::Relaxed) {
// Single-frame composites (the common preview case) stay on the
// CPU path: the GPU route costs an upload+download round trip per
// frame for no benefit until two or more tracks overlap.
if frames.len() > 1 {
if let Some(ctx) = crate::backend::GpuContext::shared() {
match composite_tracks_gpu(&ctx, &frames, (w, h)) {
Ok(frame) => return frame,
Err(err) => {
eprintln!(
"GPU track composite failed, using CPU (and staying there): {err:#}"
);
GPU_COMPOSITE_FAILED.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
}
}
}
let Ok(mut acc) = generate_frame(Rational::new(0, 1), (w, h), PixelFormat::F32) else {
+72 -1
View File
@@ -83,7 +83,12 @@ pub struct RenderManager {
/// for (M16 S1: dedup key — revisions alone collide across projects,
/// since every fresh project shares small revision numbers).
current_key: Mutex<Option<(String, u64)>>,
/// Teardown in progress (M16 S1): set first thing in
/// The live project the current snapshot was written from — the
/// INLINE (test) backend's graph-mode render source. The process
/// backend loads the snapshot file in its workers instead; this slot
/// only exists so `render_produced_frame` can run the same
/// [`oak_node::traverser`] graph evaluation without forking a child.
inline_graph: Mutex<Option<(String, std::sync::Arc<std::sync::Mutex<oak_node::project::Project>>)>>, /// Teardown in progress (M16 S1): set first thing in
/// [`RenderManager::shutdown`]; `set_graph_snapshot` /
/// `clear_graph_snapshot` become no-ops afterwards so a stale push
/// from a dying test/app cannot re-arm the worker pool mid-shutdown.
@@ -112,6 +117,35 @@ impl RenderManager {
}
let backend = BackendKind::from_user_config();
let producer: crate::ticket::Producer = Arc::new(|time, params| {
// M16 S1 graph mode on the INLINE (test) backend: when the
// ticket names a viewer of the snapshotted project, evaluate
// the node graph (the same path the oak-worker pool runs in
// the process backend) instead of the montage shortcut — the
// preview must show the node graph, not the decoded-only
// montage.
if params.viewer != 0 && !params.project.is_empty() {
if let Some(m) = crate::manager::RenderManager::global() {
if let Some((uuid, project)) = m.inline_graph() {
if uuid == params.project {
if let Some(viewer_id) =
oak_node::id::NodeId::from_identity(params.viewer)
{
if let Ok(texture) = eval::render_graph_frame(
&project,
viewer_id,
time,
params.render_size(),
oak_core::PixelFormat::F32,
) {
return Ok(crate::ticket::TicketPayload::Video(
texture,
));
}
}
}
}
}
}
eval::render_produced_frame(time, params)
.map(crate::ticket::TicketPayload::Video)
});
@@ -163,12 +197,22 @@ impl RenderManager {
snapshots: GraphSnapshotStore::new(),
current_snapshot: Mutex::new(None),
current_key: Mutex::new(None),
inline_graph: Mutex::new(None),
stopping: AtomicBool::new(false),
process_pool,
}));
Ok(())
}
/// The inline backend's graph-mode render source: the live project the
/// current snapshot was written for, with its uuid. `None` when there
/// is no snapshot (or the process pool answers frames instead).
pub fn inline_graph(
&self,
) -> Option<(String, std::sync::Arc<std::sync::Mutex<oak_node::project::Project>>)> {
lock(&self.inline_graph).clone()
}
/// Global access; `None` before init.
pub fn global() -> Option<Arc<RenderManager>> {
lock(&MANAGER).clone()
@@ -226,6 +270,12 @@ impl RenderManager {
return Ok(()); // teardown: no re-arm after the drain
}
let uuid = lock(project).uuid.clone();
if std::env::var_os("OAK_DEBUG_SNAPSHOT").is_some() {
eprintln!(
"[snapshot] set_graph_snapshot uuid {uuid} revision {revision} key {:?}",
*lock(&self.current_key)
);
}
if *lock(&self.current_key) == Some((uuid.clone(), revision)) {
return Ok(()); // unchanged state: no rewrite, no re-send
}
@@ -234,10 +284,30 @@ impl RenderManager {
self.snapshots.release(&old);
}
self.dispatch.set_graph_snapshot(Some(path));
// Keep a live graph handle for the inline backend's graph mode
// (the same snapshot content, in-process, no file required).
// `project` arrives by reference (the snapshot store's lifetime);
// oak-app installs the Arc via [`RenderManager::set_inline_project`]
// — the inline graph slot is only armed there, never from a
// borrowed reference.
*lock(&self.current_key) = Some((uuid, revision));
Ok(())
}
/// Installs the live project the inline backend's graph mode renders
/// from (oak-app calls this on project adoption; it also arms the uuid
/// the snapshot dedups against). The process backend ignores it.
pub fn set_inline_project(
&self,
project: std::sync::Arc<std::sync::Mutex<oak_node::project::Project>>,
) {
let uuid = match project.lock() {
Ok(g) => g.uuid.clone(),
Err(p) => p.into_inner().uuid.clone(),
};
*lock(&self.inline_graph) = Some((uuid, project));
}
/// M16 S1 graph mode: force the workers to re-load the current project
/// snapshot even when the undo-stack revision is unchanged. The
/// color-settings dialog writes project settings directly (no undo
@@ -277,6 +347,7 @@ impl RenderManager {
self.snapshots.release(&old);
}
*lock(&self.current_key) = None;
*lock(&self.inline_graph) = None;
self.dispatch.set_graph_snapshot(None);
}
+10 -1
View File
@@ -502,7 +502,16 @@ fn per_worker_gpu_budget(frame_size: (i32, i32), fps: u32) -> u64 {
let pixels = (frame_size.0.max(0) as f64) * (frame_size.1.max(0) as f64);
let pixel_ratio = pixels / (1920.0 * 1080.0);
let fps_factor = (fps.max(1) as f64 / 24.0).sqrt().max(1.0);
let peak = 1u64 << 30;
// Peak-vs-quiet spread: a worker sustains a NVDEC session (surface
// pool + an in-flight frame upload) plus the wgpu pipeline and a
// couple of montage uploads. The 1 GiB 1080p / 4 GiB 4K estimates
// have been shown to still exhaust a 16 GB card when the pool pushes
// them simultaneously on several sources (the CUDA_ERROR_OUT_OF_MEMORY
// mid-playback flood), so the budget includes an extra headroom
// factor for undetected spikes (shared decoder surface growth during
// long GOP scans at a keyframe miss).
let headroom = 2;
let peak = (1u64 << 30) * headroom;
let idle = 256u64 << 20;
((peak as f64 * pixel_ratio * fps_factor + idle as f64) as u64).max(1)
}
+1 -1
Submodule gpui updated: 41dac8f33e...209c593da6