From 5937e557a7dd4ab81381257531c49a76bdb63f7d Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 30 Aug 2026 21:58:26 +0800 Subject: [PATCH] app: project-explorer new-sequence button, 4K presets, keying node fix, color-picker canvases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Project explorer header gains a 新建序列 button (opens the existing new-sequence dialog, seeded like the menu action). - Sequence presets gain 4K UHD (3840x2160@25) and 4K DCI (4096x2160@24); the sequence-properties dialog re-selects them on reopen. Format fields are also seedable from a probed footage format (the drop flow). - Chroma Key (and Color Difference Key) value() now box a ShaderJobPayload like Despill: the old OCIO-processor gate pushed nothing (the processor is never populated without the render bridge), so the traverser handed the clip NodeValue::None and the rendered frame lost the clip. The renderer resolves the OCIO stub at compile time from OCIO_SHADER_STUBS. End-to-end graph test: green key on a green frame keys out, red key keeps it. - The OFX color picker's SV palette / hue bar / preview / swatch canvases get size_full(): the bare canvases collapsed to zero height in the block layout, so the palette painted nothing (the reported 色板没显示). Regression test clicks the palette center and expects mid s/v. --- assets/i18n/de.yaml | 8 + assets/i18n/en-US.yaml | 8 + assets/i18n/es.yaml | 8 + assets/i18n/fr.yaml | 8 + assets/i18n/ja.yaml | 8 + assets/i18n/pt.yaml | 8 + assets/i18n/ru.yaml | 8 + assets/i18n/zh-CN.yaml | 8 + crates/oak-app/src/app.rs | 204 +++++++++++++++- crates/oak-app/src/dialogs.rs | 228 ++++++++++++++++-- .../oak-app/src/oakui/component/controls.rs | 6 +- crates/oak-app/src/oakui/engine.rs | 9 + crates/oak-app/src/oakui/real.rs | 84 +++++++ crates/oak-app/src/oakui/renderops.rs | 51 ++++ crates/oak-app/src/panels/ofx_params.rs | 87 ++++++- crates/oak-app/src/panels/project_explorer.rs | 65 +++-- crates/oak-app/src/panels/timeline.rs | 36 +++ crates/oak-node/src/nodes/chromakey.rs | 182 ++++++-------- .../oak-node/src/nodes/colordifferencekey.rs | 40 ++- crates/oak-render/tests/graph_render.rs | 97 ++++++++ 20 files changed, 988 insertions(+), 165 deletions(-) diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index a4a052602..2e7ff45e0 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -272,6 +272,13 @@ "seqprops.preset.ntsc": "NTSC (720×480, 29,97 fps)" "seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 fps)" "seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 fps)" +"seqprops.preset.uhd_4k": "4K UHD (3840×2160, 25 fps)" +"seqprops.preset.dci_4k": "4K DCI (4096×2160, 24 fps)" +"seqprops.drop.title": "Neue Sequenz für Material" +"seqprops.drop.caption": "Die Timeline hat keine geöffnete Sequenz. Das eingefügte Material braucht eine Sequenz; die Parameter wurden aus dem Medium ermittelt:" +"seqprops.drop.use_footage": "Materialparameter verwenden" +"seqprops.drop.manual": "Manuell festlegen" +"seqprops.drop.no_video": "Das Material hat keinen Videostream; das Standard-Format der Sequenz wird verwendet." "seqprops.width": "Breite" "seqprops.height": "Höhe" "seqprops.frame_rate": "Bildrate" @@ -445,6 +452,7 @@ "node.category.openfx": "OpenFX" "node.category.group": "Gruppe" "project.context.new": "Neu" +"project.new_sequence": "Neue Sequenz" "project.context.reveal_in_finder": "Im Finder anzeigen" "project.context.replace_footage": "Material ersetzen" "project.context.rename": "Umbenennen" diff --git a/assets/i18n/en-US.yaml b/assets/i18n/en-US.yaml index c6c94af89..ae8d9c1bf 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -272,6 +272,13 @@ "seqprops.preset.ntsc": "NTSC (720×480, 29.97 fps)" "seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 fps)" "seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 fps)" +"seqprops.preset.uhd_4k": "4K UHD (3840×2160, 25 fps)" +"seqprops.preset.dci_4k": "4K DCI (4096×2160, 24 fps)" +"seqprops.drop.title": "New Sequence for Footage" +"seqprops.drop.caption": "The timeline has no sequence open. The dropped footage needs a sequence; its parameters were probed from the media:" +"seqprops.drop.use_footage": "Use Footage Parameters" +"seqprops.drop.manual": "Specify Manually" +"seqprops.drop.no_video": "The footage has no video stream; the default sequence format will be used." "seqprops.width": "Width" "seqprops.height": "Height" "seqprops.frame_rate": "Frame Rate" @@ -445,6 +452,7 @@ "node.category.openfx": "OpenFX" "node.category.group": "Group" "project.context.new": "New" +"project.new_sequence": "New Sequence" "project.context.reveal_in_finder": "Reveal in Finder" "project.context.replace_footage": "Replace Footage" "project.context.rename": "Rename" diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index e51030366..19b8da027 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -272,6 +272,13 @@ "seqprops.preset.ntsc": "NTSC (720×480, 29,97 fps)" "seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 fps)" "seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 fps)" +"seqprops.preset.uhd_4k": "4K UHD (3840×2160, 25 fps)" +"seqprops.preset.dci_4k": "4K DCI (4096×2160, 24 fps)" +"seqprops.drop.title": "Nueva secuencia para el material" +"seqprops.drop.caption": "La línea de tiempo no tiene una secuencia abierta. El material soltado necesita una secuencia; sus parámetros fueron sondeados desde el medio:" +"seqprops.drop.use_footage": "Usar parámetros del material" +"seqprops.drop.manual": "Especificar manualmente" +"seqprops.drop.no_video": "El material no tiene flujo de vídeo; se usará el formato predeterminado de la secuencia." "seqprops.width": "Ancho" "seqprops.height": "Alto" "seqprops.frame_rate": "Velocidad de fotogramas" @@ -445,6 +452,7 @@ "node.category.openfx": "OpenFX" "node.category.group": "Grupo" "project.context.new": "Nuevo" +"project.new_sequence": "Nueva secuencia" "project.context.reveal_in_finder": "Mostrar en Finder" "project.context.replace_footage": "Reemplazar material" "project.context.rename": "Cambiar nombre" diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index 71ced939d..ea2944fde 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -272,6 +272,13 @@ "seqprops.preset.ntsc": "NTSC (720×480, 29,97 i/s)" "seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 i/s)" "seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 i/s)" +"seqprops.preset.uhd_4k": "4K UHD (3840×2160, 25 i/s)" +"seqprops.preset.dci_4k": "4K DCI (4096×2160, 24 i/s)" +"seqprops.drop.title": "Nouvelle séquence pour le média" +"seqprops.drop.caption": "La chronologie n’a aucune séquence ouverte. Le média déposé a besoin d’une séquence ; ses paramètres ont été détectés depuis le média :" +"seqprops.drop.use_footage": "Utiliser les paramètres du média" +"seqprops.drop.manual": "Spécifier manuellement" +"seqprops.drop.no_video": "Le média n’a pas de flux vidéo ; le format de séquence par défaut sera utilisé." "seqprops.width": "Largeur" "seqprops.height": "Hauteur" "seqprops.frame_rate": "Cadence" @@ -445,6 +452,7 @@ "node.category.openfx": "OpenFX" "node.category.group": "Groupe" "project.context.new": "Nouveau" +"project.new_sequence": "Nouvelle séquence" "project.context.reveal_in_finder": "Révéler dans le Finder" "project.context.replace_footage": "Remplacer les médias" "project.context.rename": "Renommer" diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index c43dc34ba..88dde8a9f 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -272,6 +272,13 @@ "seqprops.preset.ntsc": "NTSC (720×480, 29.97 fps)" "seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 fps)" "seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 fps)" +"seqprops.preset.uhd_4k": "4K UHD(3840×2160、25 fps)" +"seqprops.preset.dci_4k": "4K DCI(4096×2160、24 fps)" +"seqprops.drop.title": "メディアの新規シーケンス" +"seqprops.drop.caption": "タイムラインに開いているシーケンスがありません。ドロップしたメディアにはシーケンスが必要です。パラメーターはメディアからプローブされました:" +"seqprops.drop.use_footage": "メディアのパラメータを使用" +"seqprops.drop.manual": "手動で指定" +"seqprops.drop.no_video": "メディアにビデオストリームがありません。デフォルトのシーケンス形式が使用されます。" "seqprops.width": "幅" "seqprops.height": "高さ" "seqprops.frame_rate": "フレームレート" @@ -445,6 +452,7 @@ "node.category.openfx": "OpenFX" "node.category.group": "グループ" "project.context.new": "新規" +"project.new_sequence": "新規シーケンス" "project.context.reveal_in_finder": "Finderに表示" "project.context.replace_footage": "素材を置き換え" "project.context.rename": "名前を変更" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index abcce2a36..b63079679 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -272,6 +272,13 @@ "seqprops.preset.ntsc": "NTSC (720×480, 29,97 qps)" "seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 qps)" "seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 qps)" +"seqprops.preset.uhd_4k": "4K UHD (3840×2160, 25 qps)" +"seqprops.preset.dci_4k": "4K DCI (4096×2160, 24 qps)" +"seqprops.drop.title": "Nova sequência para o material" +"seqprops.drop.caption": "A linha de tempo não tem uma sequência aberta. O material solto precisa de uma sequência; seus parâmetros foram sondados a partir da mídia:" +"seqprops.drop.use_footage": "Usar parâmetros do material" +"seqprops.drop.manual": "Especificar manualmente" +"seqprops.drop.no_video": "O material não tem fluxo de vídeo; o formato padrão da sequência será usado." "seqprops.width": "Largura" "seqprops.height": "Altura" "seqprops.frame_rate": "Taxa de quadros" @@ -445,6 +452,7 @@ "node.category.openfx": "OpenFX" "node.category.group": "Grupo" "project.context.new": "Novo" +"project.new_sequence": "Nova sequência" "project.context.reveal_in_finder": "Revelar no Finder" "project.context.replace_footage": "Substituir mídia" "project.context.rename": "Renomear" diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index 2738d26f5..9a9813282 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -272,6 +272,13 @@ "seqprops.preset.ntsc": "NTSC (720×480, 29,97 кадр/с)" "seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 кадр/с)" "seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 кадр/с)" +"seqprops.preset.uhd_4k": "4K UHD (3840×2160, 25 кадр/с)" +"seqprops.preset.dci_4k": "4K DCI (4096×2160, 24 кадр/с)" +"seqprops.drop.title": "Новая последовательность для медиа" +"seqprops.drop.caption": "В таймлайне нет открытой последовательности. Упавшему медиа нужна последовательность; её параметры были определены из медиафайла:" +"seqprops.drop.use_footage": "Использовать параметры медиа" +"seqprops.drop.manual": "Указать вручную" +"seqprops.drop.no_video": "У медиа нет видеопотока; будет использован формат последовательности по умолчанию." "seqprops.width": "Ширина" "seqprops.height": "Высота" "seqprops.frame_rate": "Частота кадров" @@ -445,6 +452,7 @@ "node.category.openfx": "OpenFX" "node.category.group": "Группа" "project.context.new": "Создать" +"project.new_sequence": "Новая последовательность" "project.context.reveal_in_finder": "Показать в Finder" "project.context.replace_footage": "Заменить медиафайлы" "project.context.rename": "Переименовать" diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index eace5b535..d173969ed 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -272,6 +272,13 @@ "seqprops.preset.ntsc": "NTSC(720×480,29.97 fps)" "seqprops.preset.hd_1080_25": "HD 1080p25(1920×1080,25 fps)" "seqprops.preset.hd_1080_30": "HD 1080p30(1920×1080,30 fps)" +"seqprops.preset.uhd_4k": "4K UHD(3840×2160,25 fps)" +"seqprops.preset.dci_4k": "4K DCI(4096×2160,24 fps)" +"seqprops.drop.title": "为素材新建序列" +"seqprops.drop.caption": "时间轴没有打开的序列。拖入的素材需要先有一个序列,以下参数来自素材探测:" +"seqprops.drop.use_footage": "使用素材参数" +"seqprops.drop.manual": "手工指定参数" +"seqprops.drop.no_video": "该素材没有视频流,将使用默认的序列格式。" "seqprops.width": "宽度" "seqprops.height": "高度" "seqprops.frame_rate": "帧速率" @@ -445,6 +452,7 @@ "node.category.openfx": "OpenFX" "node.category.group": "组" "project.context.new": "新建" +"project.new_sequence": "新建序列" "project.context.reveal_in_finder": "在 Finder 中显示" "project.context.replace_footage": "替换素材" "project.context.rename": "重命名" diff --git a/crates/oak-app/src/app.rs b/crates/oak-app/src/app.rs index c2f62c281..e46f32c16 100644 --- a/crates/oak-app/src/app.rs +++ b/crates/oak-app/src/app.rs @@ -57,7 +57,9 @@ use gpui_widgets::theme::{apply_theme, OakTheme}; use gpui_widgets::viewer::PlaybackClock; use crate::actions::{ActionId, TimelineToolExt, Tool}; -use crate::dialogs::{ExportDialogContent, PreferencesDialogContent}; +use crate::dialogs::{ + DropSequenceChoice, ExportDialogContent, PreferencesDialogContent, SequenceFormatSeed, +}; use crate::oakui::{AppEngine, ExportSession, MockEngine, Monitor, RealEngine}; use crate::panels::commands as panel_commands; use crate::panels::effect_library::EffectLibraryPanel; @@ -70,7 +72,7 @@ use crate::panels::program_viewer::ProgramViewerPanel; use crate::panels::project_explorer::ProjectExplorerPanel; use crate::panels::source_viewer::SourceViewerPanel; use crate::panels::status_bar::StatusBar; -use crate::panels::timeline::TimelinePanel; +use crate::panels::timeline::{FootageDropNeedsSequence, TimelinePanel}; // Menu item ids: thin aliases over the action registry's menu ids (the // registry is the single source; these keep the test call sites readable). @@ -131,6 +133,9 @@ mod modal_ids { /// The sequence properties dialog (right-click a sequence in the /// project explorer > Sequence Properties). pub const SEQUENCE_PROPERTIES: usize = 15; + /// 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; } /// What a picked platform-dialog path should do. @@ -210,6 +215,12 @@ enum ModalState { modal: Entity, content: Entity>, }, + /// The drop-onto-empty-timeline choice (拖入素材到没有序列的时间轴): + /// probe the footage's params as the sequence's, or open the manual + /// setup dialog. The skipped drop is resumed by the shell once a + /// 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 }, } /// A running export: the session the tick loop drains for progress. @@ -233,7 +244,8 @@ impl ModalState { | ModalState::ProjectProperties { modal, .. } | ModalState::About { modal } | ModalState::NewSequence { modal, .. } - | ModalState::SequenceProperties { modal, .. } => Some(modal.clone()), + | ModalState::SequenceProperties { modal, .. } + | ModalState::DropSequenceChoice { modal, .. } => Some(modal.clone()), } } } @@ -352,6 +364,10 @@ pub struct OakApp { shell_focus: gpui::FocusHandle, /// The running export session, if any. export: Option, + /// A footage drop paused on the empty-timeline drop choice: kept while + /// its modal (the choice dialog, or the new-sequence dialog it spawns) + /// is up; performed when a sequence exists. `None` outside the flow. + pending_drop: Option, /// Whether the progress modal currently on screen is the OFX plugin /// progress dialog (as opposed to the export progress). Guards /// [`poll_plugin_progress`] from hijacking the export's bar. @@ -542,6 +558,29 @@ impl OakApp { ) .detach(); + // The project explorer's 新建序列 button opens the new-sequence + // dialog (the same one File > New > Sequence… uses). + cx.subscribe( + &panels.project, + |this, + _panel, + _event: &crate::panels::project_explorer::NewSequenceRequested, + cx| { + this.open_new_sequence(cx); + }, + ) + .detach(); + + // A footage drop onto a timeline with no open sequence goes through + // the probe-vs-manual choice before any sequence is created. + cx.subscribe( + &panels.timeline, + |this, _panel, event: &FootageDropNeedsSequence, cx| { + this.on_footage_drop_needs_sequence(*event, cx); + }, + ) + .detach(); + // The viewer panels re-emit their monitor-level requests (full-screen // and the loop in/out range) for the shell to apply: full-screen // toggles the window, and the in/out/clear requests act on the shared @@ -730,6 +769,7 @@ impl OakApp { modal: ModalState::None, shell_focus, export: None, + pending_drop: None, pending_export: None, plugin_progress_open: false, plugin_progress_rx: Mutex::new(plugin_progress_rx), @@ -2233,12 +2273,47 @@ impl OakApp { /// `NewSequenceDialog`): the sequence name plus the format fields. The /// OK button creates the sequence through the content's `commit`. fn open_new_sequence(&mut self, cx: &mut Context) { + self.spawn_new_sequence(SequenceFormatSeed::hd_1080p25(), cx); + } + + /// Opens the new-sequence dialog seeded from the footage's probed video + /// parameters (the drop-onto-empty-timeline flow), named like the + /// footage so the user's OK match the media. + fn open_new_sequence_for_footage( + &mut self, + drop: FootageDropNeedsSequence, + cx: &mut Context, + ) { + let probe = self + .engine + .read(cx) + .footage_video_params(drop.footage_id); + let seed = probe + .map(|(width, height, rate, interlaced)| { + SequenceFormatSeed::from_format( + &crate::oakui::engine::VideoFormat { width, height, rate }, + interlaced, + ) + }) + .unwrap_or_else(SequenceFormatSeed::hd_1080p25); + self.spawn_new_sequence(seed, cx); + } + + /// Opens the new-sequence dialog with the given format seed, while no + /// other modal is up. + fn spawn_new_sequence( + &mut self, + seed: SequenceFormatSeed, + cx: &mut Context, + ) { 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::NewSequenceContent::new(engine, window, cx)); + let content = app.new(|cx| { + crate::dialogs::NewSequenceContent::new_seeded(engine, seed, window, cx) + }); let modal = app.new(|cx| { Modal::new( modal_ids::NEW_SEQUENCE, @@ -2257,6 +2332,92 @@ impl OakApp { }); } + /// A footage drop landed on the timeline while no sequence is open: the + /// shell pauses it and asks whether to probe the footage's parameters + /// as the sequence's, or set them up manually. + fn on_footage_drop_needs_sequence( + &mut self, + drop: FootageDropNeedsSequence, + cx: &mut Context, + ) { + if !matches!(self.modal, ModalState::None) { + // A modal is already up (the drop cannot have been committed + // against it): resume the drop through the probe path, keeping + // the engine's auto-create behaviour. + println!("[shell] drop while a modal is up: using the footage probe params"); + self.engine.update(cx, |engine, cx| { + engine.drop_footage(drop.footage_id, drop.track_kind, drop.track_index, drop.time, cx); + }); + return; + } + self.pending_drop = Some(drop); + let probe = self.engine.read(cx).footage_video_params(drop.footage_id); + self.spawn_modal(cx, move |window, app| { + let content = app.new(|_cx| crate::dialogs::DropSequenceChoiceContent::new(probe)); + let modal = app.new(|cx| { + Modal::new( + modal_ids::DROP_SEQUENCE_CHOICE, + ModalOptions::new(crate::i18n::tr("seqprops.drop.title"), px(440.0)) + .with_button(DialogButton::primary(crate::i18n::tr( + "seqprops.drop.use_footage", + ))) + .with_button(DialogButton::new( + crate::i18n::tr("seqprops.drop.manual"), + gpui_widgets::dialog::DialogButtonRole::Secondary, + )) + .with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::DropSequenceChoice { modal } + }); + } + + /// The drop-pause flow's chosen outcome: resume the paused drop, open + /// the manual setup dialog, or drop nothing (cancel). + fn resolve_drop_sequence_choice( + &mut self, + choice: DropSequenceChoice, + cx: &mut Context, + ) { + let Some(drop) = self.pending_drop.take() else { + self.close_modal(cx); + return; + }; + match choice { + DropSequenceChoice::UseFootageParams => { + // The engine's drop-footage path sizes the new sequence + // from the footage's probed video stream (the NLE + // convention); close the choice and place the clip. + self.engine.update(cx, |engine, cx| { + engine.drop_footage(drop.footage_id, drop.track_kind, drop.track_index, drop.time, cx); + }); + self.close_modal(cx); + } + DropSequenceChoice::SpecifyManually => { + // The manual path re-stashes the drop; the new-sequence + // dialog's OK resumes it through the same commit below. + self.pending_drop = Some(drop); + self.close_modal(cx); + self.open_new_sequence_for_footage(drop, cx); + } + } + } + + /// The paused drop's last chance to place its clip: called after a + /// manual new-sequence commit succeeded (the new sequence is open, so + /// the original floor coordinates still resolve). + fn resume_pending_drop(&mut self, cx: &mut Context) { + let Some(drop) = self.pending_drop.take() else { + return; + }; + self.engine.update(cx, |engine, cx| { + engine.drop_footage(drop.footage_id, drop.track_kind, drop.track_index, drop.time, cx); + }); + } + /// Opens the sequence properties dialog for the given sequence (the /// project explorer's context menu; the C++ `SequencePropertiesDialog`). /// The OK button applies the name / format edits through the content's @@ -2517,13 +2678,40 @@ impl OakApp { // OK: create the sequence; a rejected create // keeps the dialog open with the error shown. match content.update(cx, |dialog, cx| dialog.commit(cx)) { - Ok(()) => self.close_modal(cx), + Ok(()) => { + self.close_modal(cx); + // The manual path of the drop-onto-empty- + // timeline flow resumes the paused drop + // now that a sequence is open. + self.resume_pending_drop(cx); + } Err(err) => { content .update(cx, |dialog, cx| dialog.set_error(Some(err), cx)); } } } else { + // Cancel: any paused drop is abandoned too. + self.pending_drop = None; + self.close_modal(cx); + } + } + } + modal_ids::DROP_SEQUENCE_CHOICE => { + // 0 = handle it from the footage probe, 1 = manual + // dialog, 2 = cancel. + match *button { + 0 => self.resolve_drop_sequence_choice( + DropSequenceChoice::UseFootageParams, + cx, + ), + 1 => self.resolve_drop_sequence_choice( + DropSequenceChoice::SpecifyManually, + cx, + ), + _ => { + // Cancel: abandon the paused drop. + self.pending_drop = None; self.close_modal(cx); } } @@ -2568,6 +2756,12 @@ impl OakApp { // Escape / backdrop close the project properties dialog // without applying (only OK commits). modal_ids::PROJECT_PROPERTIES => self.close_modal(cx), + // Escape from the drop choice (or the manual new-sequence + // dialog it spawned) abandons the paused drop. + modal_ids::DROP_SEQUENCE_CHOICE | modal_ids::NEW_SEQUENCE => { + self.pending_drop = None; + self.close_modal(cx); + } _ => self.close_modal(cx), }, } diff --git a/crates/oak-app/src/dialogs.rs b/crates/oak-app/src/dialogs.rs index a4a323a29..703825f72 100644 --- a/crates/oak-app/src/dialogs.rs +++ b/crates/oak-app/src/dialogs.rs @@ -2956,8 +2956,11 @@ const SEQUENCE_RATES: &[(u32, u32)] = &[ /// The frame-rate labels, in the same order as [`SEQUENCE_RATES`]. const SEQUENCE_RATE_OPTIONS: &[&str] = &["23.98", "24", "25", "29.97", "30", "50", "59.94", "60"]; -/// The preset formats offered for a sequence (0 = custom, which leaves the -/// width / height / frame-rate fields free). +/// The number of preset entries (indices 1..=COUNT; 0 is the custom entry, +/// which leaves the width / height / frame-rate fields free). +const SEQUENCE_PRESET_COUNT: usize = 6; + +/// The preset formats offered for a sequence. fn sequence_preset_options() -> Vec { vec![ ComboBoxOption::new(0, i18n::tr("seqprops.preset.custom")), @@ -2965,6 +2968,8 @@ fn sequence_preset_options() -> Vec { ComboBoxOption::new(2, i18n::tr("seqprops.preset.ntsc")), ComboBoxOption::new(3, i18n::tr("seqprops.preset.hd_1080_25")), ComboBoxOption::new(4, i18n::tr("seqprops.preset.hd_1080_30")), + ComboBoxOption::new(5, i18n::tr("seqprops.preset.uhd_4k")), + ComboBoxOption::new(6, i18n::tr("seqprops.preset.dci_4k")), ] } @@ -2976,6 +2981,8 @@ fn sequence_preset_format(index: usize) -> Option<(u32, u32, u32, u32)> { 2 => Some((720, 480, 30000, 1001)), // NTSC 3 => Some((1920, 1080, 25, 1)), // HD 1080p25 4 => Some((1920, 1080, 30, 1)), // HD 1080p30 + 5 => Some((3840, 2160, 25, 1)), // 4K UHD + 6 => Some((4096, 2160, 24, 1)), // 4K DCI _ => None, } } @@ -3040,6 +3047,49 @@ pub struct SequenceFormatSeed { pub interlaced: bool, } +impl SequenceFormatSeed { + /// The defaults the new-sequence dialog starts from: HD 1080p25. + pub fn hd_1080p25() -> Self { + Self { + preset: 3, + width: 1920, + height: 1080, + rate: Some(2), + interlaced: false, + } + } + + /// Builds a seed matching a source format (a footage's probed video + /// stream, or a sequence's current parameters): the matching preset + /// when the dimensions/rate appear in it, the custom entry otherwise, + /// and the rate pick's exact match when the rate is one of the common + /// options (else the custom default 25 must be selected explicitly — + /// the drop-choice seed follows the probe, so it only seeds what is + /// known). + pub fn from_format(format: &crate::oakui::engine::VideoFormat, interlaced: bool) -> Self { + let preset = (1..=SEQUENCE_PRESET_COUNT) + .find(|i| { + sequence_preset_format(*i) == Some(( + format.width, + format.height, + format.rate.num, + format.rate.den, + )) + }) + .unwrap_or(0); + let rate = SEQUENCE_RATES + .iter() + .position(|r| *r == (format.rate.num, format.rate.den)); + Self { + preset, + width: format.width, + height: format.height, + rate, + interlaced, + } + } +} + /// The format controls shared by the new-sequence and sequence-properties /// dialogs: a preset combo that fills the numeric fields, width / height /// spin boxes, a frame-rate combo and an interlaced checkbox. Picking a @@ -3257,6 +3307,18 @@ impl NewSequenceContent { /// Builds the content seeded with the default name and the HD 1080p25 /// preset. pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> Self { + Self::new_seeded(engine, SequenceFormatSeed::hd_1080p25(), window, cx) + } + + /// Builds the content seeded with the default name and `seed`'s format + /// (the footage-probe seed when a drop created the dialog, the HD + /// 1080p25 defaults otherwise). + pub fn new_seeded( + engine: Entity, + seed: SequenceFormatSeed, + window: &mut Window, + cx: &mut Context, + ) -> Self { let name = cx.new(|cx| { let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); TextValue { editor } @@ -3265,19 +3327,7 @@ impl NewSequenceContent { field.set_value(i18n::tr("seqprops.default_name"), cx) }); - let format = cx.new(|cx| { - SequenceFormatFields::build( - SequenceFormatSeed { - preset: 3, - width: 1920, - height: 1080, - rate: Some(2), - interlaced: false, - }, - window, - cx, - ) - }); + let format = cx.new(|cx| SequenceFormatFields::build(seed, window, cx)); Self { engine, @@ -3389,7 +3439,7 @@ impl SequencePropertiesContent { let seed = match ¤t { Some(params) => { - let preset = (1..=4) + let preset = (1..=SEQUENCE_PRESET_COUNT) .find(|i| { sequence_preset_format(*i) == Some(( @@ -3506,6 +3556,80 @@ impl Render for SequencePropertiesContent } } +/// The outcome of the drop-onto-empty-timeline choice (拖到空时间轴上的素材 +/// 需要先创建一个序列:探测素材参数作为序列参数,还是手工指定参数)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DropSequenceChoice { + /// Create the sequence from the footage's probed video parameters + /// (the previous auto-create behaviour; the engine's `drop_footage` + /// sizes the new sequence from the probe). + UseFootageParams, + /// Open the new-sequence dialog (seeded with the probed parameters) + /// and create the sequence with the user's entered settings. + SpecifyManually, +} + +/// The content of the drop-onto-empty-timeline choice dialog: the +/// footage's probed video parameters (or a note when the footage is +/// pure audio). The host routes the modal's buttons to +/// [`DropSequenceChoice`]. +pub struct DropSequenceChoiceContent { + /// The footage's first probed video stream (`width, height, rate, + /// interlaced`), when the footage carries one. + probed: Option<(u32, u32, gpui::timeline::FrameRate, bool)>, +} + +impl DropSequenceChoiceContent { + /// Builds the content seeded with the footage's probed video stream. + pub fn new(probed: Option<(u32, u32, gpui::timeline::FrameRate, bool)>) -> Self { + Self { probed } + } + + /// The footage's probed parameters shown in the body. + pub fn probed(&self) -> Option<(u32, u32, gpui::timeline::FrameRate, bool)> { + self.probed + } +} + +impl Render for DropSequenceChoiceContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let body = match self.probed { + Some((width, height, rate, interlaced)) => { + let base = format!( + "{} × {}, {}", + width, + height, + crate::oakui::timecode::format_fps(rate), + ); + if interlaced { + format!("{base} · {}", i18n::tr("seqprops.interlaced")) + } else { + base + } + } + None => i18n::tr("seqprops.drop.no_video").to_string(), + }; + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child( + div() + .text_color(colors.disabled) + .child(i18n::tr("seqprops.drop.caption")), + ) + .child( + div() + .debug_selector(|| "drop-choice-params".into()) + .text_color(colors.text) + .text_sm() + .child(body), + ) + } +} + #[cfg(test)] mod tests { use super::*; @@ -3514,6 +3638,78 @@ mod tests { gpui::Keystroke::parse(key).unwrap() } + /// The 4K presets are in the dropdown and resolve to their formats + /// (4K UHD 3840×2160@25, 4K DCI 4096×2160@24). + #[test] + fn four_k_presets_are_offered_and_resolve() { + let options = sequence_preset_options(); + let labels: Vec<&str> = options.iter().map(|o| o.label.as_ref()).collect(); + assert_eq!(options.len(), 7); + assert_eq!(sequence_preset_format(5), Some((3840, 2160, 25, 1))); + assert_eq!(sequence_preset_format(6), Some((4096, 2160, 24, 1))); + assert_eq!(sequence_preset_format(7), None); + // The preset labels come from the language packs (default English + // here): the 4K entries say what they set. + assert!(labels[5].contains("3840×2160"), "4K UHD label: {}", labels[5]); + assert!(labels[6].contains("4096×2160"), "4K DCI label: {}", labels[6]); + } + + /// A probed footage format seeds the new-sequence fields: an exact + /// preset match selects the preset, otherwise the custom entry with the + /// probed dimensions/rate already filled in. + #[test] + fn seed_from_format_matches_presets_and_falls_back_to_custom() { + let hd = crate::oakui::engine::VideoFormat { + width: 1920, + height: 1080, + rate: FrameRate::new(25, 1), + }; + let seed = SequenceFormatSeed::from_format(&hd, false); + assert_eq!(seed.preset, 3); + assert_eq!((seed.width, seed.height), (1920, 1080)); + assert_eq!(seed.rate, Some(2)); + + let custom = crate::oakui::engine::VideoFormat { + width: 512, + height: 512, + rate: FrameRate::new(1234, 100), + }; + let seed = SequenceFormatSeed::from_format(&custom, true); + assert_eq!(seed.preset, 0); + assert_eq!((seed.width, seed.height), (512, 512)); + assert_eq!(seed.rate, None); + assert!(seed.interlaced); + + let dci = crate::oakui::engine::VideoFormat { + width: 4096, + height: 2160, + rate: FrameRate::new(24, 1), + }; + let seed = SequenceFormatSeed::from_format(&dci, false); + assert_eq!(seed.preset, 6, "4K DCI resolves to its preset"); + } + + /// The sequence properties dialog seeds the preset dropdown correctly for + /// every preset value, so reopening properties on a 4K sequence keeps + /// 4K selected (not "custom"). + #[test] + fn properties_seed_matches_all_presets_including_4k() { + for index in 1..=SEQUENCE_PRESET_COUNT { + let (w, h, num, den) = sequence_preset_format(index).unwrap(); + let params = crate::oakui::engine::SequenceParameters { + name: "4K".into(), + format: crate::oakui::engine::VideoFormat { + width: w, + height: h, + rate: FrameRate::new(num, den), + }, + interlaced: false, + }; + let seed = SequenceFormatSeed::from_format(¶ms.format, params.interlaced); + assert_eq!(seed.preset, index, "preset {index} reselects itself"); + } + } + #[test] fn capture_decision_handles_all_shapes() { assert!(matches!( diff --git a/crates/oak-app/src/oakui/component/controls.rs b/crates/oak-app/src/oakui/component/controls.rs index fb46fddc5..b22a1ad1d 100644 --- a/crates/oak-app/src/oakui/component/controls.rs +++ b/crates/oak-app/src/oakui/component/controls.rs @@ -614,8 +614,10 @@ impl CheckBox { } } -/// The checkbox's fill: the theme selection colour when checked, white when -/// not (the user wants the unchecked box to stand out as a white well). +/// The checkbox's fill: the theme selection colour when checked, opaque +/// white when not (the design's unchecked box is a white well with a +/// black border — theme-independent, so it reads the same on the dark +/// panel where the theme border is a light blue). /// Kept as a pure function so the unit test below pins the colours without /// needing pixel reads. fn checkbox_fill(colors: &gpui::colors::Colors, checked: bool) -> gpui::Rgba { diff --git a/crates/oak-app/src/oakui/engine.rs b/crates/oak-app/src/oakui/engine.rs index 97f1cdd60..2961a531c 100644 --- a/crates/oak-app/src/oakui/engine.rs +++ b/crates/oak-app/src/oakui/engine.rs @@ -767,6 +767,15 @@ pub trait AppEngine: None } + /// The footage's first probed video stream as `(width, height, rate, + /// interlaced)` — the "use footage params" path of a footage drop + /// onto an empty timeline (what the auto-created sequence would pick). + /// `None` when the entry is not footage or has no probed video stream + /// (pure-audio footage falls back to the default sequence format). + fn footage_video_params(&self, _id: u64) -> Option<(u32, u32, FrameRate, bool)> { + None + } + /// Starts an export of the current sequence in `format` to `path` and /// returns a session the host polls for progress and can cancel. /// diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index 89323ec9f..e33e4108d 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -3134,6 +3134,17 @@ impl RealEngine { }, length, }); + // The worker pool's size follows the sequence resolution: the GPU + // vram budget per worker grows with the pixel count (an NVDEC 4K + // decoder needs ~4× the 1080p surface memory), so a 1080p pool of + // 20+ workers exhausts a consumer GPU the moment the sequence + // switches to 4K and every decoder open starts OOMing. Resize the + // pool to what fits now; the size change on the OTHER sequence's + // update re-expands it. + if let Some(m) = oak_render::manager::RenderManager::global() { + let fps = (rate.num as f64 / rate.den.max(1) as f64).round().max(1.0) as u32; + m.set_workspace_size(width as i32, height as i32, fps); + } // Stage 6b: keep the OFX normalised-coordinate default conversion in // sync with the current sequence's extent. crate::oakui::ofx::update_project_extent(width as f64, height as f64); @@ -5044,6 +5055,22 @@ impl AppEngine for RealEngine { Some((seconds * fps_f).round().max(1.0) as i64) } + fn footage_video_params(&self, id: u64) -> Option<(u32, u32, FrameRate, bool)> { + let project = self.project_ref()?; + let node = graphops::id_of(id)?; + let guard = graphops::lock(project); + let vp = graphops::footage_behavior(&guard.graph, node)?.video_params(0)?; + Some(( + vp.width.max(1) as u32, + vp.height.max(1) as u32, + FrameRate::new( + vp.frame_rate.numerator().max(1) as u32, + vp.frame_rate.denominator().max(1) as u32, + ), + vp.interlaced, + )) + } + // --- project library (M13 D4) -------------------------------------- fn storage_bound(&self) -> bool { @@ -9159,4 +9186,61 @@ mod tests { let _ = std::fs::remove_file(&media); } + + /// The drop-choice flow reads the footage's probed video stream through + /// `footage_video_params`: the same values the auto-create path picks, + /// so the "use footage params" prompt offers what the sequence would + /// get. + #[gpui::test] + async fn footage_video_params_returns_the_probed_stream(cx: &mut gpui::TestAppContext) { + let _media = media_lock(); + let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); + cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); + + let media = std::env::temp_dir().join(format!("oak_probe_params_{}.mp4", std::process::id())); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate"); + cx.update(|app| { + engine + .update(app, |engine, cx| engine.import_footage(media.clone(), cx)) + .expect("import") + }); + let name = media.file_name().unwrap().to_string_lossy().into_owned(); + let entry = cx + .read(|app| { + engine + .read(app) + .roots() + .into_iter() + .find(|e| e.name.as_ref() == name) + }) + .expect("imported footage is listed"); + + let (width, height, rate_num, rate_den) = + cx.read(|app| { + let result: Option<(u32, u32, u32, u32)> = engine + .read(app) + .footage_video_params(entry.id) + .map(|(w, h, rate, _)| (w, h, rate.num, rate.den)); + result.expect("probed video stream") + }); + // The values match the probe (not hard-coded). + let expected = { + let project = cx.read(|app| engine.read(app).project.clone().expect("project")); + let guard = graphops::lock(&project); + let node = graphops::id_of(entry.id).expect("footage node"); + let vp = graphops::footage_behavior(&guard.graph, node) + .and_then(|f| f.video_params(0)) + .expect("a video stream"); + ( + vp.width.max(1) as u32, + vp.height.max(1) as u32, + vp.frame_rate.numerator().max(1) as u32, + vp.frame_rate.denominator().max(1) as u32, + ) + }; + assert_eq!((width, height), (expected.0, expected.1)); + assert_eq!((rate_num, rate_den), (expected.2, expected.3)); + + let _ = std::fs::remove_file(&media); + } } diff --git a/crates/oak-app/src/oakui/renderops.rs b/crates/oak-app/src/oakui/renderops.rs index e8de19d98..9d0caf0bb 100644 --- a/crates/oak-app/src/oakui/renderops.rs +++ b/crates/oak-app/src/oakui/renderops.rs @@ -1086,6 +1086,57 @@ mod tests { (project, seq, footage) } + /// The Chroma Key effect's boolean inputs read as `Boolean(false)` + /// through the inspector's parameter path (`effect_params`), so the + /// OfxParamsView checkboxes start UNCHECKED (white fill, black border + /// in the app's `CheckBox`). Regression guard for the "Invert Mask 复 + /// 选框是水蓝色" report: if the boolean value ever reads as + /// `Boolean(true)` (or anything else that maps to `Checked`) the + /// unchecked boxes would render with the blue accent fill. + #[test] + fn chromakey_boolean_params_start_unchecked() { + let _media = media_lock(); + let project = graphops::create_project(); + let clip = { + let mut g = graphops::lock(&project); + let (core, behavior) = oak_node::block::clip_create(); + g.graph.add_node(core, behavior) + }; + let fx = crate::oakui::effectchain::insert( + &project, + clip, + usize::MAX, + "org.olivevideoeditor.Olive.chromakey", + ) + .expect("insert the chromakey effect"); + + let params = { + let g = graphops::lock(&project); + crate::oakui::effectchain::effect_params(&g.graph, fx).expect("params") + }; + let invert = params + .iter() + .find(|p| p.input_id == "invert_in") + .expect("invert_in param"); + let mask_only = params + .iter() + .find(|p| p.input_id == "mask_only_in") + .expect("mask_only_in param"); + // Exactly `Boolean(false)` (NOT None / Int / anything else): a + // different shape would still map to Unchecked, but a `true` + // would make the checkbox render as the blue accent fill. + assert_eq!( + invert.value, + oak_node::value::NodeValue::Boolean(false), + "invert_in must start false" + ); + assert_eq!( + mask_only.value, + oak_node::value::NodeValue::Boolean(false), + "mask_only_in must start false" + ); + } + #[test] fn video_montage_covers_the_clip_range() { let _media = media_lock(); diff --git a/crates/oak-app/src/panels/ofx_params.rs b/crates/oak-app/src/panels/ofx_params.rs index 293558fa6..64a188699 100644 --- a/crates/oak-app/src/panels/ofx_params.rs +++ b/crates/oak-app/src/panels/ofx_params.rs @@ -1460,7 +1460,8 @@ impl OfxColorPicker { b }, move |b, _content, window, cx| paint_sv_palette(b, hue, window, cx), - ); + ) + .size_full(); let sv_panel = div() .id(ElementId::named_usize("ofx-color-sv-palette", control)) .debug_selector(|| "ofx-color-sv-palette".into()) @@ -1515,7 +1516,8 @@ impl OfxColorPicker { b }, |b, _content, window, cx| paint_hue_bar(b, window, cx), - ); + ) + .size_full(); let hue_bar = div() .id(ElementId::named_usize("ofx-color-hue-bar", control)) .debug_selector(|| "ofx-color-hue-bar".into()) @@ -1591,7 +1593,8 @@ impl OfxColorPicker { move |bounds, _content, window, cx| { paint_checker_swatch(bounds, draft, window, cx); }, - ); + ) + .size_full(); let preview = div() .w(px(36.0)) .h(px(24.0)) @@ -1780,7 +1783,8 @@ impl Render for OfxColorPicker { move |bounds, _content, window, cx| { paint_checker_swatch(bounds, swatch_color, window, cx); }, - )); + ) + .size_full()); let popup = if self.open { self.popup_anchored(cx, &colors) @@ -2112,6 +2116,81 @@ mod tests { ); } + /// The palette (SV square) mapping works from inside the popup: the + /// canvas records its layout bounds each frame and the click maps them + /// to (s, v). Regression test for the bare canvases — without + /// `.size_full()` the canvas leaf collapses to zero height in the + /// block layout, the recorded bounds are 0 tall, and a click at the + /// palette centre maps to v ≈ 0 (black) instead of v ≈ 0.5: the popup + /// opens but shows/behaves as an empty box (the reported "色板没显示 + /// 出来"). + #[gpui::test] + async fn palette_click_maps_center_to_mid_saturation_value(cx: &mut TestAppContext) { + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(320.0), px(560.0)), |window, cx| { + OfxColorPicker::new( + 1, + Rgba { + r: 0.4, + g: 0.2, + b: 0.8, + a: 0.5, + }, + window, + cx, + ) + }); + cx.run_until_parked(); + + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx).into_mut(); + visual.update(|window, cx| { + window.draw(cx).clear(); + }); + + // Open the popup by clicking the swatch, then re-draw so the popup's + // deferred layer is laid out (and the palette canvas records its + // bounds for the click mapping). + let swatch = visual.debug_bounds("ofx-color-swatch").expect("swatch painted"); + let swatch_center = Point::new( + swatch.origin.x + swatch.size.width * 0.5, + swatch.origin.y + swatch.size.height * 0.5, + ); + visual.simulate_click(swatch_center, Modifiers::default()); + visual.update(|window, cx| { + window.draw(cx).clear(); + }); + + // Click the centre of the SV palette. + let sv = visual + .debug_bounds("ofx-color-sv-palette") + .expect("sv palette bounds"); + assert!( + f32::from(sv.size.height) >= 170.0, + "sv palette height collapsed to {}", + f32::from(sv.size.height) + ); + let sv_center = Point::new( + sv.origin.x + sv.size.width * 0.5, + sv.origin.y + sv.size.height * 0.5, + ); + visual.simulate_click(sv_center, Modifiers::default()); + cx.run_until_parked(); + + // The centre of the palette maps to ~(0.5, 0.5); the hue bar still + // shows the initial colour's hue is kept (reading the draft's s/v). + let picker = window.root(cx).expect("picker root"); + let draft = cx.read(|cx| picker.read(cx).draft); + let (_, s, v) = rgb_to_hsv(draft.r, draft.g, draft.b); + assert!( + (s - 0.5).abs() < 0.05, + "centre click maps to mid saturation (got {s})" + ); + assert!( + (v - 0.5).abs() < 0.05, + "centre click maps to mid value, not the collapsed-canvas 0 (got {v})" + ); + } + /// The "pick from viewer" button toggles the eyedropper and emits /// `PickViewerToggle` events that the params view routes to the engine. #[gpui::test] diff --git a/crates/oak-app/src/panels/project_explorer.rs b/crates/oak-app/src/panels/project_explorer.rs index f78d60935..14fb07b62 100644 --- a/crates/oak-app/src/panels/project_explorer.rs +++ b/crates/oak-app/src/panels/project_explorer.rs @@ -22,11 +22,12 @@ use std::process::Command; use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::{ - div, px, prelude::*, AnyElement, App, Context, Entity, EventEmitter, MouseButton, + div, px, prelude::*, AnyElement, App, ClickEvent, Context, Entity, EventEmitter, MouseButton, PathPromptOptions, Pixels, Point, Render, SharedString, Window, }; use crate::oakui::component::menu::{Menu, MenuItem}; use gpui_widgets::project_explorer::{ProjectExplorer, ProjectExplorerEvent}; +use gpui_widgets::tooltip::tooltip_view; use crate::actions::ActionId; use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered}; @@ -209,6 +210,44 @@ impl PanelCommandHandler for ProjectExplorerPanel {} impl Render for ProjectExplorerPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); + + // The panel title row, per the design's panel headers: the + // widget below only shows the bare tree/icon view toggles, so + // without this row the panel reads as anonymous. The trailing 新建 + // 序列 button mirrors the blank-area context menu's new-sequence + // item (and the File > New > Sequence… action). + let new_sequence_button = div() + .id("project-new-sequence") + .debug_selector(|| "project-new-sequence".into()) + .px_2() + .py_0p5() + .rounded_sm() + .flex() + .items_center() + .cursor_pointer() + .text_color(colors.text) + .text_xs() + .hover(|style| style.bg(colors.selected)) + .tooltip(move |window, cx| { + tooltip_view(crate::i18n::tr("project.new_sequence").into(), window, cx) + }) + .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { + cx.emit(NewSequenceRequested); + })) + .child(crate::i18n::tr("project.new_sequence")); + let header = div() + .flex() + .items_center() + .h(px(28.0)) + .flex_shrink_0() + .px_2() + .border_b_1() + .border_color(colors.border) + .bg(colors.container) + .text_sm() + .text_color(colors.text) + .child(div().flex_1().child(crate::i18n::tr("panel.project"))) + .child(new_sequence_button); div() .size_full() .flex() @@ -221,23 +260,7 @@ impl Render for ProjectExplorerPanel { cx.emit(PanelEvent::Focused); }) }) - // The panel title row, per the design's panel headers: the - // widget below only shows the bare tree/icon view toggles, so - // without this row the panel reads as anonymous. - .child( - div() - .flex() - .items_center() - .h(px(28.0)) - .flex_shrink_0() - .px_2() - .border_b_1() - .border_color(colors.border) - .bg(colors.container) - .text_sm() - .text_color(colors.text) - .child(crate::i18n::tr("panel.project")), - ) + .child(header) .child( div() .flex_1() @@ -260,6 +283,12 @@ pub struct SequencePropertiesRequested(pub u64); impl EventEmitter for ProjectExplorerPanel {} +/// The project explorer asked the shell to open the new-sequence dialog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NewSequenceRequested; + +impl EventEmitter for ProjectExplorerPanel {} + impl DockPanel for ProjectExplorerPanel { fn panel_id(&self) -> gpui::dock::PanelId { PROJECT diff --git a/crates/oak-app/src/panels/timeline.rs b/crates/oak-app/src/panels/timeline.rs index fc97033db..fcd42ddae 100644 --- a/crates/oak-app/src/panels/timeline.rs +++ b/crates/oak-app/src/panels/timeline.rs @@ -407,6 +407,12 @@ impl TimelinePanel { /// Applies a finished footage drop: routes the payload's footage id with /// the last hovered track + frame to the engine, which resolves the /// footage, validates the track and places the clip (undoable). + /// + /// A drop onto a timeline with no open sequence cannot place a clip: + /// the panel hands the pending drop back to the shell through + /// [`FootageDropNeedsSequence`], which shows the probe-vs-manual + /// choice (the shell re-routes the drop through [`AppEngine::drop_footage`] + /// once a sequence exists). fn finish_footage_drop(&mut self, drag: &FootageDrag, cx: &mut Context) { let Some(target) = self.footage_drop.take() else { return; @@ -417,6 +423,16 @@ impl TimelinePanel { time, .. } = target; + if self.engine.read(cx).current_sequence().is_none() { + cx.emit(FootageDropNeedsSequence { + footage_id: drag.0, + track_kind, + track_index, + time, + }); + cx.notify(); + return; + } self.engine.update(cx, |engine, cx| { engine.drop_footage(drag.0, track_kind, track_index, time, cx); }); @@ -1025,6 +1041,26 @@ impl EventEmitter for TimelinePanel {} impl EventEmitter for TimelinePanel {} +/// A footage drop landed onto the timeline while no sequence is open: the +/// shell must set up a sequence first (probe the footage's params as the +/// sequence's, or open the manual setup dialog) before the clip can be +/// placed. Re-issued by the shell through +/// [`AppEngine::drop_footage`](crate::oakui::AppEngine::drop_footage) once +/// the sequence exists. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FootageDropNeedsSequence { + /// The footage's project-explorer entry id (the drag payload). + pub footage_id: u64, + /// The pointed display track kind. + pub track_kind: TrackKind, + /// The pointed display track index. + pub track_index: usize, + /// The start frame at the pointer. + pub time: Frame, +} + +impl EventEmitter for TimelinePanel {} + impl DockPanel for TimelinePanel { fn panel_id(&self) -> gpui::dock::PanelId { TIMELINE diff --git a/crates/oak-node/src/nodes/chromakey.rs b/crates/oak-node/src/nodes/chromakey.rs index 229c54cca..cac6c1a4e 100644 --- a/crates/oak-node/src/nodes/chromakey.rs +++ b/crates/oak-node/src/nodes/chromakey.rs @@ -19,6 +19,7 @@ use crate::factory::NodeMeta; use crate::node::{Category, NodeBehavior, NodeCore}; +use crate::nodes::jobs::ShaderJobPayload; /// Key color input id (C++ `k_color_input`). Type: color; default /// `Color(0.0, 1.0, 0.0, 1.0)` (opaque green). @@ -64,14 +65,12 @@ pub const HIGHLIGHTS_INPUT: &str = "highlights_in"; /// The C++ class derives from `OCIOBaseNode`, which owns the `tex_in` /// texture input (C++ `OCIOBaseNode::k_texture_input = "tex_in"`, the /// effect input), the color manager pointer, and the OCIO color -/// processor handle; that state is held here via the shared -/// `crate::nodes::ociobase` helper. The class has no other own members (the -/// private `generate_processor()` is a method, not state). -pub struct ChromaKeyNode { - /// OCIO base state (C++ base class `OCIOBaseNode`: `manager_` and - /// `processor_`). - base: crate::nodes::ociobase::OcioBase, -} +/// processor handle. In the Rust model the OCIO shader stub is +/// generated by the renderer at compile time (the `%1` marker is a +/// type-id lookup in oakrender's OCIO shader-stub table), so the node +/// needs no processor state: it behaves like the other keying nodes +/// and pushes a plain shader job payload from `value()`. +pub struct ChromaKeyNode; /// Fragment shader (C++ loads the `:/shaders/chromakey.frag` resource /// in `get_shader_code`). Text copied verbatim from @@ -205,22 +204,6 @@ impl ChromaKeyNode { fn shader_frag() -> &'static str { SHADER_FRAG } - - /// (Re)build the OCIO color processor (C++ `generate_processor()`): - /// converts to the `cie_xyz_d65_interchange` output color space via - /// `oakrender_color_processor_create_transform` and stores the result - /// with [`OcioBase::set_processor`] when creation succeeds. - fn generate_processor(&mut self, _core: &mut NodeCore) { - // The C++ wraps the color manager, builds a transform to the - // "cie_xyz_d65_interchange" output color space and creates the - // processor through `oakrender_color_processor_create_transform`, - // storing it with OcioBase::set_processor when `processor.ctx` is - // non-null. Without a manager (the Rust model reaches the manager - // through the oakrender bridge, absent here) the C++ guard - // `if (manager())` fails, so this is a no-op and the processor - // stays empty — `value()` then pushes nothing. - // `// CPP-PARITY: chromakey.cpp` generate_processor. - } } impl NodeBehavior for ChromaKeyNode { @@ -267,30 +250,33 @@ impl NodeBehavior for ChromaKeyNode { } } - /// Input value changed (C++ `InputValueChangedEvent`): the lower - /// tolerance branch that would update the upper tolerance's `min` - /// property is disabled in C++ (FIXME); unconditionally - /// regenerates the OCIO color processor - /// (`generate_processor()`). - fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) { + /// Input value changed (C++ `InputValueChangedEvent`): the C++ runs + /// the lower-tolerance branch (commented out upstream, FIXME) and + /// regenerates the OCIO processor. This node has no processor state + /// left (the renderer resolves the OCIO stub at compile time), so + /// nothing to do here. + fn input_value_changed(&mut self, _core: &mut NodeCore, input: &str, element: i32) { let _ = (input, element); - // The C++ lower-tolerance branch that mirrors the lower tolerance - // into the upper tolerance's `min` property is commented out - // (FIXME: breaks when the lower tolerance is keyframed/connected), - // so only the processor regeneration remains. - self.generate_processor(core); } /// Evaluate outputs (C++ `value()`): no texture on `tex_in` -> - /// push nothing; texture present and a valid OCIO processor -> - /// push a `ColorTransformJob` wired with the processor, the input - /// texture, this node as the custom-shader provider, and the - /// function name `SceneLinearToCIEXYZ_d65`. + /// push nothing; texture present -> push a `ShaderJob` over the + /// whole input row. /// - /// The C++ class also overrides `config_changed()` (pure virtual - /// on `OCIOBaseNode`) to regenerate the processor when the OCIO - /// config changes; `NodeBehavior` has no equivalent hook — that - /// wiring belongs to the facade/event layer. + /// The C++ gates the job on a valid OCIO processor (a + /// `ColorTransformJob` carrying the processor handle — the + /// `%1` marker is filled with the processor's generated shader + /// code). The Rust model has no processor bridge; the renderer + /// resolves the OCIO stub itself: `oak_render::eval` looks the + /// node's type id up in its OCIO shader-stub table and generates + /// `SceneLinearToCIEXYZ_d65` at compile time, so the node pushes + /// the plain job payload exactly like the other keying nodes + /// (despill / color-difference key). The old gate — a null handle + /// when `processor().is_some()` and *nothing* otherwise — left the + /// node's output table empty (the processor is never populated + /// without the bridge), so the traverser handed the downstream + /// clip `NodeValue::None` and the rendered frame lost the whole + /// clip. fn value( &self, core: &NodeCore, @@ -298,29 +284,24 @@ impl NodeBehavior for ChromaKeyNode { time: oak_core::Rational, table: &mut crate::value::NodeValueTable, ) { - let _ = (core, time); match inputs.get(crate::nodes::ociobase::TEXTURE_INPUT) { - Some(crate::value::NodeValue::Texture(_)) => { - if self.base.processor().is_some() { - // `// CPP-PARITY: chromakey.cpp` `value()` — the C++ - // builds a ColorTransformJob with the processor, the - // input texture, this node as the custom-shader - // provider and the function name - // `SceneLinearToCIEXYZ_d65`. The Rust model has no - // color-transform job payload: the renderer seam - // resolves the deferred job from this null handle. - table.push( - crate::value::ValueType::Texture, - crate::value::NodeValue::Texture(crate::handle::CHandle::null()), - None, - ); - } - // Texture present but no processor: the C++ pushes - // nothing (unlike the base class, which would pass the - // texture through). - } - _ => {} + Some(crate::value::NodeValue::Texture(_)) => {} + _ => return, } + table.push( + crate::value::ValueType::Texture, + crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload { + node_id: crate::id::NodeId::INVALID, + time, + iterations: 1, + type_id: self.type_id().to_string(), + shader_id: String::new(), + effect_input: core.effect_input.clone(), + params: inputs.clone(), + iterative_input: String::new(), + })), + None, + ); } /// Shader code request (C++ `get_shader_code()`): reads the @@ -345,12 +326,7 @@ impl NodeBehavior for ChromaKeyNode { /// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`). fn duplicate(&self, _core: &NodeCore) -> Option> { - // The C++ copy constructor copies the embedded OCIO base state; - // a fresh base with no processor is the safe Rust port (the - // processor is never populated without the render bridge). - Some(Box::new(ChromaKeyNode { - base: crate::nodes::ociobase::OcioBase::new(), - })) + Some(Box::new(ChromaKeyNode)) } } @@ -449,12 +425,7 @@ pub fn create() -> (NodeCore, Box) { crate::value::NodeValue::Boolean(false), )); - ( - core, - Box::new(ChromaKeyNode { - base: crate::nodes::ociobase::OcioBase::new(), - }), - ) + (core, Box::new(ChromaKeyNode)) } /// Register this node type (C++ factory entry for @@ -476,9 +447,7 @@ mod tests { #[test] fn input_names() { - let n = ChromaKeyNode { - base: crate::nodes::ociobase::OcioBase::new(), - }; + let n = ChromaKeyNode; assert_eq!(n.input_name(crate::nodes::ociobase::TEXTURE_INPUT), "Input"); assert_eq!(n.input_name(GARBAGE_MATTE_INPUT), "Garbage Matte"); assert_eq!(n.input_name(CORE_MATTE_INPUT), "Core Matte"); @@ -542,9 +511,7 @@ mod tests { #[test] fn shader_code_replaces_stub_marker() { - let n = ChromaKeyNode { - base: crate::nodes::ociobase::OcioBase::new(), - }; + let n = ChromaKeyNode; let stub = "float SceneLinearToCIEXYZ_d65(vec4 c){ return 0.0; }"; let code = n.shader_code(stub).unwrap(); assert!(!code.contains("%1")); @@ -553,9 +520,7 @@ mod tests { #[test] fn legacy_input_ids_remap_misspellings() { - let n = ChromaKeyNode { - base: crate::nodes::ociobase::OcioBase::new(), - }; + let n = ChromaKeyNode; assert_eq!( n.map_legacy_input_id("upper_tolerence_in"), UPPER_TOLERANCE_INPUT @@ -584,34 +549,33 @@ mod tests { } #[test] - fn value_texture_without_processor_pushes_nothing() { - // Unlike the OCIO base, chroma key has no pass-through branch: - // without a processor the C++ pushes nothing. + fn value_texture_pushes_shader_job_payload() { let (core, behavior) = create(); - let inputs = crate::value::NodeValueRow::from([( - crate::nodes::ociobase::TEXTURE_INPUT.to_string(), - NodeValue::Texture(crate::handle::CHandle::null()), - )]); + // The payload carries the whole input row (the renderer packs the + // uniforms from it), with the full node identity + effect input. + let inputs = crate::value::NodeValueRow::from([ + ( + crate::nodes::ociobase::TEXTURE_INPUT.to_string(), + NodeValue::Texture(crate::handle::CHandle::null()), + ), + (COLOR_INPUT.to_string(), NodeValue::Color([1.0, 0.0, 0.0, 1.0])), + ]); let mut table = NodeValueTable::default(); behavior.value(&core, &inputs, Rational::new(0, 1), &mut table); - assert!(table.is_empty()); - } - - #[test] - fn value_texture_with_processor_pushes_deferred_job() { - let core = NodeCore::new(); - let mut node = ChromaKeyNode { - base: crate::nodes::ociobase::OcioBase::new(), + let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else { + panic!("expected a texture-typed value"); }; - node.base - .set_processor(Some(crate::handle::CHandle::null())); - let inputs = crate::value::NodeValueRow::from([( - crate::nodes::ociobase::TEXTURE_INPUT.to_string(), - NodeValue::Texture(crate::handle::CHandle::null()), - )]); - let mut table = NodeValueTable::default(); - node.value(&core, &inputs, Rational::new(0, 1), &mut table); - assert!(table.get(ValueType::Texture).is_some()); + let payload = + unsafe { crate::handle::get_checked::(handle) } + .expect("payload boxed behind the handle"); + assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.chromakey"); + assert_eq!(payload.shader_id, ""); + assert_eq!(payload.iterations, 1); + assert_eq!(payload.effect_input, crate::nodes::ociobase::TEXTURE_INPUT); + // The whole input row rides along, so the renderer can pack the + // Key Color + tolerances uniforms from it. + assert_eq!(payload.params.get(COLOR_INPUT), Some(&NodeValue::Color([1.0, 0.0, 0.0, 1.0]))); + assert!(payload.params.contains_key(crate::nodes::ociobase::TEXTURE_INPUT)); } #[test] diff --git a/crates/oak-node/src/nodes/colordifferencekey.rs b/crates/oak-node/src/nodes/colordifferencekey.rs index 3acc481d1..724f27d36 100644 --- a/crates/oak-node/src/nodes/colordifferencekey.rs +++ b/crates/oak-node/src/nodes/colordifferencekey.rs @@ -20,6 +20,7 @@ use crate::factory::NodeMeta; use crate::node::{Category, NodeBehavior, NodeCore}; +use crate::nodes::jobs::ShaderJobPayload; /// Texture input id (C++ `k_texture_input`). Type: texture; flags: /// not-keyframable; this is the node's effect input. @@ -186,6 +187,13 @@ impl NodeBehavior for ColorDifferenceKeyNode { /// Evaluate outputs (C++ `value()`): no texture on `tex_in` -> /// push nothing; texture present -> push a `ShaderJob` with the /// whole input row inserted. + /// + /// The job is boxed here as a [`ShaderJobPayload`] that the + /// renderer's resolve hook executes and replaces with the result + /// texture (exactly like despill). The previous null-handle + /// "deferred job" marker was never resolved by the Rust renderer, + /// so the node's output table handed the downstream clip a null + /// texture and the rendered frame lost the whole clip. fn value( &self, core: &NodeCore, @@ -193,21 +201,22 @@ impl NodeBehavior for ColorDifferenceKeyNode { time: oak_core::Rational, table: &mut crate::value::NodeValueTable, ) { - let _ = (core, time); match inputs.get(TEXTURE_INPUT) { Some(crate::value::NodeValue::Texture(_)) => {} _ => return, } - - // `// CPP-PARITY: colordifferencekey.cpp` `value()` — the C++ - // builds a ShaderJob over the whole input row and pushes - // `tex->to_job(job)`. The Rust model has no shader-job payload: - // the renderer seam resolves the deferred job (and the - // `garbage_in_enabled`/`core_in_enabled` uniforms derived from - // input presence) from this null handle. table.push( crate::value::ValueType::Texture, - crate::value::NodeValue::Texture(crate::handle::CHandle::null()), + crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload { + node_id: crate::id::NodeId::INVALID, + time, + iterations: 1, + type_id: self.type_id().to_string(), + shader_id: String::new(), + effect_input: core.effect_input.clone(), + params: inputs.clone(), + iterative_input: String::new(), + })), None, ); } @@ -368,7 +377,7 @@ mod tests { } #[test] - fn value_with_texture_pushes_deferred_shader_job() { + fn value_with_texture_pushes_shader_job_payload() { let (core, behavior) = create(); let inputs = crate::value::NodeValueRow::from([( TEXTURE_INPUT.to_string(), @@ -376,7 +385,16 @@ mod tests { )]); let mut table = NodeValueTable::default(); behavior.value(&core, &inputs, Rational::new(0, 1), &mut table); - assert!(table.get(ValueType::Texture).is_some()); + let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else { + panic!("expected a texture-typed value"); + }; + let payload = + unsafe { crate::handle::get_checked::(handle) } + .expect("payload boxed behind the handle"); + assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.colordifferencekey"); + assert_eq!(payload.iterations, 1); + assert_eq!(payload.effect_input, TEXTURE_INPUT); + assert!(payload.params.contains_key(TEXTURE_INPUT)); } #[test] diff --git a/crates/oak-render/tests/graph_render.rs b/crates/oak-render/tests/graph_render.rs index 5ee134f6e..ceb3d12bc 100644 --- a/crates/oak-render/tests/graph_render.rs +++ b/crates/oak-render/tests/graph_render.rs @@ -542,3 +542,100 @@ fn shader_job_blur_smooths_edge() { let _ = std::fs::remove_file(&path); } + +/// Chroma Key on a green clip: the keying node's job payload flows through +/// the whole graph path (footage → chromakey → clip → traverser → GPU +/// shader job → composite), and the key color actually drives the mask — a +/// green key on a green frame makes the frame transparent, a red key keeps +/// it opaque. Regression test for the chromakey node's old value(): gated +/// on the OCIO processor (never populated without the render bridge), it +/// pushed an empty output table, the traverser handed the clip +/// `NodeValue::None`, and the rendered frame lost the clip entirely. +/// Skipped when no GPU adapter or OCIO config exists. +#[test] +fn chromakey_job_keys_green_with_ociobased_stub() { + if oak_render::backend::GpuContext::shared().is_none() { + eprintln!("skipping chromakey_job_keys_green_with_ociobased_stub: no GPU adapter"); + return; + } + if oak_render::color::set_up_default_config().is_err() { + eprintln!("skipping chromakey_job_keys_green_with_ociobased_stub: no OCIO config"); + return; + } + + let path = clip_path("chromakey_job"); + oak_codec::testmedia::write_test_clip_solid(&path, 64, 64, 10, 10, [0.0, 1.0, 0.0, 1.0]) + .expect("green clip generation"); + + // The chromakey built from the factory (the same `create()` the + // inspector's effect stack uses), defaulting to the green key color. + let render = |key: [f64; 4]| { + let (project, seq) = build_effect_project( + (&path.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)), + |p, footage, clip| { + let (ecore, ebehavior) = + oak_node::factory::Factory::global() + .create_any("org.olivevideoeditor.Olive.chromakey") + .expect("chromakey factory entry"); + let effect = p.graph.add_node(ecore, ebehavior); + p.graph + .disconnect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1); + p.graph + .connect(footage, effect, "tex_in", -1) + .expect("connect footage to chromakey"); + p.graph + .connect(effect, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1) + .expect("connect chromakey to clip"); + p.graph + .get_mut(effect) + .unwrap() + .core + .set_standard_value("color_key", -1, oak_node::value::NodeValue::Color(key)); + effect + }, + ); + let tex = oak_render::eval::render_graph_frame( + &project, + seq, + Rational::new(0, 1), + (64, 64), + PixelFormat::F32, + ) + .expect("chromakey render"); + frame_data(&tex).to_vec() + }; + + // Sum the RGB channels over a 48x48 center crop (MPEG-2 chroma bleed + // stays near the edges); a keyed-out frame contributes nothing. + let energy = |frame: &[u8]| -> f32 { + let mut total = 0.0f32; + for y in 8..56 { + for x in 8..56 { + let off = (y * 64 + x) * 16; + for c in 0..3 { + total += f32::from_le_bytes(frame[off + c * 4..off + c * 4 + 4].try_into().unwrap()); + } + } + } + total + }; + + // Green key on the green clip: fully keyed (transparent black). + let green_keyed = render([0.0, 1.0, 0.0, 1.0]); + assert!( + energy(&green_keyed) < 1.0, + "green frame against the green key must key out (energy {})", + energy(&green_keyed) + ); + + // Red key on the green clip: far from the key color, mask ~1, the + // frame stays opaque — the key color parametrizes the result. + let red_keyed = render([1.0, 0.0, 0.0, 1.0]); + assert!( + energy(&red_keyed) > 10.0, + "green frame against the red key must stay (energy {})", + energy(&red_keyed) + ); + + let _ = std::fs::remove_file(&path); +}