feat(engine): rename facade to oakengine, build liboakengine.dylib

- src/facade/rust -> src/engine/rust; package oakfacade -> oakengine
- crate-type += cdylib; module crates are real deps; linkage anchors
  force-link module C ABIs into the dylib
- build.rs: -undefined dynamic_lookup for host-provided oakcore_*/fb_*
- nm: 749 oakengine_* + 687 module oak*_* exports; undefined set is
  only the intended host-provided symbols
- worker/cli updated to the new path/name; undo test race fix
This commit is contained in:
2026-08-10 16:55:35 +08:00
parent e563b340ac
commit 0ca9cad448
66 changed files with 3398 additions and 204 deletions
+1
View File
@@ -3636,6 +3636,7 @@ dependencies = [
"gpui_platform",
"gpui_widgets",
"image",
"smallvec",
]
[[package]]
+4
View File
@@ -37,6 +37,10 @@ gpui = { path = "../../gpui/crates/gpui" }
gpui_platform = { path = "../../gpui/crates/gpui_platform", features = ["font-kit"] }
# Oak's widget library: menus, viewer, form controls, project explorer.
gpui_widgets = { path = "../../gpui/crates/gpui_widgets" }
# The mock engine's synthetic viewer frames (`image::Frame` in a
# `RenderImage`), matching the versions gpui itself uses.
image = "0.25"
smallvec = "1"
[dev-dependencies]
# `#[gpui::test]` harness for engine-seam smoke tests (test-support feature).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 397 KiB

