app: project-explorer new-sequence button, 4K presets, keying node fix, color-picker canvases
- 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.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "名前を変更"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "Переименовать"
|
||||
|
||||
@@ -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": "重命名"
|
||||
|
||||
+199
-5
@@ -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<E: AppEngine> {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<crate::dialogs::SequencePropertiesContent<E>>,
|
||||
},
|
||||
/// 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<Modal> },
|
||||
}
|
||||
|
||||
/// A running export: the session the tick loop drains for progress.
|
||||
@@ -233,7 +244,8 @@ impl<E: AppEngine> ModalState<E> {
|
||||
| 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<E: AppEngine> {
|
||||
shell_focus: gpui::FocusHandle,
|
||||
/// The running export session, if any.
|
||||
export: Option<ExportRun>,
|
||||
/// 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<FootageDropNeedsSequence>,
|
||||
/// 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<E: AppEngine> OakApp<E> {
|
||||
)
|
||||
.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<E: AppEngine> OakApp<E> {
|
||||
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<E: AppEngine> OakApp<E> {
|
||||
/// `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>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<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::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<E: AppEngine> OakApp<E> {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<E: AppEngine> OakApp<E> {
|
||||
// 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<E: AppEngine> OakApp<E> {
|
||||
// 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),
|
||||
},
|
||||
}
|
||||
|
||||
+212
-16
@@ -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<ComboBoxOption> {
|
||||
vec![
|
||||
ComboBoxOption::new(0, i18n::tr("seqprops.preset.custom")),
|
||||
@@ -2965,6 +2968,8 @@ fn sequence_preset_options() -> Vec<ComboBoxOption> {
|
||||
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<E: crate::oakui::engine::AppEngine> NewSequenceContent<E> {
|
||||
/// Builds the content seeded with the default name and the HD 1080p25
|
||||
/// preset.
|
||||
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> 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<E>,
|
||||
seed: SequenceFormatSeed,
|
||||
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 }
|
||||
@@ -3265,19 +3327,7 @@ impl<E: crate::oakui::engine::AppEngine> NewSequenceContent<E> {
|
||||
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<E: crate::oakui::engine::AppEngine> SequencePropertiesContent<E> {
|
||||
|
||||
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<E: crate::oakui::engine::AppEngine> Render for SequencePropertiesContent<E>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Self>) -> 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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<E: AppEngine> PanelCommandHandler for ProjectExplorerPanel<E> {}
|
||||
impl<E: AppEngine> Render for ProjectExplorerPanel<E> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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<E: AppEngine> Render for ProjectExplorerPanel<E> {
|
||||
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<E: AppEngine> EventEmitter<SequencePropertiesRequested> for ProjectExplorerPanel<E> {}
|
||||
|
||||
/// The project explorer asked the shell to open the new-sequence dialog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NewSequenceRequested;
|
||||
|
||||
impl<E: AppEngine> EventEmitter<NewSequenceRequested> for ProjectExplorerPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for ProjectExplorerPanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
PROJECT
|
||||
|
||||
@@ -407,6 +407,12 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
/// 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<Self>) {
|
||||
let Some(target) = self.footage_drop.take() else {
|
||||
return;
|
||||
@@ -417,6 +423,16 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
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<E: AppEngine> EventEmitter<PanelEvent> for TimelinePanel<E> {}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<ContextMenuTriggered> for TimelinePanel<E> {}
|
||||
|
||||
/// 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<E: AppEngine> EventEmitter<FootageDropNeedsSequence> for TimelinePanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for TimelinePanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
TIMELINE
|
||||
|
||||
@@ -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<Box<dyn NodeBehavior>> {
|
||||
// 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<dyn NodeBehavior>) {
|
||||
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::<ShaderJobPayload>(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]
|
||||
|
||||
@@ -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::<ShaderJobPayload>(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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user