+9 -5
View File
@@ -57,12 +57,16 @@ fn main() -> Result<()> {
})?;
let handle: AnyWindowHandle = window.into();
// Let the platform settle, then draw one full frame into the rendered
// scene so `render_to_image` has something to capture.
// Draw a few frames so the layout settles: the node editor fits its graph
// once the canvas size is known and the viewers upload their first CPU
// frame, both of which happen on the frame after the initial render.
for _ in 0..4 {
cx.run_until_parked();
cx.update_window(handle, |_root, window, app| {
let _ = window.draw(app);
})?;
}
cx.run_until_parked();
cx.update_window(handle, |_root, window, app| {
let _ = window.draw(app);
})?;
let image = cx.capture_screenshot(handle)?;
std::fs::create_dir_all(std::path::Path::new(OUT).parent().unwrap())?;
+2 -2
View File
@@ -152,7 +152,7 @@ impl PanelRegistry for AppPanelRegistry {
cx,
)),
"node-editor" => Some(PanelHandle::new(
cx.new(|cx| NodeEditorPanel::new(window, cx)),
cx.new(|cx| NodeEditorPanel::new(self.engine.clone(), window, cx)),
cx,
)),
"inspector" => Some(PanelHandle::new(
@@ -234,7 +234,7 @@ impl OakApp {
cx,
)
});
let node_editor = cx.new(|cx| NodeEditorPanel::new(window, cx));
let node_editor = cx.new(|cx| NodeEditorPanel::new(engine.clone(), window, cx));
let inspector = cx.new(|cx| InspectorPanel::new(engine.clone(), window, cx));
let history = cx.new(|cx| HistoryPanel::new(window, cx));
let timeline_panel =
+71 -9
View File
@@ -95,17 +95,19 @@ pub fn set_language(language: Language) {
Language::ZhCN => 1,
}, Ordering::Relaxed);
persist_language(language);
sync_widgets();
}
/// Loads the persisted language from the oakcommon config C ABI. Called once
/// at startup. Never fails: without liboakcommon the default (en-US) stays.
pub fn init() {
let Some(store) = ConfigAbi::load() else {
sync_widgets();
return;
};
match store.get("Language") {
Some(code) if !code.is_empty() => set_language(Language::from_code(&code)),
_ => {}
_ => sync_widgets(),
}
}
@@ -127,6 +129,28 @@ pub fn tr(key: &'static str) -> &'static str {
}
}
/// The keys the gpui widget crates localize through
/// `gpui_widgets::i18n` (viewer transport labels, effect-stack empty state).
pub const WIDGET_KEYS: &[&str] = &[
"viewer.safe_frames",
"viewer.zoom",
"viewer.no_frame_source",
"effect_stack.empty",
"effect_stack.add",
];
/// Installs the active language's widget strings into the
/// `gpui_widgets::i18n` string-table hook, so the widget-baked labels follow
/// the app language on the next render. Called on startup and on every
/// language switch; the widgets keep their built-in defaults otherwise.
pub fn sync_widgets() {
let mut table = gpui_widgets::i18n::StringTable::new();
for key in WIDGET_KEYS {
table.insert((*key).to_string(), tr(key).to_string());
}
gpui_widgets::i18n::set_table(table);
}
/// Looks `key` up in the en-US table.
fn en(key: &'static str) -> &'static str {
EN
@@ -226,14 +250,16 @@ const EN: &[(&str, &str)] = &[
("timeline.track_height", "Track Height"),
("timeline.snap", "Snap"),
// --- node editor ---
("node.zoom_in", "Zoom In"),
("node.zoom_out", "Zoom Out"),
("node.fit", "Fit"),
("node.fit_window", "Fit Window"),
("node.placeholder", "Node Editor · Placeholder — gpui::node_graph not wired up yet"),
// --- viewer header chips ---
("viewer.source", "Source Viewer · Source"),
("viewer.program", "Program Viewer · Program"),
// --- widget-baked strings (synced to gpui_widgets::i18n) ---
("viewer.safe_frames", "Safe Frames"),
("viewer.zoom", "Zoom"),
("viewer.no_frame_source", "No frame source"),
("effect_stack.empty", "No selection"),
("effect_stack.add", "+ Add Effect"),
// --- inspector ---
("inspector.params", "Parameters (placeholder)"),
];
@@ -319,14 +345,16 @@ const ZH: &[(&str, &str)] = &[
("timeline.track_height", "轨道高"),
("timeline.snap", "吸附"),
// --- node editor ---
("node.zoom_in", "放大"),
("node.zoom_out", "缩小"),
("node.fit", "适配"),
("node.fit_window", "适配窗口"),
("node.placeholder", "节点编辑器 · 占位 — gpui::node_graph 尚未接入"),
// --- viewer header chips ---
("viewer.source", "素材查看器 · 源"),
("viewer.program", "序列查看器 · 节目"),
// --- widget-baked strings (synced to gpui_widgets::i18n) ---
("viewer.safe_frames", "安全框"),
("viewer.zoom", "缩放"),
("viewer.no_frame_source", "无帧源"),
("effect_stack.empty", "未选择"),
("effect_stack.add", "+ 添加效果"),
// --- inspector ---
("inspector.params", "参数(占位)"),
];
@@ -534,6 +562,40 @@ mod tests {
assert_eq!(tr("menu.file.save"), "Save");
}
/// `sync_widgets` installs the active language's strings into the widget
/// string-table hook, so the widget-baked labels follow the app language.
#[test]
fn sync_widgets_installs_the_active_language() {
let _guard = lang_lock().lock().unwrap();
set_language(Language::EnUs);
gpui_widgets::i18n::clear_table(); // simulate a fresh process
sync_widgets();
assert_eq!(
gpui_widgets::i18n::tr("viewer.safe_frames", "安全框").to_string(),
"Safe Frames"
);
assert_eq!(
gpui_widgets::i18n::tr("viewer.zoom", "缩放").to_string(),
"Zoom"
);
// Every widget key is covered by the installed table.
for key in WIDGET_KEYS {
let installed = gpui_widgets::i18n::tr(key, "fallback").to_string();
assert_ne!(installed, "fallback", "widget key {key} not synced");
}
set_language(Language::ZhCN);
assert_eq!(
gpui_widgets::i18n::tr("viewer.safe_frames", "Safe Frames").to_string(),
"安全框"
);
assert_eq!(
gpui_widgets::i18n::tr("effect_stack.add", "+ Add Effect").to_string(),
"+ 添加效果"
);
set_language(Language::EnUs);
}
/// The config code round-trips.
#[test]
fn language_code_round_trips() {
+699 -4
View File
@@ -36,17 +36,25 @@
//! The real engine will later implement the same gateway over the
//! `liboakengine` C ABI; only the wiring in [`crate::app`] changes.
use std::collections::{BTreeSet, HashMap};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use gpui::effect_stack::{
EffectCardKind, EffectData, EffectId, EffectStackDataSource, EffectStackEvent,
};
use gpui::node_graph::{
EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortData,
PortDataType, PortId, PortKind,
};
use gpui::timeline::{
ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TrackData, TrackKind,
};
use gpui::{prelude::*, px, App, Context, Entity, Hsla, Pixels, SharedString};
use gpui::{
hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, Point, RenderImage,
SharedString,
};
use gpui_widgets::audio_meter::AudioMeterDataSource;
use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry};
use gpui_widgets::viewer::PlaybackClock;
@@ -57,6 +65,12 @@ use super::transport::TransportState;
/// The demo sequence length: 00:04:18:18 at 25 fps.
const SEQUENCE_LENGTH: i64 = 6468;
/// The synthetic viewer test frame is rendered at a small proxy size (the
/// real engine will deliver full-resolution frames; the mock only needs to
/// prove the CPU-frame path end to end).
const SYNTH_FRAME_WIDTH: u32 = 384;
const SYNTH_FRAME_HEIGHT: u32 = 216;
// ---------------------------------------------------------------------------
// Clocks
// ---------------------------------------------------------------------------
@@ -256,6 +270,125 @@ impl EffectData for MockEffect {
}
}
// ---------------------------------------------------------------------------
// Node graph model
// ---------------------------------------------------------------------------
/// A port on a mock node. `connected` is a model-side cache refreshed by
/// [`MockEngine::refresh_port_connectivity`] after every graph mutation.
#[derive(Debug, Clone)]
pub struct MockPort {
id: PortId,
kind: PortKind,
label: SharedString,
data_type: PortDataType,
connected: bool,
}
impl PortData for MockPort {
fn id(&self) -> PortId {
self.id
}
fn kind(&self) -> PortKind {
self.kind
}
fn label(&self) -> SharedString {
self.label.clone()
}
fn data_type(&self) -> PortDataType {
self.data_type.clone()
}
fn is_connected(&self) -> bool {
self.connected
}
}
/// A node in the mock graph (媒体 → 变换 → … → 输出).
#[derive(Debug, Clone)]
pub struct MockNode {
id: NodeId,
title: SharedString,
position: Point<Pixels>,
inputs: Vec<MockPort>,
outputs: Vec<MockPort>,
header_color: Option<Hsla>,
enabled: bool,
collapsed: bool,
}
impl NodeData for MockNode {
type Port = MockPort;
fn id(&self) -> NodeId {
self.id
}
fn title(&self) -> SharedString {
self.title.clone()
}
fn position(&self) -> Point<Pixels> {
self.position
}
fn inputs(&self) -> Vec<Self::Port> {
self.inputs.clone()
}
fn outputs(&self) -> Vec<Self::Port> {
self.outputs.clone()
}
fn header_color(&self) -> Option<Hsla> {
self.header_color
}
fn is_collapsed(&self) -> bool {
self.collapsed
}
fn is_enabled(&self) -> bool {
self.enabled
}
}
/// An edge in the mock graph: a connection from an output port to an input
/// port.
#[derive(Debug, Clone)]
pub struct MockEdge {
id: EdgeId,
from_node: NodeId,
from_port: PortId,
to_node: NodeId,
to_port: PortId,
}
impl EdgeData for MockEdge {
fn id(&self) -> EdgeId {
self.id
}
fn from_node(&self) -> NodeId {
self.from_node
}
fn from_port(&self) -> PortId {
self.from_port
}
fn to_node(&self) -> NodeId {
self.to_node
}
fn to_port(&self) -> PortId {
self.to_port
}
}
// ---------------------------------------------------------------------------
// The engine
// ---------------------------------------------------------------------------
@@ -281,6 +414,19 @@ pub struct MockEngine {
selected_item: Option<u64>,
/// Phase counter driving the demo audio levels.
meter_phase: u32,
/// The demo node graph shown in the node editor.
nodes: Vec<MockNode>,
/// The demo node-graph edges.
edges: Vec<MockEdge>,
/// Id allocator for edges added at runtime.
next_edge_id: u64,
/// The node selection, kept in sync with the node editor (and, later, the
/// effect stack) so both views share one selection.
node_selection: BTreeSet<NodeId>,
/// Cache of the synthetic CPU frames handed to the viewers, keyed by
/// monitor. Entries are the playhead frame that produced the image, so a
/// paused viewer never regenerates its picture.
cpu_frame_cache: Mutex<HashMap<Monitor, (i64, Arc<RenderImage>)>>,
}
impl MockEngine {
@@ -307,8 +453,122 @@ impl MockEngine {
l: 0.55,
a: 1.0,
};
let node_color = |h: f32| Hsla {
h,
s: 0.55,
l: 0.5,
a: 1.0,
};
Self {
// The demo node graph: two clips, one through the transform, one
// through the blur, merged in the mixer and LUT'd to the viewer.
// Port ids are globally unique.
let video_type = PortDataType::new("video", hsla(0.55, 0.75, 0.6, 1.0));
let audio_type = PortDataType::new("audio", hsla(0.1, 0.7, 0.55, 1.0));
let port = |id: u64,
kind: PortKind,
label: &str,
data_type: &PortDataType| MockPort {
id: PortId(id),
kind,
label: label.into(),
data_type: data_type.clone(),
connected: false,
};
let node = |id: u64,
title: &str,
position: (f32, f32),
color: f32,
inputs: Vec<MockPort>,
outputs: Vec<MockPort>| MockNode {
id: NodeId(id),
title: title.into(),
position: point(px(position.0), px(position.1)),
inputs,
outputs,
header_color: Some(node_color(color)),
enabled: true,
collapsed: false,
};
let nodes = vec![
node(
0,
"第一稿.mp4",
(40.0, 40.0),
0.55,
vec![],
vec![port(1, PortKind::Output, "video", &video_type)],
),
node(
1,
"B-roll.mp4",
(40.0, 230.0),
0.60,
vec![],
vec![
port(3, PortKind::Output, "video", &video_type),
port(4, PortKind::Output, "audio", &audio_type),
],
),
node(
2,
"变换",
(320.0, 40.0),
0.35,
vec![
port(20, PortKind::Input, "in", &video_type),
port(22, PortKind::Input, "mask", &video_type),
],
vec![port(21, PortKind::Output, "out", &video_type)],
),
node(
3,
"模糊",
(320.0, 230.0),
0.78,
vec![port(30, PortKind::Input, "in", &video_type)],
vec![port(31, PortKind::Output, "out", &video_type)],
),
node(
4,
"混合",
(600.0, 130.0),
0.08,
vec![
port(40, PortKind::Input, "A", &video_type),
port(41, PortKind::Input, "B", &video_type),
],
vec![port(42, PortKind::Output, "out", &video_type)],
),
node(
5,
"输出",
(880.0, 130.0),
0.0,
vec![port(50, PortKind::Input, "in", &video_type)],
vec![],
),
];
let edge = |id: u64,
from_node: u64,
from_port: u64,
to_node: u64,
to_port: u64| MockEdge {
id: EdgeId(id),
from_node: NodeId(from_node),
from_port: PortId(from_port),
to_node: NodeId(to_node),
to_port: PortId(to_port),
};
let edges = vec![
edge(1, 0, 1, 2, 20),
edge(2, 1, 3, 3, 30),
edge(3, 2, 21, 4, 40),
edge(4, 3, 31, 4, 41),
edge(5, 4, 42, 5, 50),
];
let mut this = Self {
project: Project {
name: "第一稿".into(),
path: PathBuf::from("/home/mikesolar/Videos/aaa.ove"),
@@ -407,7 +667,16 @@ impl MockEngine {
program_playing: false,
selected_item: None,
meter_phase: 0,
}
nodes,
edges,
next_edge_id: 6,
node_selection: BTreeSet::new(),
cpu_frame_cache: Mutex::new(HashMap::new()),
};
// The demo graph is born connected: derive every port's `connected`
// flag from the edge list.
this.refresh_port_connectivity();
this
}
/// The current sequence length (also used by the gateway).
@@ -476,6 +745,134 @@ impl MockEngine {
cx.notify();
}
/// Applies a node-editor request to the mock graph (the "edits are
/// requests" loop: the view emits, the engine applies and notifies).
pub fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>) {
match event {
NodeGraphEvent::NodeMovePreview { .. } | NodeGraphEvent::ViewChanged { .. } => {}
NodeGraphEvent::NodeMoveRequested { nodes, delta } => {
for id in nodes {
if let Some(node) = self.nodes.iter_mut().find(|n| n.id() == *id) {
node.position = node.position + *delta;
}
}
}
NodeGraphEvent::ConnectionRequested { from, to } => {
if self.can_connect(*from, *to) {
let from_node = self.node_with_port(*from).map(|n| n.id());
let to_node = self.node_with_port(*to).map(|n| n.id());
if let (Some(from_node), Some(to_node)) = (from_node, to_node) {
self.edges.push(MockEdge {
id: EdgeId(self.next_edge_id),
from_node,
from_port: *from,
to_node,
to_port: *to,
});
self.next_edge_id += 1;
self.refresh_port_connectivity();
}
}
}
NodeGraphEvent::DisconnectionRequested { edge } => {
if let Some(index) = self.edges.iter().position(|e| e.id() == *edge) {
self.edges.remove(index);
self.refresh_port_connectivity();
}
}
NodeGraphEvent::DeleteRequested { nodes, edges } => {
self.edges.retain(|edge| {
!edges.contains(&edge.id())
&& !nodes.contains(&edge.from_node())
&& !nodes.contains(&edge.to_node())
});
self.nodes.retain(|node| !nodes.contains(&node.id()));
self.node_selection.clear();
self.refresh_port_connectivity();
}
NodeGraphEvent::SelectionChanged { nodes } => {
self.node_selection = nodes.clone();
}
NodeGraphEvent::BackgroundClicked { position } => {
// The real app opens an "add node" menu here; the mock logs it.
println!("[node editor] add-node menu at graph {position:?} (mock: ignored)");
}
}
cx.notify();
}
/// The node currently under the cursor selection (demo state; the effect
/// stack will sync to this once it drives the same selection).
pub fn selected_node_ids(&self) -> &BTreeSet<NodeId> {
&self.node_selection
}
/// Looks up a node by id (test helper).
#[cfg(test)]
fn node(&self, id: NodeId) -> Option<&MockNode> {
self.nodes.iter().find(|n| n.id() == id)
}
/// Looks up a port by its globally unique id.
fn port(&self, id: PortId) -> Option<&MockPort> {
self.nodes
.iter()
.flat_map(|n| n.inputs.iter().chain(n.outputs.iter()))
.find(|p| p.id() == id)
}
/// Returns the node that owns `port`.
fn node_with_port(&self, port: PortId) -> Option<&MockNode> {
self.nodes.iter().find(|n| {
n.inputs.iter().any(|p| p.id() == port)
|| n.outputs.iter().any(|p| p.id() == port)
})
}
/// Whether an edge with the same endpoints already exists.
fn edge_exists(&self, from: PortId, to: PortId) -> bool {
self.edges
.iter()
.any(|e| e.from_port() == from && e.to_port() == to)
}
/// Whether there is a directed path from node `from` to node `to`
/// following the existing edges (DFS). `from == to` counts as a path.
fn reaches(&self, from: NodeId, to: NodeId) -> bool {
if from == to {
return true;
}
let mut visited = std::collections::HashSet::new();
let mut stack = vec![from];
while let Some(current) = stack.pop() {
if !visited.insert(current) {
continue;
}
for edge in &self.edges {
if edge.from_node() == current {
let next = edge.to_node();
if next == to {
return true;
}
stack.push(next);
}
}
}
false
}
/// Recomputed every port's `connected` flag from the current edge list.
fn refresh_port_connectivity(&mut self) {
for node in &mut self.nodes {
for port in node.inputs.iter_mut().chain(node.outputs.iter_mut()) {
port.connected = self
.edges
.iter()
.any(|e| e.from_port() == port.id || e.to_port() == port.id);
}
}
}
/// Sets the row height of every timeline track (demo toolbar).
pub fn set_track_height(&mut self, height: Pixels, cx: &mut Context<Self>) {
for track in &mut self.tracks {
@@ -649,6 +1046,44 @@ impl EffectStackDataSource for MockEngine {
}
}
impl NodeGraphDataSource for MockEngine {
type Node = MockNode;
type Edge = MockEdge;
fn nodes(&self) -> Vec<Self::Node> {
self.nodes.clone()
}
fn edges(&self) -> Vec<Self::Edge> {
self.edges.clone()
}
fn can_connect(&self, from: PortId, to: PortId) -> bool {
let (Some(from_node), Some(to_node)) = (self.node_with_port(from), self.node_with_port(to))
else {
return false;
};
if from_node.id() == to_node.id() {
return false;
}
let (Some(from_port), Some(to_port)) = (self.port(from), self.port(to)) else {
return false;
};
if from_port.kind() != PortKind::Output || to_port.kind() != PortKind::Input {
return false;
}
if from_port.data_type() != to_port.data_type() {
return false;
}
if self.edge_exists(from, to) {
return false;
}
// Connecting from_node → to_node would create a cycle iff there is
// already a path from to_node back to from_node.
!self.reaches(to_node.id(), from_node.id())
}
}
impl ProjectDataSource for MockEngine {
fn roots(&self) -> Vec<ProjectEntry> {
vec![
@@ -698,6 +1133,87 @@ impl MockEngine {
pub fn clock_frame(&self, monitor: Monitor, cx: &App) -> Frame {
self.clock(monitor).read(cx).transport.frame()
}
/// The synthetic CPU test frame for `monitor`, cached per playhead frame so
/// a paused viewer never regenerates its picture. This is the frame the
/// source/program viewers display through [`ViewerWidget::set_cpu_frame`],
/// proving the CPU-frame path end to end before the real engine lands.
pub fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc<RenderImage> {
let frame = self.clock_frame(monitor, cx);
let mut cache = self.cpu_frame_cache.lock().unwrap();
if let Some((cached_frame, image)) = cache.get(&monitor) {
if *cached_frame == frame.0 {
return image.clone();
}
}
let image = Arc::new(self.synthetic_frame(frame));
cache.insert(monitor, (frame.0, image.clone()));
image
}
/// Generates a synthetic test frame: SMPTE-style color bars with a white
/// sweep whose x position follows `frame`, so playback is visibly moving.
///
/// Samples are computed as F32 RGBA (mirroring the real engine's pixel
/// pipeline) and downconverted to BGRA8 for the viewer's CPU-frame path.
/// The picture is rendered at a small proxy size ([`SYNTH_FRAME_WIDTH`] ×
/// [`SYNTH_FRAME_HEIGHT`]); the real engine delivers full resolution.
fn synthetic_frame(&self, frame: Frame) -> RenderImage {
let width = SYNTH_FRAME_WIDTH;
let height = SYNTH_FRAME_HEIGHT;
// F32 RGBA samples, then quantized to BGRA8 for the sprite atlas.
let mut samples = vec![0.0f32; (width * height * 4) as usize];
// SMPTE bars: 75% white, yellow, cyan, green, magenta, red, blue.
let bars: [(f32, f32, f32); 7] = [
(1.0, 1.0, 1.0),
(1.0, 1.0, 0.0),
(0.0, 1.0, 1.0),
(0.0, 1.0, 0.0),
(1.0, 0.0, 1.0),
(1.0, 0.0, 0.0),
(0.0, 0.0, 1.0),
];
// Bottom strip: blue, magenta, 75% white, black.
let strip: [(f32, f32, f32); 4] = [
(0.0, 0.0, 1.0),
(1.0, 0.0, 1.0),
(0.75, 0.75, 0.75),
(0.0, 0.0, 0.0),
];
// The sweep moves 6 px per frame and wraps around the width, so
// transport playback shows up as motion across the picture.
let sweep = (frame.0 as f32 * 6.0) % width as f32;
let bars_top = height as f32 * 0.66;
for y in 0..height {
for x in 0..width {
let in_sweep = (x as f32 - sweep).abs() < 6.0;
let color = if in_sweep {
(1.0, 1.0, 1.0)
} else if (y as f32) < bars_top {
bars[((x as f32 / width as f32) * 7.0) as usize]
} else {
strip[((x as f32 / width as f32) * 4.0) as usize]
};
let i = ((y * width + x) * 4) as usize;
samples[i] = color.0;
samples[i + 1] = color.1;
samples[i + 2] = color.2;
samples[i + 3] = 1.0;
}
}
let mut bytes = Vec::with_capacity((width * height * 4) as usize);
for i in (0..samples.len()).step_by(4) {
bytes.push((samples[i + 2] * 255.0) as u8); // B
bytes.push((samples[i + 1] * 255.0) as u8); // G
bytes.push((samples[i] * 255.0) as u8); // R
bytes.push((samples[i + 3] * 255.0) as u8); // A
}
let buffer = image::RgbaImage::from_raw(width, height, bytes).expect("synthetic frame");
RenderImage::new(smallvec::SmallVec::from_elem(image::Frame::new(buffer), 1))
}
}
#[cfg(test)]
@@ -799,4 +1315,183 @@ mod tests {
assert_eq!(before, after);
});
}
#[gpui::test]
async fn demo_graph_has_six_nodes_and_five_edges(cx: &mut TestAppContext) {
cx.update(|app| {
let engine = demo_engine(app);
let engine = engine.read(app);
let nodes = engine.nodes();
let edges = engine.edges();
assert_eq!(nodes.len(), 6, "media, transform, blur, mixer, viewer");
assert_eq!(edges.len(), 5);
// The chain ends at the viewer node, fed by the mixer.
let viewer = nodes
.iter()
.find(|n| n.title() == "输出")
.expect("viewer node");
assert_eq!(viewer.inputs().len(), 1);
assert!(viewer.outputs().is_empty());
// Every edge endpoint references an existing port, and every port
// connectivity flag matches the edge list.
for edge in &edges {
assert!(
nodes.iter().any(|n| n.id() == edge.from_node()),
"edge {} from-node exists",
edge.id().0
);
assert!(
nodes.iter().any(|n| n.id() == edge.to_node()),
"edge {} to-node exists",
edge.id().0
);
}
for node in nodes {
for port in node.inputs().into_iter().chain(node.outputs()) {
let expected = engine
.edges()
.iter()
.any(|e| e.from_port() == port.id() || e.to_port() == port.id());
assert_eq!(port.is_connected(), expected, "port {}", port.id().0);
}
}
});
}
#[gpui::test]
async fn can_connect_enforces_direction_type_duplicates_and_cycles(
cx: &mut TestAppContext,
) {
cx.update(|app| {
let engine = demo_engine(app);
let engine = engine.read(app);
// A valid, still-free connection: Clip1.video → Transform.in (the
// transform already takes the clip-0 path, so this would be the
// second input).
assert!(engine.can_connect(PortId(3), PortId(20)));
// The existing connection is not offered again.
assert!(!engine.can_connect(PortId(21), PortId(40)), "duplicate edge");
// Input → output is rejected (wrong direction).
assert!(!engine.can_connect(PortId(20), PortId(1)), "wrong direction");
// A port cannot connect to itself.
assert!(!engine.can_connect(PortId(42), PortId(40)), "self connection");
// Type mismatch: the clip's audio output is not a video signal.
assert!(
!engine.can_connect(PortId(4), PortId(20)),
"audio cannot feed a video input"
);
// Unknown ports are rejected.
assert!(!engine.can_connect(PortId(999), PortId(20)));
assert!(!engine.can_connect(PortId(1), PortId(999)));
// Cycle rule: the graph flows left-to-right (Clip → Transform →
// Mixer → Viewer), so no connection can close a loop — but the
// reachability helper behind the rule is exercised directly.
assert!(engine.reaches(NodeId(0), NodeId(5)), "main chain path");
assert!(!engine.reaches(NodeId(5), NodeId(0)), "no backward path");
assert!(engine.reaches(NodeId(4), NodeId(4)));
});
}
#[gpui::test]
async fn node_edits_apply_to_the_model(cx: &mut TestAppContext) {
cx.update(|app| {
let engine = demo_engine(app);
// Move the viewer node.
engine.update(app, |engine, cx| {
engine.apply_node_graph_event(
&NodeGraphEvent::NodeMoveRequested {
nodes: vec![NodeId(5)],
delta: point(px(100.0), px(-20.0)),
},
cx,
);
});
let moved = engine.read(app).node(NodeId(5)).expect("viewer node");
assert_eq!(moved.position(), point(px(980.0), px(110.0)));
// Connect the B-roll clip's video to the transform's unused mask
// input, then disconnect the transform → mixer edge.
engine.update(app, |engine, cx| {
engine.apply_node_graph_event(
&NodeGraphEvent::ConnectionRequested {
from: PortId(3),
to: PortId(22),
},
cx,
);
});
let engine_read = engine.read(app);
assert_eq!(engine_read.edges().len(), 6);
assert!(engine_read.port(PortId(22)).is_some_and(|p| p.is_connected()));
let edges = engine_read.edges();
let extra = edges
.iter()
.find(|e| e.to_port() == PortId(22))
.expect("the new connection");
engine.update(app, |engine, cx| {
engine.apply_node_graph_event(
&NodeGraphEvent::DisconnectionRequested { edge: extra.id() },
cx,
);
});
assert_eq!(engine.read(app).edges().len(), 5);
assert!(
!engine.read(app).port(PortId(22)).is_some_and(|p| p.is_connected()),
"mask input freed again"
);
// Deleting the blur node takes its incident edges with it.
engine.update(app, |engine, cx| {
engine.apply_node_graph_event(
&NodeGraphEvent::DeleteRequested {
nodes: vec![NodeId(3)],
edges: vec![],
},
cx,
);
});
let engine_read = engine.read(app);
assert_eq!(engine_read.nodes().len(), 5);
assert!(
!engine_read.edges().iter().any(|e| e.to_node() == NodeId(3)
|| e.from_node() == NodeId(3)),
"edges incident to the deleted node are removed"
);
});
}
#[gpui::test]
async fn cpu_frame_is_cached_per_playhead_and_advances(cx: &mut TestAppContext) {
cx.update(|app| {
let engine = demo_engine(app);
// Same playhead → same cached image (Arc identity), so a paused
// viewer never regenerates its picture.
let a = engine.read(app).cpu_frame(Monitor::Program, app);
let b = engine.read(app).cpu_frame(Monitor::Program, app);
assert!(Arc::ptr_eq(&a, &b), "paused frame must be cached");
// Advancing the playhead produces a different image: the sweep
// moved, so playback is visible.
engine.update(app, |engine, cx| {
engine.step(Monitor::Program, 25, cx);
});
let c = engine.read(app).cpu_frame(Monitor::Program, app);
assert!(!Arc::ptr_eq(&a, &c), "a new playhead frame regenerates");
// The frame has the documented proxy size and opaque BGRA8 bytes.
let size = c.size(0);
assert_eq!(size.width, SYNTH_FRAME_WIDTH.into());
assert_eq!(size.height, SYNTH_FRAME_HEIGHT.into());
let bytes = c.as_bytes(0).expect("single frame");
assert_eq!(bytes.len(), (SYNTH_FRAME_WIDTH * SYNTH_FRAME_HEIGHT * 4) as usize);
assert!(bytes.chunks_exact(4).all(|px| px[3] == 255), "opaque alpha");
});
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ pub mod ids {
pub const SOURCE_VIEWER: PanelId = PanelId::new(2);
/// The program viewer (序列查看器).
pub const PROGRAM_VIEWER: PanelId = PanelId::new(3);
/// The node editor placeholder (节点编辑器).
/// The node editor (节点编辑器).
pub const NODE_EDITOR: PanelId = PanelId::new(4);
/// The inspector / effect stack (检查器·效果栈).
pub const INSPECTOR: PanelId = PanelId::new(5);
+198 -41
View File
@@ -14,35 +14,139 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The node editor panel (节点编辑器): a placeholder tab sharing the program
//! viewer's dock group.
//! The node editor panel (节点编辑器): the real `gpui::node_graph` canvas over
//! the engine's mock graph, with the design's zoom controls (+ / − / 适配).
//!
//! The design puts the node editor in the center, switchable with the program
//! viewer. The real `gpui::node_graph` widget exists in the gpui submodule
//! but is not wired up yet — this panel is a placeholder surface with the
//! zoom controls the design specifies (+ / − / fit).
//! The graph is a full [`NodeGraphView`] fed by the [`MockEngine`]'s
//! [`NodeGraphDataSource`] implementation. Every gesture the view emits
//! (move, connect, disconnect, delete, selection) is forwarded to the engine
//! as a request; the engine applies it to its model and notifies, so the
//! view re-reads on the next frame. The toolbar buttons drive the viewport
//! directly: zoom in/out at the canvas center, or fit the whole graph.
use gpui::colors::DefaultColors;
use gpui::dock::{DockPanel, PanelEvent};
use gpui::node_graph::{
MAX_ZOOM, MIN_ZOOM, NodeData, NodeElement, NodeGraphDataSource, NodeGraphEvent, NodeGraphView,
NodeVisualState,
};
use gpui::{
div, prelude::*, AnyElement, App, ClickEvent, Context, EventEmitter, Render, SharedString,
Window,
div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity,
EventEmitter, Pixels, Render, SharedString, Window,
};
use crate::oakui::MockEngine;
use crate::panels::ids::NODE_EDITOR;
/// The node editor placeholder panel.
pub struct NodeEditorPanel;
/// The node editor panel.
pub struct NodeEditorPanel {
/// The node-graph canvas over the engine's mock graph.
graph: Entity<NodeGraphView<MockEngine>>,
engine: Entity<MockEngine>,
/// Whether the initial fit-to-window has been applied (the canvas size is
/// only known after the first layout).
fitted: bool,
}
impl NodeEditorPanel {
/// Creates the placeholder.
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
Self
/// Builds the graph canvas over `engine` and routes its edit requests back
/// to the engine.
pub fn new(
engine: Entity<MockEngine>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let graph = cx.new(|cx| NodeGraphView::new(engine.clone(), window, cx));
// The "edits are requests" loop: every graph gesture goes to the
// engine, which applies it to its model and notifies.
cx.subscribe(&graph, |this, _graph, event: &NodeGraphEvent, cx| {
this.engine
.update(cx, |engine, cx| engine.apply_node_graph_event(event, cx));
})
.detach();
Self {
graph,
engine,
fitted: false,
}
}
/// The union of every node's bounds in graph space, if the graph is
/// non-empty.
fn graph_bounds(&self, cx: &App) -> Option<Bounds<Pixels>> {
let nodes = self.engine.read(cx).nodes();
if nodes.is_empty() {
return None;
}
let mut min_x = f32::MAX;
let mut min_y = f32::MAX;
let mut max_x = f32::MIN;
let mut max_y = f32::MIN;
for node in &nodes {
let element = NodeElement::from_node(node, NodeVisualState::default());
let position = node.position();
let width = gpui::node_graph::DEFAULT_NODE_WIDTH;
let height = element.height();
let (x, y) = (f32::from(position.x), f32::from(position.y));
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x + f32::from(width));
max_y = max_y.max(y + f32::from(height));
}
Some(Bounds::from_corners(
point(px(min_x), px(min_y)),
point(px(max_x), px(max_y)),
))
}
/// The canvas size to fit against: the graph view's own painted size once
/// known, otherwise the window (before the first layout).
fn fit_viewport(&self, window: &Window, cx: &App) -> gpui::Size<Pixels> {
let viewport = self.graph.read(cx).viewport_size();
if viewport.width > px(0.0) && viewport.height > px(0.0) {
viewport
} else {
window.viewport_size()
}
}
/// Fits the whole graph into the canvas (the 适配 button).
fn fit_graph(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(rect) = self.graph_bounds(cx) else {
return;
};
let viewport = self.fit_viewport(window, cx);
self.graph.update(cx, |graph, cx| {
graph.state_mut().fit_to_rect(rect, viewport);
cx.notify();
});
}
/// Zooms the canvas by `factor` at its center (`+` / `−` buttons).
fn zoom(&mut self, factor: f32, window: &mut Window, cx: &mut Context<Self>) {
let viewport = self.fit_viewport(window, cx);
let anchor = point(viewport.width * 0.5, viewport.height * 0.5);
self.graph.update(cx, |graph, cx| {
graph.state_mut().zoom_at(anchor, factor);
cx.notify();
});
}
}
impl Render for NodeEditorPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Fit the graph once the canvas size is known (first layout). Before
// that the viewport is zero-sized, so ask for another frame instead.
if !self.fitted {
if self.graph.read(cx).viewport_size() != Default::default() {
self.fitted = true;
self.fit_graph(window, cx);
} else {
cx.notify();
}
}
let colors = cx.default_colors().clone();
div()
.size_full()
@@ -57,43 +161,46 @@ impl Render for NodeEditorPanel {
.py_1()
.border_b_1()
.border_color(colors.border)
.child(zoom_button(
cx,
"node-zoom-in",
"+",
crate::i18n::tr("node.zoom_in"),
))
.child(zoom_button(
cx,
"node-zoom-out",
"−",
crate::i18n::tr("node.zoom_out"),
))
.child(zoom_button(cx, "node-zoom-in", "+", |this, window, cx| {
this.zoom(1.25, window, cx);
}))
.child(zoom_button(cx, "node-zoom-out", "−", |this, window, cx| {
this.zoom(1.0 / 1.25, window, cx);
}))
.child(zoom_button(
cx,
"node-zoom-fit",
crate::i18n::tr("node.fit"),
crate::i18n::tr("node.fit_window"),
)),
|this, window, cx| this.fit_graph(window, cx),
))
.child(div().flex_1())
.child(
div()
.text_color(colors.disabled)
.child(format!(
"{}% · {}–{}",
(self.graph.read(cx).state().zoom() * 100.0).round(),
MIN_ZOOM,
MAX_ZOOM,
)),
),
)
.child(
div()
.debug_selector(|| "node-editor-canvas".into())
.flex_1()
.flex()
.items_center()
.justify_center()
.text_color(colors.disabled)
.child(crate::i18n::tr("node.placeholder")),
.min_h_0()
.child(self.graph.clone()),
)
}
}
/// A small toolbar button (the design's `+`/`−`/`适配` controls).
/// A small toolbar button driving the graph viewport.
fn zoom_button(
cx: &mut Context<NodeEditorPanel>,
id: &'static str,
label: &'static str,
title: &'static str,
label: impl IntoElement,
action: impl Fn(&mut NodeEditorPanel, &mut Window, &mut Context<NodeEditorPanel>) + 'static,
) -> impl gpui::IntoElement {
let colors = cx.default_colors().clone();
let container = colors.container;
@@ -107,11 +214,9 @@ fn zoom_button(
.text_color(colors.text)
.cursor_pointer()
.hover(move |style| style.bg(container))
.on_click(
cx.listener(move |_this, _event: &ClickEvent, _window, _cx| {
println!("[node editor] {title} (placeholder)");
}),
)
.on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| {
action(this, window, cx);
}))
.child(label)
}
@@ -132,3 +237,55 @@ impl DockPanel for NodeEditorPanel {
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{TestAppContext, VisualTestContext, size};
/// Builds the panel in a window and returns a `VisualTestContext` for
/// bounds assertions.
fn panel_window(
cx: &mut TestAppContext,
) -> (&'static mut VisualTestContext, Entity<NodeEditorPanel>) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(640.0), px(480.0)), |window, cx| {
let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx));
NodeEditorPanel::new(engine, window, cx)
});
cx.run_until_parked();
// The graph canvas reports its size only after the first paint, so the
// initial fit applies on the following frame: draw a few more.
for _ in 0..3 {
cx.update_window(window.into(), |_root, window, app| {
let _ = window.draw(app);
})
.expect("window still open");
cx.run_until_parked();
}
let panel = window.root(cx).expect("node editor panel root");
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
(cx, panel)
}
/// The panel lays out a graph canvas below the zoom toolbar, and the
/// initial fit centers the graph so every demo node is on screen.
#[gpui::test]
async fn canvas_fills_the_panel_below_the_toolbar(cx: &mut TestAppContext) {
let (cx, panel) = panel_window(cx);
let canvas = cx
.debug_bounds("node-editor-canvas")
.expect("graph canvas rendered");
assert!(canvas.size.width > px(0.0));
assert!(canvas.size.height > px(0.0));
// The initial fit moved the viewport off the default origin, so the
// graph is framed rather than clipped at the corner.
let state = cx.read(|app| {
panel.read(app).graph.read(app).state().clone()
});
assert_ne!(state.offset(), gpui::node_graph::GraphViewState::new().offset());
assert!(state.zoom() > 0.0);
}
}
+19
View File
@@ -40,6 +40,9 @@ pub struct ProgramViewerPanel {
viewer: Entity<ViewerWidget<MockClock>>,
meter: Entity<AudioLevelMeter<MockEngine>>,
engine: Entity<MockEngine>,
/// The last CPU frame handed to the viewer (compared by `Arc` identity so
/// a paused playhead does not re-upload the picture every frame).
last_cpu_frame: Option<std::sync::Arc<gpui::RenderImage>>,
}
impl ProgramViewerPanel {
@@ -69,12 +72,28 @@ impl ProgramViewerPanel {
viewer,
meter,
engine,
last_cpu_frame: None,
}
}
/// Pushes the engine's synthetic test frame into the viewer, but only when
/// it actually changed (the engine caches one image per playhead frame).
fn sync_frame(&mut self, cx: &mut Context<Self>) {
let frame = self.engine.read(cx).cpu_frame(Monitor::Program, cx);
if self.last_cpu_frame.as_ref().is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
{
self.last_cpu_frame = Some(frame.clone());
let frame = frame.clone();
self.viewer
.update(cx, |viewer, cx| viewer.set_cpu_frame(Some(frame), cx));
}
}
}
impl Render for ProgramViewerPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.sync_frame(cx);
let colors = cx.default_colors().clone();
let format = self
.engine
+25 -2
View File
@@ -20,7 +20,8 @@
use gpui::colors::DefaultColors;
use gpui::dock::{DockPanel, PanelEvent};
use gpui::{
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window,
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString,
Window,
};
use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
@@ -33,6 +34,9 @@ use crate::panels::ids::SOURCE_VIEWER;
pub struct SourceViewerPanel {
viewer: Entity<ViewerWidget<MockClock>>,
engine: Entity<MockEngine>,
/// The last CPU frame handed to the viewer (compared by `Arc` identity so
/// a paused playhead does not re-upload the picture every frame).
last_cpu_frame: Option<std::sync::Arc<gpui::RenderImage>>,
}
impl SourceViewerPanel {
@@ -56,12 +60,31 @@ impl SourceViewerPanel {
})
.detach();
Self { viewer, engine }
Self {
viewer,
engine,
last_cpu_frame: None,
}
}
/// Pushes the engine's synthetic test frame into the viewer, but only when
/// it actually changed (the engine caches one image per playhead frame).
fn sync_frame(&mut self, cx: &mut Context<Self>) {
let frame = self.engine.read(cx).cpu_frame(Monitor::Source, cx);
if self.last_cpu_frame.as_ref().is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
{
self.last_cpu_frame = Some(frame.clone());
let frame = frame.clone();
self.viewer
.update(cx, |viewer, cx| viewer.set_cpu_frame(Some(frame), cx));
}
}
}
impl Render for SourceViewerPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.sync_frame(cx);
let colors = cx.default_colors().clone();
let format = self
.engine