feat(storage,app): PostgreSQL backend (D3) + project manager window (D4)
D3: oakdb+pg:// fully wired (shared sea-orm entities, BIGSERIAL DDL, connect-probe instead of pool retry on dead servers); Storage/Backend=pg + Storage/PgUrl config; 13 OAK_TEST_PG_URL-gated PG tests (verified against a Docker postgres:16), always-on clean-error tests otherwise. D4: DaVinci-style project manager — list with derived stats, create/ rename/duplicate/delete (confirm)/import/export (native dialogs, ove/otio/fcpxml), shown at startup and from the file menu; facade oakengine_library_* exports (list/create/delete/rename/duplicate/ import/export + project_load_library that binds write-through); save/save-as menu becomes 'export project file', open splits into from-library/from-file; status bar shows library write state; storage activates on app start and flushes on exit; spawn_modal reentrancy fixed (window-callback path) with a doc note. Also: the P1 audio test's environment probe was lost in the ffi purge; restored on cpal (the output device is cpal now).
This commit is contained in:
Generated
+1
@@ -4744,6 +4744,7 @@ dependencies = [
|
||||
"gpui_widgets",
|
||||
"image",
|
||||
"oakengine",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ smallvec = "1"
|
||||
# Editable-text widget (used by the file / export dialogs' path fields, the
|
||||
# same gpui-elements crate gpui_widgets builds on).
|
||||
gpui_elements = { path = "gpui/crates/gpui_elements" }
|
||||
# The library list crosses the facade C ABI as JSON
|
||||
# (`oakengine_library_list`, M13 D4); Value-only parsing, no derive.
|
||||
serde_json = "1"
|
||||
|
||||
[build-dependencies]
|
||||
# The real engine is NOT linked as an rlib: the app binds only the frozen
|
||||
|
||||
@@ -454,10 +454,43 @@ mod tests {
|
||||
fn output_callback_consumes_pushed_samples() {
|
||||
// Skip when the audio system cannot actually run a stream: open a
|
||||
// silent stream and require at least one callback within 2 s. A
|
||||
// device existing is not enough — headless sessions report
|
||||
// is_active=true while delivering zero callbacks.
|
||||
// device existing is not enough — headless sessions report the
|
||||
// stream running while delivering zero callbacks.
|
||||
use std::sync::atomic::AtomicI64 as A;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
static PROBE: A = A::new(0);
|
||||
PROBE.store(0, Ordering::Relaxed);
|
||||
let can_play = (|| {
|
||||
let host = cpal::default_host();
|
||||
let device = host.default_output_device()?;
|
||||
let config = device.default_output_config().ok()?;
|
||||
let cb = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
PROBE.fetch_add(data.len() as i64, Ordering::Relaxed);
|
||||
for s in data.iter_mut() {
|
||||
*s = 0.0;
|
||||
}
|
||||
};
|
||||
let stream = device
|
||||
.build_output_stream(
|
||||
config.config(),
|
||||
cb,
|
||||
|_| {},
|
||||
None,
|
||||
)
|
||||
.ok()?;
|
||||
stream.play().ok()?;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
while std::time::Instant::now() < deadline && PROBE.load(Ordering::Relaxed) == 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
let got = PROBE.load(Ordering::Relaxed) > 0;
|
||||
drop(stream);
|
||||
Some(got)
|
||||
})();
|
||||
if can_play != Some(true) {
|
||||
eprintln!("audio session cannot deliver callbacks; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
DESTROYED.store(false, Ordering::SeqCst);
|
||||
let manager = MANAGER.get_or_init(|| Mutex::new(ManagerInner::default()));
|
||||
|
||||
@@ -74,6 +74,7 @@ pub mod handle;
|
||||
pub mod ipc;
|
||||
#[cfg(not(test))]
|
||||
pub mod linkage;
|
||||
pub mod library;
|
||||
pub mod node;
|
||||
pub mod plugin;
|
||||
pub mod pods;
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// 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 project-library manager C ABI (plan M13 §4): list / create / open /
|
||||
//! rename / duplicate / delete / import / export over the oakstorage
|
||||
//! database backend the write-through binds to ([`crate::storage`]).
|
||||
//!
|
||||
//! These exports are additive (D4): the app talks to the engine dylib only
|
||||
//! through the frozen `oakengine_*` surface, so the manager's data source
|
||||
//! crosses the boundary here instead of linking oakstorage directly (which
|
||||
//! would give the app a second copy of the handle/serializer types).
|
||||
//!
|
||||
//! All operations address the configured default library (the same
|
||||
//! `Storage/Backend` + `Storage/SqlitePath` configuration the write-through
|
||||
//! uses); with storage disabled every call fails with `OAKENGINE_E_STATE`
|
||||
//! except [`oakengine_library_list`], which reports an empty library
|
||||
//! (`"[]"`) so the manager window can still open.
|
||||
|
||||
use std::ffi::{c_char, c_int};
|
||||
|
||||
use oakstorage::backend::StorageBackend;
|
||||
use oakstorage::uri::StorageUri;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::handle::{guard, guard_int, read_cstr, write_string, OakEngineProject};
|
||||
use crate::stubs::node as n;
|
||||
|
||||
/// One library row as the project manager shows it: the project metadata
|
||||
/// plus the stats derived from the head state (plan §4).
|
||||
#[derive(serde::Serialize)]
|
||||
struct LibraryRow {
|
||||
/// Library row uuid (the open/duplicate/export selector).
|
||||
uuid: String,
|
||||
/// Display name.
|
||||
name: String,
|
||||
/// Row creation time (unix seconds, UTC).
|
||||
created_at: i64,
|
||||
/// Last-write time (unix seconds, UTC; the manager sort key).
|
||||
modified_at: i64,
|
||||
/// Longest sequence duration, milliseconds.
|
||||
duration_ms: i64,
|
||||
/// Total tracks across all sequences.
|
||||
track_count: i32,
|
||||
/// Total clip blocks.
|
||||
clip_count: i32,
|
||||
/// Total footage nodes.
|
||||
footage_count: i32,
|
||||
}
|
||||
|
||||
/// Map an oakstorage error onto the facade error space (the context string
|
||||
/// is log-only per the error contract).
|
||||
fn map_err(e: oakstorage::error::Error) -> Error {
|
||||
use oakstorage::error::Error as E;
|
||||
match e {
|
||||
E::Invalid => Error::Invalid,
|
||||
E::State => Error::State,
|
||||
E::NotFound => Error::NotFound,
|
||||
E::NoMem => Error::NoMem,
|
||||
other => Error::Failed(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured default library as a parsed URI; [`Error::State`] when
|
||||
/// the write-through backend is disabled or the path does not resolve.
|
||||
fn library() -> Result<StorageUri> {
|
||||
if !crate::storage::storage_enabled() {
|
||||
return Err(Error::State);
|
||||
}
|
||||
let uri = crate::storage::library_uri().ok_or(Error::State)?;
|
||||
StorageUri::parse(&uri).map_err(map_err)
|
||||
}
|
||||
|
||||
/// The library URI selecting one row (`…?project=<uuid>`).
|
||||
fn project_uri(uuid: &str) -> Result<StorageUri> {
|
||||
let uri = library()?;
|
||||
StorageUri::parse(&format!("{}?project={uuid}", uri.to_uri_string())).map_err(map_err)
|
||||
}
|
||||
|
||||
/// Load one library row as an owned project handle (refcount 1).
|
||||
fn load_handle(uuid: &str) -> Result<crate::handle::CHandle> {
|
||||
let uri = project_uri(uuid)?;
|
||||
let result = crate::storage::backend().load(&uri).map_err(map_err)?;
|
||||
if result.project.is_null() {
|
||||
return Err(Error::Failed(format!(
|
||||
"library load of {uuid} returned no project (info code {})",
|
||||
result.version_info
|
||||
)));
|
||||
}
|
||||
Ok(result.project)
|
||||
}
|
||||
|
||||
/// Release an owned handle (refcount 1).
|
||||
fn release(h: crate::handle::CHandle) {
|
||||
if let Some(release) = h.release {
|
||||
unsafe { release(h.ctx) };
|
||||
}
|
||||
}
|
||||
|
||||
/// `oakengine_library_list` — the library rows as a JSON array (buf/size
|
||||
/// convention), most recently modified first. Each row carries the manager
|
||||
/// stats derived from the head state; a row whose stats fail to replay
|
||||
/// degrades to zeros instead of failing the whole list. With storage
|
||||
/// disabled the result is the empty array (`"[]"`), not an error.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_list(buf: *mut c_char, buf_size: c_int) -> c_int {
|
||||
guard_int(|| unsafe {
|
||||
if !crate::storage::storage_enabled() {
|
||||
return Ok(write_string("[]", buf, buf_size));
|
||||
}
|
||||
let uri = library()?;
|
||||
let infos = crate::storage::backend()
|
||||
.list_projects(&uri)
|
||||
.map_err(map_err)?;
|
||||
let mut rows = Vec::with_capacity(infos.len());
|
||||
for info in infos {
|
||||
let stats = crate::storage::backend()
|
||||
.project_stats(&uri, &info.uuid)
|
||||
.unwrap_or_default();
|
||||
rows.push(LibraryRow {
|
||||
uuid: info.uuid,
|
||||
name: info.name,
|
||||
created_at: info.created_at.and_utc().timestamp(),
|
||||
modified_at: info.modified_at.and_utc().timestamp(),
|
||||
duration_ms: stats.duration_ms,
|
||||
track_count: stats.track_count,
|
||||
clip_count: stats.clip_count,
|
||||
footage_count: stats.footage_count,
|
||||
});
|
||||
}
|
||||
let json = serde_json::to_string(&rows)
|
||||
.map_err(|e| Error::Failed(format!("library list encode: {e}")))?;
|
||||
Ok(write_string(&json, buf, buf_size))
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_library_create` — create a blank project named `name` as a
|
||||
/// new library row and report its uuid (buf/size convention on
|
||||
/// `out_uuid`; the return value is the uuid length, negative on error).
|
||||
/// The row lands immediately (one `kind='import'` command), so the
|
||||
/// manager list shows it before the first edit.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_create(
|
||||
name: *const c_char,
|
||||
out_uuid: *mut c_char,
|
||||
out_size: c_int,
|
||||
) -> c_int {
|
||||
guard_int(|| unsafe {
|
||||
if name.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let name = read_cstr(name);
|
||||
if name.trim().is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let uri = library()?;
|
||||
|
||||
let mut h = n::oaknode_project_init();
|
||||
if h.is_null() {
|
||||
return Err(Error::NoMem);
|
||||
}
|
||||
let outcome = (|| -> Result<String> {
|
||||
Error::from_module(n::oaknode_project_initialize(h))?;
|
||||
let uuid = {
|
||||
let arc = crate::handle::domain::project_of(&h).ok_or(Error::Invalid)?;
|
||||
let mut guard = arc.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.settings.insert("projectname".to_string(), name);
|
||||
guard.uuid.clone()
|
||||
};
|
||||
crate::storage::backend().save(h, &uri, 0).map_err(map_err)?;
|
||||
Ok(uuid)
|
||||
})();
|
||||
n::oaknode_project_free(&mut h);
|
||||
let uuid = outcome?;
|
||||
Ok(write_string(&uuid, out_uuid, out_size))
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_library_delete` — delete the library row `uuid` (cascades
|
||||
/// settings / snapshots / journal; `OAKENGINE_E_NOT_FOUND` when absent).
|
||||
/// The manager confirms with the user before calling.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_delete(uuid: *const c_char) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if uuid.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let uuid = read_cstr(uuid);
|
||||
if uuid.is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
crate::storage::backend()
|
||||
.delete_project(&library()?, &uuid)
|
||||
.map_err(map_err)
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_library_rename` — rename the library row `uuid` (the
|
||||
/// manager's list name; the in-project `projectname` setting is
|
||||
/// untouched).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_rename(
|
||||
uuid: *const c_char,
|
||||
name: *const c_char,
|
||||
) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if uuid.is_null() || name.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let (uuid, name) = (read_cstr(uuid), read_cstr(name));
|
||||
if uuid.is_empty() || name.trim().is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
crate::storage::backend()
|
||||
.rename_project(&library()?, &uuid, name.trim())
|
||||
.map_err(map_err)
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_library_duplicate` — copy the library row `uuid` (settings,
|
||||
/// snapshots and the full journal history included) under a fresh uuid,
|
||||
/// reporting the new row's uuid (buf/size convention on `out_uuid`; the
|
||||
/// return value is the uuid length, negative on error).
|
||||
/// `name` is the copy's display name; NULL/empty defaults to
|
||||
/// `<name> (copy)`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_duplicate(
|
||||
uuid: *const c_char,
|
||||
name: *const c_char,
|
||||
out_uuid: *mut c_char,
|
||||
out_size: c_int,
|
||||
) -> c_int {
|
||||
guard_int(|| unsafe {
|
||||
if uuid.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let uuid = read_cstr(uuid);
|
||||
if uuid.is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let name = read_cstr(name);
|
||||
let name = match name.trim() {
|
||||
"" => None,
|
||||
trimmed => Some(trimmed),
|
||||
};
|
||||
let info = crate::storage::backend()
|
||||
.duplicate_project(&library()?, &uuid, name)
|
||||
.map_err(map_err)?;
|
||||
Ok(write_string(&info.uuid, out_uuid, out_size))
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_library_import` — import a `.ove` / `.otio` / `.fcpxml`
|
||||
/// project file as a new library row (the file backend parses it, a fresh
|
||||
/// uuid is assigned, and the first save journals the whole project as one
|
||||
/// `kind='import'` command). Reports the new row's uuid (buf/size
|
||||
/// convention on `out_uuid`; the return value is the uuid length,
|
||||
/// negative on error).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_import(
|
||||
path: *const c_char,
|
||||
out_uuid: *mut c_char,
|
||||
out_size: c_int,
|
||||
) -> c_int {
|
||||
guard_int(|| unsafe {
|
||||
if path.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let path = read_cstr(path);
|
||||
if path.is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let file_uri = StorageUri::parse(&path).map_err(map_err)?;
|
||||
let uuid = crate::storage::backend()
|
||||
.import_from_file(&library()?, &file_uri)
|
||||
.map_err(map_err)?;
|
||||
Ok(write_string(&uuid, out_uuid, out_size))
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_library_export` — export the library row `uuid` to the file
|
||||
/// `path`; the format is dispatched by extension through the oakstorage
|
||||
/// registry (`.ove` / `.ovexml` → ove-xml, `.otio` / `.fcpxml` → the
|
||||
/// interchange backend). Nothing is written back to the library.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_library_export(
|
||||
uuid: *const c_char,
|
||||
path: *const c_char,
|
||||
) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if uuid.is_null() || path.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let (uuid, path) = (read_cstr(uuid), read_cstr(path));
|
||||
if uuid.is_empty() || path.is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let file_uri = StorageUri::parse(&path).map_err(map_err)?;
|
||||
if file_uri.scheme != "file" {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let handle = load_handle(&uuid)?;
|
||||
let backend = oakstorage::registry::Registry::global()
|
||||
.resolve(&file_uri)
|
||||
.map_err(map_err)?;
|
||||
let result = backend.save(handle, &file_uri, 0).map_err(map_err);
|
||||
release(handle);
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_project_load_library` — load the library row `uuid` into a
|
||||
/// fresh project shell (same contract as `oakengine_project_load`: the
|
||||
/// shell must carry no content). On success the undo stack is cleared, the
|
||||
/// modified flag is reset, and the project is bound to the library session
|
||||
/// (the write-through continues the row's journal from its head seq).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_project_load_library(
|
||||
self_: *mut OakEngineProject,
|
||||
uuid: *const c_char,
|
||||
err: *mut c_char,
|
||||
err_size: c_int,
|
||||
) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if self_.is_null() || uuid.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let h = crate::handle::unbox(self_)?;
|
||||
if !n::oaknode_project_root(h).is_null() {
|
||||
return Err(Error::State);
|
||||
}
|
||||
let uuid = read_cstr(uuid);
|
||||
if uuid.is_empty() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let loaded = match load_handle(&uuid) {
|
||||
Ok(handle) => handle,
|
||||
Err(e) => {
|
||||
write_string(&e.to_string(), err, err_size);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Swap the loaded content into the caller's shell box, releasing
|
||||
// the empty shell handle the box was created with.
|
||||
let mut old = (*self_).handle;
|
||||
(*self_).handle = loaded;
|
||||
n::oaknode_project_free(&mut old);
|
||||
crate::undo::oakengine_undo_clear();
|
||||
Error::from_module(n::oaknode_project_set_modified(loaded, 0))?;
|
||||
crate::storage::bind_project(loaded);
|
||||
if !err.is_null() && err_size > 0 {
|
||||
*err = 0;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -41,9 +41,9 @@
|
||||
//! The library is selected from the `Storage` config group (all defaults
|
||||
//! are config-driven, plan §5):
|
||||
//!
|
||||
//! - `Storage/Backend` — `"sqlite"` (the documented default value) or
|
||||
//! `"database"` enable the write-through; any other value (e.g.
|
||||
//! `"off"`) disables it. When the key is absent, no library is
|
||||
//! - `Storage/Backend` — `"sqlite"` (the documented default value),
|
||||
//! `"database"` or `"pg"` enable the write-through; any other value
|
||||
//! (e.g. `"off"`) disables it. When the key is absent, no library is
|
||||
//! configured: projects stay unbound and the undo path runs without
|
||||
//! touching a database (graceful degradation — this is what keeps
|
||||
//! headless consumers and the test suite from writing to the user's
|
||||
@@ -52,6 +52,12 @@
|
||||
//! storage is enabled) `<system data directory>/library.db` (the same
|
||||
//! location `FileFunctions::get_configuration_location` derives,
|
||||
//! honoring `OAK_CONFIG_DIR` and portable mode).
|
||||
//! - `Storage/PgUrl` — the PostgreSQL connection string (plan D3), used
|
||||
//! when `Storage/Backend` is `"pg"`: `user:pass@host:5432/dbname`
|
||||
//! (libpq URL form; an optional `postgres://`/`postgresql://` scheme is
|
||||
//! accepted and stripped). The resolved library URI is
|
||||
//! `oakdb+pg://<PgUrl>`. When `Backend = "pg"` but `PgUrl` is absent
|
||||
//! or empty, no library is configured (same graceful degradation).
|
||||
//!
|
||||
//! ## Snapshot thread and exit flush
|
||||
//!
|
||||
@@ -101,9 +107,9 @@ struct Binding {
|
||||
}
|
||||
|
||||
/// The process-wide oakstorage backend (shared by the UI thread's
|
||||
/// write-throughs and the snapshot thread; the backend serializes its own
|
||||
/// operations).
|
||||
fn backend() -> &'static DatabaseBackend {
|
||||
/// write-throughs, the snapshot thread and the library manager exports in
|
||||
/// [`crate::library`]; the backend serializes its own operations).
|
||||
pub(crate) fn backend() -> &'static DatabaseBackend {
|
||||
static BACKEND: OnceLock<DatabaseBackend> = OnceLock::new();
|
||||
BACKEND.get_or_init(DatabaseBackend::new)
|
||||
}
|
||||
@@ -291,18 +297,18 @@ fn record_error(key: usize, message: &str) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Whether the write-through backend is enabled (config-driven, plan §5):
|
||||
/// `Storage/Backend` set to `"sqlite"` or `"database"` enables it; any
|
||||
/// other explicit value (e.g. `"off"`) disables it. When NO `Storage`
|
||||
/// configuration is present the backend is NOT enabled — "no library
|
||||
/// configured" degrades gracefully to plain unbound projects, which keeps
|
||||
/// headless consumers (the CLI) and the test suite from ever writing to
|
||||
/// the user's default library. The documented default *values* are
|
||||
/// `Backend = "sqlite"` and `SqlitePath = <system data dir>/library.db`
|
||||
/// `Storage/Backend` set to `"sqlite"`, `"database"` or `"pg"` enables
|
||||
/// it; any other explicit value (e.g. `"off"`) disables it. When NO
|
||||
/// `Storage` configuration is present the backend is NOT enabled — "no
|
||||
/// library configured" degrades gracefully to plain unbound projects,
|
||||
/// which keeps headless consumers (the CLI) and the test suite from ever
|
||||
/// writing to the user's default library. The documented default *values*
|
||||
/// are `Backend = "sqlite"` and `SqlitePath = <system data dir>/library.db`
|
||||
/// (used once storage is enabled without an explicit path).
|
||||
pub(crate) fn storage_enabled() -> bool {
|
||||
let store = oakcommon::configstore::ConfigStore::instance();
|
||||
match store.get(Some("Storage"), "Backend") {
|
||||
Ok(b) => b == "sqlite" || b == "database",
|
||||
Ok(b) => b == "sqlite" || b == "database" || b == "pg",
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
@@ -316,6 +322,29 @@ fn configured_sqlite_path() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured PostgreSQL connection string (`Storage/PgUrl`; empty
|
||||
/// value = not configured).
|
||||
fn configured_pg_url() -> Option<String> {
|
||||
let store = oakcommon::configstore::ConfigStore::instance();
|
||||
match store.get(Some("Storage"), "PgUrl") {
|
||||
Ok(u) if !u.trim().is_empty() => Some(u.trim().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `oakdb+pg://…` uri of the configured PostgreSQL library (None
|
||||
/// when `Storage/PgUrl` is absent). A `postgres://`/`postgresql://`
|
||||
/// scheme on the config value is stripped — the oakdb uri body is the
|
||||
/// bare connection string (`user:pass@host:5432/dbname`).
|
||||
fn pg_library_uri() -> Option<String> {
|
||||
let url = configured_pg_url()?;
|
||||
let body = url
|
||||
.strip_prefix("postgres://")
|
||||
.or_else(|| url.strip_prefix("postgresql://"))
|
||||
.unwrap_or(&url);
|
||||
Some(format!("oakdb+pg://{body}"))
|
||||
}
|
||||
|
||||
/// The default library file: `<system data directory>/library.db`, where
|
||||
/// the data directory is the standard per-user location
|
||||
/// (`FileFunctions::get_configuration_location`: macOS Application
|
||||
@@ -327,9 +356,14 @@ pub(crate) fn default_library_path() -> String {
|
||||
format!("{}/library.db", dir)
|
||||
}
|
||||
|
||||
/// The `oakdb+sqlite://…` uri of the configured library (None when the
|
||||
/// path cannot be made absolute).
|
||||
fn library_uri() -> Option<String> {
|
||||
/// The `oakdb+…` uri of the configured library (None when the path
|
||||
/// cannot be made absolute, or the PG url is missing). Shared with the
|
||||
/// library manager exports in [`crate::library`].
|
||||
pub(crate) fn library_uri() -> Option<String> {
|
||||
let store = oakcommon::configstore::ConfigStore::instance();
|
||||
if store.get(Some("Storage"), "Backend").ok().as_deref() == Some("pg") {
|
||||
return pg_library_uri();
|
||||
}
|
||||
let path = match configured_sqlite_path() {
|
||||
Some(p) => p,
|
||||
None => default_library_path(),
|
||||
@@ -580,4 +614,47 @@ mod tests {
|
||||
assert!(!storage_enabled());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_uri_resolves_pg_config() {
|
||||
use oakcommon::configstore::ConfigStore;
|
||||
let store = ConfigStore::instance();
|
||||
let _g = crate::tests::common::STORAGE_CONFIG_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
// Backend = "pg" yields an oakdb+pg uri from Storage/PgUrl; a
|
||||
// postgres:// scheme on the config value is stripped.
|
||||
store.set(Some("Storage"), "Backend", "pg");
|
||||
store.set(
|
||||
Some("Storage"),
|
||||
"PgUrl",
|
||||
"postgres://user:pass@host:5432/oak",
|
||||
);
|
||||
assert!(storage_enabled());
|
||||
assert_eq!(
|
||||
library_uri().as_deref(),
|
||||
Some("oakdb+pg://user:pass@host:5432/oak")
|
||||
);
|
||||
|
||||
// postgresql:// is accepted too.
|
||||
store.set(
|
||||
Some("Storage"),
|
||||
"PgUrl",
|
||||
"postgresql://u@h/db?sslmode=disable",
|
||||
);
|
||||
assert_eq!(
|
||||
library_uri().as_deref(),
|
||||
Some("oakdb+pg://u@h/db?sslmode=disable")
|
||||
);
|
||||
|
||||
// Backend = "pg" with no PgUrl = no library (graceful
|
||||
// degradation, same as an absent sqlite path).
|
||||
store.set(Some("Storage"), "PgUrl", "");
|
||||
assert_eq!(library_uri(), None);
|
||||
|
||||
// Leave the store in a safe state.
|
||||
store.set(Some("Storage"), "Backend", "off");
|
||||
assert!(!storage_enabled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! D4 integration tests: the project-library manager C ABI
|
||||
//! (`src/library.rs`, plan M13 §4).
|
||||
//!
|
||||
//! End-to-end against real SQLite library files in temp directories,
|
||||
//! driving the facade exactly like the app's project manager: create lands
|
||||
//! a row immediately, list reports it with the derived stats, open
|
||||
//! (`oakengine_project_load_library`) binds the loaded project to the
|
||||
//! library session (the next undoable edit write-throughs onto the row's
|
||||
//! journal), rename / duplicate / delete / import / export round-trip, and
|
||||
//! the disabled-backend configuration degrades to an empty list + error
|
||||
//! codes.
|
||||
//!
|
||||
//! Every test holds the shared undo-stack lock (the facade's stack is
|
||||
//! process-wide, same as the it_undo / it_storage families) and the
|
||||
//! storage-config lock, so the suite never races on either singleton.
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::common;
|
||||
use super::it_undo::GLOBAL_STACK_LOCK;
|
||||
|
||||
use crate::error::{OAKENGINE_OK, OAKENGINE_E_INVALID, OAKENGINE_E_NOT_FOUND, OAKENGINE_E_STATE};
|
||||
use crate::handle::OakEngineProject;
|
||||
|
||||
/// The math node type id (a factory type the tests add as an undoable
|
||||
/// edit; footage is not factory-creatable, so the write-through is
|
||||
/// verified on the journal rows directly).
|
||||
const MATH: &str = "org.olivevideoeditor.Olive.math";
|
||||
|
||||
/// The journal row count of a library row (direct sea-orm read, the same
|
||||
/// pattern as the it_storage tests).
|
||||
fn journal_rows(db: &Path, uuid: &str) -> usize {
|
||||
use sea_orm::entity::prelude::*;
|
||||
use oakstorage::backends::database::entities::{journal, project};
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
let conn = sea_orm::Database::connect(format!("sqlite://{}?mode=ro", db.display()))
|
||||
.await
|
||||
.unwrap();
|
||||
let model = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(uuid))
|
||||
.one(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("the row exists");
|
||||
journal::Entity::find()
|
||||
.filter(journal::Column::ProjectId.eq(model.id))
|
||||
.all(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.len()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Serialize a library test: hold the process-global undo-stack lock AND
|
||||
/// the storage-config lock for the whole body, then point the library at a
|
||||
/// temp SQLite file.
|
||||
fn with_library<R>(db: &Path, f: impl FnOnce() -> R) -> R {
|
||||
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _config = common::STORAGE_CONFIG_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let store = oakcommon::configstore::ConfigStore::instance();
|
||||
store.set(Some("Storage"), "Backend", "sqlite");
|
||||
store.set(Some("Storage"), "SqlitePath", &db.to_string_lossy());
|
||||
f()
|
||||
}
|
||||
|
||||
/// A fresh, unique temp directory for one test.
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("oakengine_library_{}_{}", std::process::id(), tag));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Two-stage string read over a facade `(buf, size)` getter.
|
||||
fn read_string(f: impl Fn(*mut std::ffi::c_char, i32) -> i32) -> String {
|
||||
let needed = f(std::ptr::null_mut(), 0);
|
||||
if needed <= 0 {
|
||||
return String::new();
|
||||
}
|
||||
let mut buf = vec![0 as std::ffi::c_char; needed as usize + 1];
|
||||
f(buf.as_mut_ptr(), needed + 1);
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// The library list JSON.
|
||||
fn list_json() -> String {
|
||||
read_string(|buf, size| unsafe { crate::library::oakengine_library_list(buf, size) })
|
||||
}
|
||||
|
||||
/// Create a library row; returns its uuid. The create export has a side
|
||||
/// effect, so it is called ONCE with a stack buffer (never two-stage).
|
||||
fn create(name: &str) -> String {
|
||||
let name = CString::new(name).unwrap();
|
||||
let mut buf = [0 as std::ffi::c_char; 256];
|
||||
let rc = unsafe { crate::library::oakengine_library_create(name.as_ptr(), buf.as_mut_ptr(), 256) };
|
||||
assert!(rc > 0, "create {name:?} rc={rc}");
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// Duplicate a library row (single call with a stack buffer, see
|
||||
/// [`create`]).
|
||||
fn duplicate(uuid: &str) -> String {
|
||||
let uuid = CString::new(uuid).unwrap();
|
||||
let mut buf = [0 as std::ffi::c_char; 256];
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_duplicate(
|
||||
uuid.as_ptr(),
|
||||
std::ptr::null(),
|
||||
buf.as_mut_ptr(),
|
||||
256,
|
||||
)
|
||||
};
|
||||
assert!(rc > 0, "duplicate rc={rc}");
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// The uuids in the library list JSON (order preserved).
|
||||
fn list_uuids(json: &str) -> Vec<String> {
|
||||
serde_json::from_str::<serde_json::Value>(json)
|
||||
.expect("list is JSON")
|
||||
.as_array()
|
||||
.expect("list is an array")
|
||||
.iter()
|
||||
.map(|row| {
|
||||
row.get("uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("row uuid")
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One row of the library list JSON by uuid.
|
||||
fn list_row<'a>(json: &'a str, uuid: &str) -> Option<serde_json::Value> {
|
||||
serde_json::from_str::<serde_json::Value>(json)
|
||||
.expect("list is JSON")
|
||||
.as_array()
|
||||
.expect("list is an array")
|
||||
.iter()
|
||||
.find(|row| row.get("uuid").and_then(|v| v.as_str()) == Some(uuid))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Open a library row into a fresh facade project shell.
|
||||
fn open(uuid: &str) -> *mut OakEngineProject {
|
||||
let project = unsafe { crate::node::oakengine_project_create() };
|
||||
assert!(!project.is_null());
|
||||
let uuid_c = CString::new(uuid).unwrap();
|
||||
let mut err = [0 as std::ffi::c_char; 4096];
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_project_load_library(
|
||||
project,
|
||||
uuid_c.as_ptr(),
|
||||
err.as_mut_ptr(),
|
||||
err.len() as i32,
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_OK, "open {uuid}");
|
||||
project
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create lands a row immediately; list reports it with the metadata and
|
||||
/// the (zero) stats.
|
||||
#[test]
|
||||
fn create_then_list_shows_the_row() {
|
||||
let dir = temp_dir("create");
|
||||
with_library(&dir.join("lib.db"), || {
|
||||
let uuid = create("Demo Reel");
|
||||
assert!(!uuid.is_empty(), "create reports the new uuid");
|
||||
|
||||
let json = list_json();
|
||||
let row = list_row(&json, &uuid).expect("the created row is listed");
|
||||
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Demo Reel"));
|
||||
assert!(row.get("modified_at").and_then(|v| v.as_i64()).unwrap() > 0);
|
||||
assert_eq!(row.get("track_count").and_then(|v| v.as_i64()), Some(0));
|
||||
assert_eq!(row.get("footage_count").and_then(|v| v.as_i64()), Some(0));
|
||||
|
||||
// A second create adds a second row.
|
||||
let other = create("Second");
|
||||
assert_ne!(uuid, other);
|
||||
assert_eq!(list_uuids(&list_json()).len(), 2);
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Opening a library row binds the project to the library session: the
|
||||
/// next undoable edit write-throughs onto the row's journal.
|
||||
#[test]
|
||||
fn open_binds_and_write_through_advances_the_row() {
|
||||
let dir = temp_dir("open");
|
||||
let db = dir.join("lib.db");
|
||||
with_library(&db, || {
|
||||
let uuid = create("Editable");
|
||||
let before = journal_rows(&db, &uuid);
|
||||
let project = open(&uuid);
|
||||
assert_eq!(
|
||||
unsafe { crate::storage::oakengine_storage_is_bound(project) },
|
||||
1,
|
||||
"the library-opened project is bound"
|
||||
);
|
||||
|
||||
// An undoable edit write-throughs.
|
||||
let node = unsafe {
|
||||
crate::node::oakengine_project_add_node(project, CString::new(MATH).unwrap().as_ptr())
|
||||
};
|
||||
assert!(!node.is_null());
|
||||
unsafe { crate::node::oakengine_node_free(node) };
|
||||
|
||||
let after = journal_rows(&db, &uuid);
|
||||
assert!(
|
||||
after > before,
|
||||
"the edit journaled new rows ({before} -> {after})"
|
||||
);
|
||||
|
||||
// The row name comes from the projectname setting (the facade's
|
||||
// project name is filename-derived, so a library project displays
|
||||
// "(untitled)"; the app overrides it with the row name).
|
||||
let row = list_row(&list_json(), &uuid).expect("row after the edit");
|
||||
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Editable"));
|
||||
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Rename and duplicate keep the list coherent; delete removes the row and
|
||||
/// opening it afterwards fails.
|
||||
#[test]
|
||||
fn rename_duplicate_delete() {
|
||||
let dir = temp_dir("rdd");
|
||||
with_library(&dir.join("lib.db"), || {
|
||||
let uuid = create("Original");
|
||||
|
||||
// Rename.
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_rename(
|
||||
CString::new(uuid.clone()).unwrap().as_ptr(),
|
||||
CString::new("Renamed").unwrap().as_ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_OK);
|
||||
let row = list_row(&list_json(), &uuid).expect("renamed row");
|
||||
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Renamed"));
|
||||
|
||||
// Duplicate (default "<name> (copy)" name).
|
||||
let copy = duplicate(&uuid);
|
||||
assert_ne!(copy, uuid);
|
||||
let row = list_row(&list_json(), ©).expect("the copy is listed");
|
||||
assert_eq!(
|
||||
row.get("name").and_then(|v| v.as_str()),
|
||||
Some("Renamed (copy)")
|
||||
);
|
||||
|
||||
// The copy opens (its journal history came along).
|
||||
let project = open(©);
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
|
||||
// Delete the copy; opening it afterwards fails.
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_delete(CString::new(copy.clone()).unwrap().as_ptr())
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_OK);
|
||||
assert!(!list_uuids(&list_json()).contains(©));
|
||||
let shell = unsafe { crate::node::oakengine_project_create() };
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_project_load_library(
|
||||
shell,
|
||||
CString::new(copy).unwrap().as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_E_NOT_FOUND, "a deleted row does not open");
|
||||
unsafe { crate::node::oakengine_project_free(shell) };
|
||||
|
||||
// Unknown uuids are E_NOT_FOUND; empty arguments E_INVALID.
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_delete(CString::new("{no-such}").unwrap().as_ptr())
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_E_NOT_FOUND);
|
||||
let rc = unsafe { crate::library::oakengine_library_delete(c"".as_ptr()) };
|
||||
assert_eq!(rc, OAKENGINE_E_INVALID);
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Export writes the row's head state to a file (dispatched by extension),
|
||||
/// and import brings a file back as a new library row that opens.
|
||||
#[test]
|
||||
fn export_then_import_round_trip() {
|
||||
let dir = temp_dir("xport");
|
||||
with_library(&dir.join("lib.db"), || {
|
||||
// A row with one node, so the exported file has content.
|
||||
let uuid = create("Exchange");
|
||||
let project = open(&uuid);
|
||||
let node = unsafe {
|
||||
crate::node::oakengine_project_add_node(project, CString::new(MATH).unwrap().as_ptr())
|
||||
};
|
||||
assert!(!node.is_null());
|
||||
unsafe { crate::node::oakengine_node_free(node) };
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
|
||||
// Export as .ove and as .otio.
|
||||
let ove = dir.join("out.ove");
|
||||
let otio = dir.join("out.otio");
|
||||
for path in [&ove, &otio] {
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_export(
|
||||
CString::new(uuid.clone()).unwrap().as_ptr(),
|
||||
CString::new(path.to_string_lossy().into_owned()).unwrap().as_ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_OK, "export {}", path.display());
|
||||
assert!(path.exists(), "{} exists", path.display());
|
||||
}
|
||||
let xml = std::fs::read_to_string(&ove).unwrap();
|
||||
assert!(xml.contains("<project"), "ove payload:\n{xml}");
|
||||
|
||||
// Import the .ove back as a new row and open it (single call with a
|
||||
// stack buffer — the import export has a side effect).
|
||||
let path_c = CString::new(ove.to_string_lossy().into_owned()).unwrap();
|
||||
let mut buf = [0 as std::ffi::c_char; 256];
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_import(path_c.as_ptr(), buf.as_mut_ptr(), 256)
|
||||
};
|
||||
assert!(rc > 0, "import rc={rc}");
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
let imported =
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
|
||||
.into_owned();
|
||||
assert!(!imported.is_empty(), "import reports the new uuid");
|
||||
assert_ne!(imported, uuid, "import assigns a fresh uuid");
|
||||
let project = open(&imported);
|
||||
// The projectname setting round-trips into the imported row's name.
|
||||
let row = list_row(&list_json(), &imported).expect("the imported row is listed");
|
||||
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Exchange"));
|
||||
unsafe { crate::node::oakengine_project_free(project) };
|
||||
|
||||
// Importing a missing file fails.
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_import(
|
||||
CString::new(dir.join("nope.ove").to_string_lossy().into_owned())
|
||||
.unwrap()
|
||||
.as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
assert!(rc < 0, "a missing file does not import");
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// With the backend disabled the list is empty (not an error) and every
|
||||
/// mutating call fails with E_STATE.
|
||||
#[test]
|
||||
fn disabled_backend_degrades_gracefully() {
|
||||
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _off = common::storage_off_guard();
|
||||
|
||||
assert_eq!(list_json(), "[]", "no library configured reads as empty");
|
||||
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_library_create(
|
||||
c"Nope".as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_E_STATE);
|
||||
let rc = unsafe { crate::library::oakengine_library_delete(c"{x}".as_ptr()) };
|
||||
assert_eq!(rc, OAKENGINE_E_STATE);
|
||||
let rc = unsafe { crate::library::oakengine_library_export(c"{x}".as_ptr(), c"/tmp/x.ove".as_ptr()) };
|
||||
assert_eq!(rc, OAKENGINE_E_STATE);
|
||||
|
||||
let shell = unsafe { crate::node::oakengine_project_create() };
|
||||
let rc = unsafe {
|
||||
crate::library::oakengine_project_load_library(
|
||||
shell,
|
||||
c"{x}".as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, OAKENGINE_E_STATE);
|
||||
unsafe { crate::node::oakengine_project_free(shell) };
|
||||
}
|
||||
@@ -41,6 +41,7 @@ mod it_audio;
|
||||
mod it_codec;
|
||||
mod it_common;
|
||||
mod it_export;
|
||||
mod it_library;
|
||||
mod it_plugin;
|
||||
mod it_storage;
|
||||
mod it_task;
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
//!
|
||||
//! One module per entity: the `DeriveEntityModel` macro generates
|
||||
//! `Entity`/`Column`/`Model`/`ActiveModel` in the defining module, so
|
||||
//! each table gets its own scope. The same definitions serve SQLite
|
||||
//! today and PostgreSQL (D3) — the column types (`i64`, `String`,
|
||||
//! `Option<String>`, `NaiveDateTime`) map to BIGINT/TEXT/TIMESTAMP on
|
||||
//! both.
|
||||
//! each table gets its own scope. The same definitions serve SQLite and
|
||||
//! PostgreSQL — the column types (`i64`, `String`, `Option<String>`,
|
||||
//! `NaiveDateTime`) map to BIGINT/TEXT/TIMESTAMP on both, so the shared
|
||||
//! save/replay logic never branches on the dialect.
|
||||
|
||||
pub mod journal;
|
||||
pub mod project;
|
||||
|
||||
@@ -60,22 +60,53 @@ const SQLITE_DDL: &[&str] = &[
|
||||
)",
|
||||
];
|
||||
|
||||
/// Apply the schema (idempotent; safe to run on every connection open).
|
||||
///
|
||||
/// PostgreSQL (D3) gets its own `BIGSERIAL` DDL — same shape, currently
|
||||
/// unreachable because the backend rejects `oakdb+pg://` before any
|
||||
/// connection is opened.
|
||||
/// PostgreSQL DDL for the four tables — the same shape with the PG
|
||||
/// dialect differences (plan D3: BIGSERIAL for the surrogate key,
|
||||
/// BIGINT for the FKs; `TEXT` everywhere, matching the SQLite TEXT
|
||||
/// decision — no BYTEA). `CREATE TABLE IF NOT EXISTS` makes it
|
||||
/// idempotent, exactly like the SQLite side.
|
||||
const POSTGRES_DDL: &[&str] = &[
|
||||
"CREATE TABLE IF NOT EXISTS projects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
schema_ver INTEGER NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
modified_at TIMESTAMP NOT NULL,
|
||||
command_seq BIGINT NOT NULL DEFAULT 0
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (project_id, key)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS snapshots (
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
command_seq BIGINT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
written_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (project_id, command_seq)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS journal (
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
seq BIGINT NOT NULL,
|
||||
node_identity BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
old_xml TEXT,
|
||||
new_xml TEXT,
|
||||
at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (project_id, seq, node_identity)
|
||||
)",
|
||||
];
|
||||
|
||||
/// Apply the schema for the connection's dialect (idempotent; safe to
|
||||
/// run on every connection open).
|
||||
pub async fn migrate(db: &impl ConnectionTrait) -> crate::error::Result<()> {
|
||||
let backend = db.get_database_backend();
|
||||
let statements: &[&str] = match backend {
|
||||
DatabaseBackend::Sqlite => SQLITE_DDL,
|
||||
// D3: `BIGSERIAL PRIMARY KEY` for projects.id, `BIGINT` for the
|
||||
// FKs — the rest of the column set is identical.
|
||||
DatabaseBackend::Postgres => {
|
||||
return Err(crate::error::Error::Failed(
|
||||
"oakdb+pg is a D3 milestone; the PostgreSQL schema is not wired yet".to_string(),
|
||||
));
|
||||
}
|
||||
DatabaseBackend::Postgres => POSTGRES_DDL,
|
||||
other => {
|
||||
return Err(crate::error::Error::Failed(format!(
|
||||
"unsupported database backend {other:?}"
|
||||
|
||||
@@ -14,16 +14,20 @@
|
||||
// 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 database backend (plan M13 §1/§2), SQLite first.
|
||||
//! The database backend (plan M13 §1/§2): SQLite and PostgreSQL share
|
||||
//! one code path — the same sea-orm entities, diff/save/replay logic and
|
||||
//! migrations, with the dialect differences (BIGSERIAL vs AUTOINCREMENT,
|
||||
//! `IF NOT EXISTS` DDL) converged in the connection and migration layer.
|
||||
//!
|
||||
//! URI forms:
|
||||
//! - `oakdb+sqlite:///absolute/path.db[?project=<uuid>]` — local file
|
||||
//! database; the optional `project` query selects the library row to
|
||||
//! load (default: the most recently modified project).
|
||||
//! - `oakdb+pg://user:pass@host:5432/db[?project=<uuid>]` — PostgreSQL
|
||||
//! (D3). The target is parsed into [`DbTarget::Pg`] today, but every
|
||||
//! operation rejects it with [`crate::error::Error::NoBackend`] until
|
||||
//! the D3 client lands.
|
||||
//! - `oakdb+pg://user:pass@host:5432/db[?project=<uuid>]` — PostgreSQL;
|
||||
//! the body is the libpq connection string without a scheme. A `?…`
|
||||
//! part is the `?project=` selector only when it carries a `project=`
|
||||
//! key — a bare query (e.g. `?sslmode=disable`) belongs to the
|
||||
//! connection string and is passed through untouched.
|
||||
//!
|
||||
//! The async sea-orm API is driven by a private current-thread tokio
|
||||
//! runtime so the public surface stays synchronous (plan §6: "后端内嵌
|
||||
@@ -104,9 +108,11 @@ pub(crate) enum DbTarget {
|
||||
/// `?project=` uuid; `None` = most recently modified row.
|
||||
project: Option<String>,
|
||||
},
|
||||
/// PostgreSQL (parsed for D3; rejected until then).
|
||||
/// PostgreSQL server.
|
||||
Pg {
|
||||
/// Connection string (`user:pass@host:port/db`).
|
||||
/// Connection string body (`user:pass@host:port/db`, without a
|
||||
/// scheme; may carry its own `?param=…` query when it does not
|
||||
/// contain a `project=` key).
|
||||
conn: String,
|
||||
/// `?project=` uuid; `None` = most recently modified row.
|
||||
project: Option<String>,
|
||||
@@ -114,20 +120,6 @@ pub(crate) enum DbTarget {
|
||||
}
|
||||
|
||||
impl DbTarget {
|
||||
/// True for the PostgreSQL variant (all ops return E_NO_BACKEND
|
||||
/// until D3).
|
||||
fn is_pg(&self) -> bool {
|
||||
matches!(self, DbTarget::Pg { .. })
|
||||
}
|
||||
|
||||
/// The SQLite path (`None` for PG).
|
||||
fn sqlite_path(&self) -> Option<&str> {
|
||||
match self {
|
||||
DbTarget::Sqlite { path, .. } => Some(path),
|
||||
DbTarget::Pg { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `?project=` selector.
|
||||
fn project(&self) -> Option<&str> {
|
||||
match self {
|
||||
@@ -138,12 +130,23 @@ impl DbTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection-cache key of a parsed target (the database location —
|
||||
/// an absolute SQLite path or a PostgreSQL connection string body).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
enum DbKey {
|
||||
/// SQLite file database at an absolute path.
|
||||
Sqlite(String),
|
||||
/// PostgreSQL server (connection string body, see [`DbTarget::Pg`]).
|
||||
Pg(String),
|
||||
}
|
||||
|
||||
/// Parse a storage URI into a [`DbTarget`].
|
||||
///
|
||||
/// `oakdb+sqlite://` requires an absolute path (E_INVALID otherwise);
|
||||
/// the body may carry `?project=<uuid>` (no percent-decoding — uuid
|
||||
/// values contain only hex/braces/hyphens). Unknown `oakdb` variants
|
||||
/// are E_NO_BACKEND.
|
||||
/// values contain only hex/braces/hyphens). `oakdb+pg://` requires a
|
||||
/// non-empty, parseable connection string (E_INVALID otherwise). Unknown
|
||||
/// `oakdb` variants are E_NO_BACKEND.
|
||||
pub(crate) fn parse_target(uri: &StorageUri) -> Result<DbTarget> {
|
||||
match uri.scheme.as_str() {
|
||||
"oakdb+sqlite" => {
|
||||
@@ -157,16 +160,37 @@ pub(crate) fn parse_target(uri: &StorageUri) -> Result<DbTarget> {
|
||||
})
|
||||
}
|
||||
"oakdb+pg" => {
|
||||
let (conn, query) = split_query(&uri.body);
|
||||
Ok(DbTarget::Pg {
|
||||
conn: conn.to_string(),
|
||||
project: query_param(query, "project"),
|
||||
})
|
||||
let (base, query) = split_query(&uri.body);
|
||||
// A `?…` part is the `?project=` selector only when it carries
|
||||
// a `project=` key; otherwise (e.g. `?sslmode=disable`) it
|
||||
// belongs to the connection string and the whole body passes
|
||||
// through.
|
||||
let project = match query {
|
||||
Some(q) if query_param(Some(q), "project").is_some() => {
|
||||
query_param(Some(q), "project")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let conn = match &project {
|
||||
Some(_) => base.to_string(),
|
||||
None => uri.body.clone(),
|
||||
};
|
||||
if conn.is_empty() || !valid_pg_conn(&conn) {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
Ok(DbTarget::Pg { conn, project })
|
||||
}
|
||||
_ => Err(Error::NoBackend),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `conn` parses as a PostgreSQL connection string (the libpq
|
||||
/// URL form, `user:pass@host:port/db[?params]`, with a scheme prepended).
|
||||
fn valid_pg_conn(conn: &str) -> bool {
|
||||
use std::str::FromStr;
|
||||
sea_orm::sqlx::postgres::PgConnectOptions::from_str(&format!("postgres://{conn}")).is_ok()
|
||||
}
|
||||
|
||||
/// Split `body` at the first `?` (the query part, if any).
|
||||
fn split_query(body: &str) -> (&str, Option<&str>) {
|
||||
match body.split_once('?') {
|
||||
@@ -310,8 +334,8 @@ pub struct DatabaseBackend {
|
||||
runtime: OnceLock<tokio::runtime::Runtime>,
|
||||
/// Serializes all database operations of this backend.
|
||||
op_lock: Mutex<()>,
|
||||
/// Open connections by database path.
|
||||
connections: Mutex<HashMap<String, DatabaseConnection>>,
|
||||
/// Open connections by database location (path or PG conn string).
|
||||
connections: Mutex<HashMap<DbKey, DatabaseConnection>>,
|
||||
}
|
||||
|
||||
impl DatabaseBackend {
|
||||
@@ -332,10 +356,11 @@ impl DatabaseBackend {
|
||||
.expect("tokio current_thread runtime")
|
||||
}
|
||||
|
||||
/// Run `f` against the database at `key` (an absolute SQLite path).
|
||||
/// The op lock is held for the whole call; the connection is opened
|
||||
/// and migrated on first use, then cached.
|
||||
fn run<T, F, Fut>(&self, key: &str, f: F) -> Result<T>
|
||||
/// Run `f` against the database at `key` (an absolute SQLite path or a
|
||||
/// PostgreSQL connection string). The op lock is held for the whole
|
||||
/// call; the connection is opened and migrated on first use, then
|
||||
/// cached.
|
||||
fn run<T, F, Fut>(&self, key: DbKey, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(DatabaseConnection) -> Fut,
|
||||
Fut: Future<Output = Result<T>>,
|
||||
@@ -343,33 +368,36 @@ impl DatabaseBackend {
|
||||
let _guard = self.op_lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let runtime = self.runtime.get_or_init(Self::build_runtime);
|
||||
runtime.block_on(async {
|
||||
let conn = self.connect_cached(key).await?;
|
||||
let conn = self.connect_cached(&key).await?;
|
||||
f(conn).await
|
||||
})
|
||||
}
|
||||
|
||||
/// Open (and migrate) the SQLite database at `path`, caching the
|
||||
/// connection.
|
||||
/// Open (and migrate) the database at `key`, caching the connection.
|
||||
///
|
||||
/// Two writers racing on a fresh file surface SQLite's `database is
|
||||
/// Two writers racing on a fresh SQLite file surface `database is
|
||||
/// locked` (SQLITE_BUSY) — most often when a fresh connection's WAL
|
||||
/// mode switch collides with the other's, which `busy_timeout` cannot
|
||||
/// wait out — so connect+ migrate are retried a few times (plan §6:
|
||||
/// single-writer is the contract; a clean error beats a silent
|
||||
/// failure).
|
||||
async fn connect_cached(&self, path: &str) -> Result<DatabaseConnection> {
|
||||
/// wait out — so the SQLite connect + migrate are retried a few times
|
||||
/// (plan §6: single-writer is the contract; a clean error beats a
|
||||
/// silent failure). PostgreSQL connections are never retried (their
|
||||
/// errors are not retryable by [`retryable`]).
|
||||
async fn connect_cached(&self, key: &DbKey) -> Result<DatabaseConnection> {
|
||||
if let Some(conn) = self
|
||||
.connections
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(path)
|
||||
.get(key)
|
||||
{
|
||||
return Ok(conn.clone());
|
||||
}
|
||||
let mut attempt = 0;
|
||||
let conn = loop {
|
||||
let result = async {
|
||||
let conn = connect_sqlite(path).await?;
|
||||
let conn = match key {
|
||||
DbKey::Sqlite(path) => connect_sqlite(path).await?,
|
||||
DbKey::Pg(conn) => connect_pg(conn).await?,
|
||||
};
|
||||
migration::migrate(&conn).await?;
|
||||
Ok::<_, Error>(conn)
|
||||
}
|
||||
@@ -386,7 +414,7 @@ impl DatabaseBackend {
|
||||
self.connections
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(path.to_string(), conn.clone());
|
||||
.insert(key.clone(), conn.clone());
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
@@ -396,8 +424,8 @@ impl DatabaseBackend {
|
||||
/// node graph).
|
||||
pub fn list_projects(&self, uri: &StorageUri) -> Result<Vec<ProjectInfo>> {
|
||||
let target = parse_target(uri)?;
|
||||
let path = sqlite_path_of(&target)?;
|
||||
self.run(path, |conn| async move {
|
||||
let key = db_key_of(&target);
|
||||
self.run(key, |conn| async move {
|
||||
let models = project::Entity::find()
|
||||
.order_by_desc(project::Column::ModifiedAt)
|
||||
.all(&conn)
|
||||
@@ -422,9 +450,9 @@ impl DatabaseBackend {
|
||||
/// the project's data is gone — the manager confirms before calling).
|
||||
pub fn delete_project(&self, uri: &StorageUri, uuid: &str) -> Result<()> {
|
||||
let target = parse_target(uri)?;
|
||||
let path = sqlite_path_of(&target)?;
|
||||
let key = db_key_of(&target);
|
||||
let uuid = uuid.to_string();
|
||||
self.run(path, move |conn| async move {
|
||||
self.run(key, move |conn| async move {
|
||||
let tx = conn.begin().await.map_err(db_err)?;
|
||||
let res = project::Entity::delete_many()
|
||||
.filter(project::Column::Uuid.eq(&uuid))
|
||||
@@ -449,10 +477,10 @@ impl DatabaseBackend {
|
||||
new_name: Option<&str>,
|
||||
) -> Result<ProjectInfo> {
|
||||
let target = parse_target(uri)?;
|
||||
let path = sqlite_path_of(&target)?;
|
||||
let key = db_key_of(&target);
|
||||
let uuid = uuid.to_string();
|
||||
let new_name = new_name.map(str::to_string);
|
||||
self.run(path, move |conn| async move {
|
||||
self.run(key, move |conn| async move {
|
||||
let tx = conn.begin().await.map_err(db_err)?;
|
||||
let src = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid))
|
||||
@@ -556,10 +584,10 @@ impl DatabaseBackend {
|
||||
/// in-app project name (`settings["projectname"]`) is untouched.
|
||||
pub fn rename_project(&self, uri: &StorageUri, uuid: &str, new_name: &str) -> Result<()> {
|
||||
let target = parse_target(uri)?;
|
||||
let path = sqlite_path_of(&target)?;
|
||||
let key = db_key_of(&target);
|
||||
let uuid = uuid.to_string();
|
||||
let new_name = new_name.to_string();
|
||||
self.run(path, move |conn| async move {
|
||||
self.run(key, move |conn| async move {
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let tx = conn.begin().await.map_err(db_err)?;
|
||||
let res = project::Entity::update_many()
|
||||
@@ -633,9 +661,9 @@ impl DatabaseBackend {
|
||||
/// the head seq.
|
||||
pub fn snapshot(&self, uri: &StorageUri, uuid: &str) -> Result<()> {
|
||||
let target = parse_target(uri)?;
|
||||
let path = sqlite_path_of(&target)?;
|
||||
let key = db_key_of(&target);
|
||||
let uuid = uuid.to_string();
|
||||
self.run(path, move |conn| async move {
|
||||
self.run(key, move |conn| async move {
|
||||
let model = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid))
|
||||
.one(&conn)
|
||||
@@ -657,9 +685,9 @@ impl DatabaseBackend {
|
||||
/// the empty project. E_INVALID when `seq` is out of range.
|
||||
pub fn load_at(&self, uri: &StorageUri, uuid: &str, seq: i64) -> Result<CHandle> {
|
||||
let target = parse_target(uri)?;
|
||||
let path = sqlite_path_of(&target)?;
|
||||
let key = db_key_of(&target);
|
||||
let uuid = uuid.to_string();
|
||||
self.run(path, move |conn| async move {
|
||||
self.run(key, move |conn| async move {
|
||||
let model = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid))
|
||||
.one(&conn)
|
||||
@@ -695,9 +723,9 @@ impl DatabaseBackend {
|
||||
/// handle (the head state).
|
||||
fn load_handle_by_uuid(&self, uri: &StorageUri, uuid: &str) -> Result<CHandle> {
|
||||
let target = parse_target(uri)?;
|
||||
let path = sqlite_path_of(&target)?;
|
||||
let key = db_key_of(&target);
|
||||
let uuid = uuid.to_string();
|
||||
self.run(path, move |conn| async move {
|
||||
self.run(key, move |conn| async move {
|
||||
let model = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid))
|
||||
.one(&conn)
|
||||
@@ -736,9 +764,9 @@ impl StorageBackend for DatabaseBackend {
|
||||
|
||||
fn load(&self, uri: &StorageUri) -> Result<LoadResult> {
|
||||
let target = parse_target(uri)?;
|
||||
let path = sqlite_path_of(&target)?;
|
||||
let key = db_key_of(&target);
|
||||
let project = target.project().map(str::to_string);
|
||||
self.run(path, move |conn| async move {
|
||||
self.run(key, move |conn| async move {
|
||||
let model = pick_project(&conn, project.as_deref()).await?;
|
||||
let xml = assemble_at(&conn, model.id, &model.uuid, model.command_seq).await?;
|
||||
let handle =
|
||||
@@ -749,7 +777,7 @@ impl StorageBackend for DatabaseBackend {
|
||||
|
||||
fn save(&self, project: CHandle, uri: &StorageUri, _options: u32) -> Result<()> {
|
||||
let target = parse_target(uri)?;
|
||||
let path = sqlite_path_of(&target)?;
|
||||
let key = db_key_of(&target);
|
||||
// Serialize under the project lock (the same per-node writer the
|
||||
// `.ove` backend uses — one serialization truth).
|
||||
let arc = unsafe { crate::nodeutil::project_arc(&project)? };
|
||||
@@ -760,21 +788,19 @@ impl StorageBackend for DatabaseBackend {
|
||||
let (nodes, settings_xml, settings_map) = serialize_project_state(&guard)?;
|
||||
(uuid, name, nodes, settings_xml, settings_map)
|
||||
};
|
||||
self.run(path, move |conn| async move {
|
||||
self.run(key, move |conn| async move {
|
||||
save_tx(&conn, &uuid, &name, &nodes, &settings_xml, &settings_map).await
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The SQLite path of a target, rejecting PG with E_NO_BACKEND (D3).
|
||||
fn sqlite_path_of(target: &DbTarget) -> Result<&str> {
|
||||
if target.is_pg() {
|
||||
// D3: `oakdb+pg://` is parsed (see parse_target) but every
|
||||
// operation refuses it until the PostgreSQL client is wired.
|
||||
return Err(Error::NoBackend);
|
||||
/// The connection-cache key of a target (SQLite path or PG conn string).
|
||||
fn db_key_of(target: &DbTarget) -> DbKey {
|
||||
match target {
|
||||
DbTarget::Sqlite { path, .. } => DbKey::Sqlite(path.clone()),
|
||||
DbTarget::Pg { conn, .. } => DbKey::Pg(conn.clone()),
|
||||
}
|
||||
Ok(target.sqlite_path().expect("pg rejected above"))
|
||||
}
|
||||
|
||||
/// Display name for a project on first entry: the `projectname` setting
|
||||
@@ -804,6 +830,34 @@ async fn connect_sqlite(path: &str) -> Result<DatabaseConnection> {
|
||||
Ok(DatabaseConnection::from(pool))
|
||||
}
|
||||
|
||||
/// Open the PostgreSQL database at `conn` (a connection string body,
|
||||
/// e.g. `user:pass@host:5432/db`; a scheme is prepended for sqlx).
|
||||
///
|
||||
/// The server is first probed with a single, non-retrying connection
|
||||
/// attempt: the pool retries refused connections with backoff for the
|
||||
/// whole acquire timeout, which would hold a dead server for seconds.
|
||||
/// A single `connect()` surfaces the refusal immediately, so a dead /
|
||||
/// unreachable host fails fast with a clean E_IO.
|
||||
async fn connect_pg(conn: &str) -> Result<DatabaseConnection> {
|
||||
use std::str::FromStr;
|
||||
use sea_orm::sqlx::postgres::{PgConnectOptions, PgPoolOptions};
|
||||
use sea_orm::sqlx::ConnectOptions;
|
||||
let url = format!("postgres://{conn}");
|
||||
let options = PgConnectOptions::from_str(&url)
|
||||
.map_err(|e| Error::Io(format!("invalid postgres connection string: {e}")))?;
|
||||
options
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| Error::Io(format!("cannot connect to postgres database: {e}")))?;
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect_with(options)
|
||||
.await
|
||||
.map_err(|e| Error::Io(format!("cannot connect to postgres database: {e}")))?;
|
||||
Ok(DatabaseConnection::from(pool))
|
||||
}
|
||||
|
||||
/// Whether an error is worth retrying (SQLite BUSY — plan §6 note in
|
||||
/// [`DatabaseBackend::run`]).
|
||||
fn retryable(e: &Error) -> bool {
|
||||
@@ -1450,7 +1504,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_target_pg_kept_for_d3() {
|
||||
fn parse_target_pg_conn_string() {
|
||||
let t = parse_target(&uri("oakdb+pg://user:pass@host:5432/db")).unwrap();
|
||||
assert_eq!(
|
||||
t,
|
||||
@@ -1459,6 +1513,47 @@ mod tests {
|
||||
project: None
|
||||
}
|
||||
);
|
||||
// A `?project=` query is the row selector, not a conn param.
|
||||
let t = parse_target(&uri("oakdb+pg://user@host/db?project={abc-123}")).unwrap();
|
||||
assert_eq!(
|
||||
t,
|
||||
DbTarget::Pg {
|
||||
conn: "user@host/db".to_string(),
|
||||
project: Some("{abc-123}".to_string())
|
||||
}
|
||||
);
|
||||
// A query without a `project=` key belongs to the conn string
|
||||
// (e.g. `?sslmode=disable` passes through untouched).
|
||||
let t = parse_target(&uri("oakdb+pg://user@host/db?sslmode=disable")).unwrap();
|
||||
assert_eq!(
|
||||
t,
|
||||
DbTarget::Pg {
|
||||
conn: "user@host/db?sslmode=disable".to_string(),
|
||||
project: None
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_target_pg_invalid_rejected() {
|
||||
// Empty / unparseable connection strings are E_INVALID (clean, no
|
||||
// network touched), matching the sqlite relative-path rule.
|
||||
assert!(matches!(
|
||||
parse_target(&uri("oakdb+pg://")),
|
||||
Err(Error::Invalid)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_target(&uri("oakdb+pg://?project={x}")),
|
||||
Err(Error::Invalid)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_target(&uri("oakdb+pg://user@")),
|
||||
Err(Error::Invalid)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_target(&uri("oakdb+pg://not a url")),
|
||||
Err(Error::Invalid)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Shared fixtures and helpers for the database-backend integration
|
||||
//! tests (plan M13 D1/D3).
|
||||
//!
|
||||
//! Everything here is dialect-agnostic: the test bodies run against a
|
||||
//! `oakdb+sqlite://…` or `oakdb+pg://…` uri string, and the helpers that
|
||||
//! inspect rows behind the backend's back open a raw sea-orm connection
|
||||
//! ([`inspect_sqlite`] / [`inspect_pg`]). `tests/database_test.rs` runs
|
||||
//! the SQLite suite; `tests/database_pg_test.rs` runs the same behaviors
|
||||
//! against a real PostgreSQL server gated on `OAK_TEST_PG_URL`.
|
||||
|
||||
// The module is compiled into two test binaries, each of which uses only
|
||||
// the helpers for its own dialect, so the other dialect's helpers look
|
||||
// dead to one binary while the other uses them.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use oakcore_rs::Rational;
|
||||
use oaknode::block::ClipBlockBehavior;
|
||||
use oaknode::footage::FootageBehavior;
|
||||
use oaknode::id::NodeId;
|
||||
use oaknode::keyframe::{Interpolation, Keyframe};
|
||||
use oaknode::node::NodeCore;
|
||||
use oaknode::project::Project;
|
||||
use oaknode::sequence::SequenceBehavior;
|
||||
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
|
||||
use oaknode::value::NodeValue;
|
||||
use oakstorage::backend::StorageBackend;
|
||||
use oakstorage::backends::database::entities::settings;
|
||||
use oakstorage::backends::database::DatabaseBackend;
|
||||
use oakstorage::error::OAKSTORAGE_OK;
|
||||
use oakstorage::handle::CHandle;
|
||||
use oakstorage::nodeutil::{make_project_owned, project_arc};
|
||||
use oakstorage::uri::StorageUri;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URIs and paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A fresh, unique temp directory for one test.
|
||||
pub(crate) fn temp_dir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("oakstorage_db_{}_{}", std::process::id(), tag));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// `oakdb+sqlite:///…` URI for a database file.
|
||||
pub(crate) fn db_uri(path: &Path) -> String {
|
||||
format!("oakdb+sqlite://{}", path.display())
|
||||
}
|
||||
|
||||
/// `oakdb+sqlite:///…?project=<uuid>` URI selecting one library row.
|
||||
pub(crate) fn project_uri(db: &str, uuid: &str) -> String {
|
||||
format!("{db}?project={uuid}")
|
||||
}
|
||||
|
||||
/// `file://…` URI for a plain file.
|
||||
pub(crate) fn file_uri(path: &Path) -> String {
|
||||
format!("file://{}", path.display())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save / load through the backend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Release an owned handle (refcount 1).
|
||||
pub(crate) fn release(h: CHandle) {
|
||||
if let Some(release) = h.release {
|
||||
unsafe { release(h.ctx) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Save an `Arc<Mutex<Project>>` through the database backend.
|
||||
pub(crate) fn save_project(
|
||||
backend: &DatabaseBackend,
|
||||
project: &Arc<Mutex<Project>>,
|
||||
uri: &str,
|
||||
) -> oakstorage::error::Result<()> {
|
||||
let parsed = StorageUri::parse(uri).unwrap();
|
||||
let handle = make_project_owned(project.clone());
|
||||
let result = backend.save(handle, &parsed, 0);
|
||||
release(handle);
|
||||
result
|
||||
}
|
||||
|
||||
/// Load a project through a *new* database backend session (a fresh
|
||||
/// connection pool = a fresh session), returning `(uuid, loaded)`.
|
||||
pub(crate) fn load_project(backend: &DatabaseBackend, uri: &str) -> (String, Arc<Mutex<Project>>) {
|
||||
let parsed = StorageUri::parse(uri).unwrap();
|
||||
let result = backend.load(&parsed).unwrap();
|
||||
assert_eq!(result.version_info, OAKSTORAGE_OK);
|
||||
let handle = result.project;
|
||||
let loaded = unsafe { project_arc(&handle) }.unwrap();
|
||||
let uuid = loaded.lock().unwrap().uuid.clone();
|
||||
release(handle);
|
||||
(uuid, loaded)
|
||||
}
|
||||
|
||||
/// Load the state at `seq` (undo to any point).
|
||||
pub(crate) fn load_at(backend: &DatabaseBackend, uri: &str, uuid: &str, seq: i64) -> Arc<Mutex<Project>> {
|
||||
let parsed = StorageUri::parse(uri).unwrap();
|
||||
let handle = backend.load_at(&parsed, uuid, seq).unwrap();
|
||||
let loaded = unsafe { project_arc(&handle) }.unwrap();
|
||||
release(handle);
|
||||
loaded
|
||||
}
|
||||
|
||||
pub(crate) fn r_to_f(r: Rational) -> f64 {
|
||||
r.numerator() as f64 / r.denominator() as f64
|
||||
}
|
||||
|
||||
pub(crate) fn assert_close(a: f64, b: f64) {
|
||||
assert!((a - b).abs() < 1e-6, "expected {a} close to {b}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row inspection (raw sea-orm connections behind the backend's back)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Open a raw sea-orm SQLite connection to `path` and drive one future
|
||||
/// against it on a private current-thread runtime.
|
||||
pub(crate) fn inspect_sqlite<R, Fut>(path: &Path, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R
|
||||
where
|
||||
Fut: std::future::Future<Output = R>,
|
||||
{
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let options = sea_orm::sqlx::sqlite::SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.journal_mode(sea_orm::sqlx::sqlite::SqliteJournalMode::Wal)
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.foreign_keys(true);
|
||||
let pool = sea_orm::sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.unwrap();
|
||||
f(sea_orm::DatabaseConnection::from(pool)).await
|
||||
})
|
||||
}
|
||||
|
||||
/// Open a raw sea-orm PostgreSQL connection to `url` and drive one
|
||||
/// future against it on a private current-thread runtime.
|
||||
pub(crate) fn inspect_pg<R, Fut>(url: &str, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R
|
||||
where
|
||||
Fut: std::future::Future<Output = R>,
|
||||
{
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let pool = sea_orm::sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(url)
|
||||
.await
|
||||
.unwrap();
|
||||
f(sea_orm::DatabaseConnection::from(pool)).await
|
||||
})
|
||||
}
|
||||
|
||||
/// The settings mirror rows of a project (SQLite).
|
||||
pub(crate) fn settings_rows(path: &Path, project_id: i64) -> Vec<(String, String)> {
|
||||
inspect_sqlite(path, |conn| async move {
|
||||
settings::Entity::find()
|
||||
.filter(settings::Column::ProjectId.eq(project_id))
|
||||
.all(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| (s.key, s.value))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The settings mirror rows of a project (PostgreSQL).
|
||||
pub(crate) fn settings_rows_pg(url: &str, project_id: i64) -> Vec<(String, String)> {
|
||||
inspect_pg(url, |conn| async move {
|
||||
settings::Entity::find()
|
||||
.filter(settings::Column::ProjectId.eq(project_id))
|
||||
.all(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| (s.key, s.value))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The project uuid.
|
||||
pub(crate) fn uuid_of(project: &Arc<Mutex<Project>>) -> String {
|
||||
project.lock().unwrap().uuid.clone()
|
||||
}
|
||||
|
||||
/// Read a config value for the duration of a test (the config store is
|
||||
/// process-global, so restore it afterwards).
|
||||
pub(crate) fn with_config(group: &str, key: &str, value: i32, f: impl FnOnce()) {
|
||||
let store = oakcommon::configstore::ConfigStore::instance();
|
||||
let before = store.get_int(Some(group), key, 0);
|
||||
store.set_int(Some(group), key, value);
|
||||
f();
|
||||
store.set_int(Some(group), key, before);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full-feature fixture (footage / sequence / track / clip / effect /
|
||||
// keyframes) — the union of the ove round-trip and timeline fixtures.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) const MATH: &str = "org.olivevideoeditor.Olive.math";
|
||||
|
||||
/// Build the round-trip fixture: root folder + two math nodes with
|
||||
/// values/keyframes/label/color/link/connection + a sequence "My Seq"
|
||||
/// with one video track carrying two clips (footage /a/b.mp4, /a/c.mp4).
|
||||
pub(crate) fn build_full_project() -> Arc<Mutex<Project>> {
|
||||
let project = Project::new();
|
||||
let mut p = project.lock().unwrap();
|
||||
p.initialize().unwrap();
|
||||
|
||||
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
|
||||
let a = p.graph.add_node(core, behavior);
|
||||
{
|
||||
let e = p.graph.get_mut(a).unwrap();
|
||||
e.core.label = "Math A".to_string();
|
||||
e.core.override_color = 2;
|
||||
e.core.set_standard_value("param_a_in", -1, NodeValue::Float(2.5));
|
||||
e.core
|
||||
.keyframe_track_mut("param_a_in", -1)
|
||||
.set_key(Keyframe {
|
||||
time: Rational::new(0, 1),
|
||||
value: NodeValue::Float(1.0),
|
||||
interpolation: Interpolation::Linear,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
e.core
|
||||
.keyframe_track_mut("param_a_in", -1)
|
||||
.set_key(Keyframe {
|
||||
time: Rational::new(1, 1),
|
||||
value: NodeValue::Float(3.0),
|
||||
interpolation: Interpolation::Bezier,
|
||||
bezier_in: (0.1, 0.2),
|
||||
bezier_out: (0.3, 0.4),
|
||||
});
|
||||
}
|
||||
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
|
||||
let b = p.graph.add_node(core, behavior);
|
||||
p.graph
|
||||
.get_mut(b)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("param_a_in", -1, NodeValue::Float(4.0));
|
||||
p.graph.connect(a, b, "param_b_in", -1).unwrap();
|
||||
p.graph.link(a, b);
|
||||
|
||||
// Timeline: sequence + video track + two clips with footage.
|
||||
let (seq_id, lists) = oakstorage::nodeutil::create_sequence(&mut p.graph);
|
||||
p.graph.get_mut(seq_id).unwrap().core.label = "My Seq".to_string();
|
||||
|
||||
let mut tb = TrackBehavior::new(TrackType::Video);
|
||||
tb.track_list = Some(lists[0]);
|
||||
let track_id = p.graph.add_node(NodeCore::new(), Box::new(tb));
|
||||
|
||||
let foot1 = p
|
||||
.graph
|
||||
.add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/b.mp4")));
|
||||
let clip1 = {
|
||||
let (core, mut behavior) = oaknode::block::clip_create();
|
||||
let clip = behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
|
||||
.unwrap();
|
||||
clip.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(100, 25));
|
||||
clip.core.media_in = Rational::new(0, 1);
|
||||
clip.core.track = Some(track_id);
|
||||
clip.footage = Some(foot1);
|
||||
p.graph.add_node(core, behavior)
|
||||
};
|
||||
|
||||
let foot2 = p
|
||||
.graph
|
||||
.add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/c.mp4")));
|
||||
let clip2 = {
|
||||
let (core, mut behavior) = oaknode::block::clip_create();
|
||||
let clip = behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
|
||||
.unwrap();
|
||||
clip.core.range =
|
||||
oakcore_rs::TimeRange::new(Rational::new(100, 25), Rational::new(150, 25));
|
||||
clip.core.media_in = Rational::new(10, 25);
|
||||
clip.core.track = Some(track_id);
|
||||
clip.footage = Some(foot2);
|
||||
p.graph.add_node(core, behavior)
|
||||
};
|
||||
|
||||
if let Some(entry) = p.graph.get_mut(track_id) {
|
||||
entry
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<TrackBehavior>())
|
||||
.unwrap()
|
||||
.blocks = vec![clip1, clip2];
|
||||
}
|
||||
if let Some(entry) = p.graph.get_mut(lists[0]) {
|
||||
entry
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
|
||||
.unwrap()
|
||||
.tracks = vec![track_id];
|
||||
}
|
||||
|
||||
p.settings
|
||||
.insert("projectname".to_string(), "full-fixture".to_string());
|
||||
drop(p);
|
||||
project
|
||||
}
|
||||
|
||||
/// Field-by-field comparison of the full fixture after a round-trip
|
||||
/// (uuid included).
|
||||
pub(crate) fn assert_full_fields(orig: &Project, loaded: &Project) {
|
||||
assert_eq!(loaded.uuid, orig.uuid, "uuid");
|
||||
assert_full_state(orig, loaded);
|
||||
}
|
||||
|
||||
/// Field-by-field comparison that ignores the uuid (a duplicated row
|
||||
/// carries a fresh uuid by design).
|
||||
pub(crate) fn assert_full_state(orig: &Project, loaded: &Project) {
|
||||
assert_eq!(loaded.settings, orig.settings, "settings");
|
||||
|
||||
// Node set: count, type order (slot order preserved by the writer).
|
||||
let o_ids = orig.graph.node_ids();
|
||||
let l_ids = loaded.graph.node_ids();
|
||||
assert_eq!(l_ids.len(), o_ids.len(), "node count");
|
||||
let o_types: Vec<&str> = o_ids
|
||||
.iter()
|
||||
.map(|id| orig.graph.get(*id).unwrap().behavior.type_id())
|
||||
.collect();
|
||||
let l_types: Vec<&str> = l_ids
|
||||
.iter()
|
||||
.map(|id| loaded.graph.get(*id).unwrap().behavior.type_id())
|
||||
.collect();
|
||||
assert_eq!(l_types, o_types, "node types");
|
||||
|
||||
// Math A: label, color, value, keyframes.
|
||||
let (a_o, a_l) = (o_ids[1], l_ids[1]);
|
||||
assert_eq!(
|
||||
loaded.graph.get(a_l).unwrap().core.label,
|
||||
orig.graph.get(a_o).unwrap().core.label,
|
||||
"label"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.graph.get(a_l).unwrap().core.override_color,
|
||||
orig.graph.get(a_o).unwrap().core.override_color,
|
||||
"color"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded
|
||||
.graph
|
||||
.get(a_l)
|
||||
.unwrap()
|
||||
.core
|
||||
.standard_value("param_a_in", -1),
|
||||
orig.graph
|
||||
.get(a_o)
|
||||
.unwrap()
|
||||
.core
|
||||
.standard_value("param_a_in", -1),
|
||||
"value a"
|
||||
);
|
||||
let keys_o = orig
|
||||
.graph
|
||||
.get(a_o)
|
||||
.unwrap()
|
||||
.core
|
||||
.keyframe_track("param_a_in", -1)
|
||||
.unwrap()
|
||||
.keys()
|
||||
.to_vec();
|
||||
let keys_l = loaded
|
||||
.graph
|
||||
.get(a_l)
|
||||
.unwrap()
|
||||
.core
|
||||
.keyframe_track("param_a_in", -1)
|
||||
.unwrap()
|
||||
.keys()
|
||||
.to_vec();
|
||||
assert_eq!(keys_l.len(), keys_o.len(), "keyframe count");
|
||||
for (ko, kl) in keys_o.iter().zip(&keys_l) {
|
||||
assert_eq!(kl.time, ko.time, "key time");
|
||||
assert_eq!(kl.value.to_double(), ko.value.to_double(), "key value");
|
||||
assert_eq!(kl.interpolation, ko.interpolation, "key interpolation");
|
||||
assert_eq!(kl.bezier_in, ko.bezier_in, "key bezier in");
|
||||
assert_eq!(kl.bezier_out, ko.bezier_out, "key bezier out");
|
||||
}
|
||||
|
||||
// Connection a -> b.param_b_in and the link.
|
||||
assert_eq!(
|
||||
loaded.graph.connected_output(l_ids[2], "param_b_in", -1),
|
||||
Some(a_l),
|
||||
"connection"
|
||||
);
|
||||
assert!(loaded.graph.are_linked(a_l, l_ids[2]), "link");
|
||||
|
||||
// Timeline: sequence, track lists, track, clips, footage.
|
||||
let mut seq: Option<(NodeId, Vec<NodeId>)> = None;
|
||||
for id in loaded.graph.node_ids() {
|
||||
let entry = loaded.graph.get(id).unwrap();
|
||||
if entry.behavior.type_id() == "org.olivevideoeditor.Olive.sequence" {
|
||||
let s = entry
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
|
||||
.unwrap();
|
||||
seq = Some((id, s.track_lists.clone()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (_seq_id, lists) = seq.expect("sequence present");
|
||||
assert_eq!(lists.len(), 3, "video/audio/subtitle lists");
|
||||
let list = loaded
|
||||
.graph
|
||||
.get(lists[0])
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
|
||||
.unwrap();
|
||||
assert_eq!(list.kind, TrackType::Video);
|
||||
assert_eq!(list.array_base, 0);
|
||||
assert_eq!(list.tracks.len(), 1);
|
||||
let track = loaded
|
||||
.graph
|
||||
.get(list.tracks[0])
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<TrackBehavior>())
|
||||
.unwrap();
|
||||
assert_eq!(track.kind, TrackType::Video);
|
||||
assert_eq!(track.blocks.len(), 2);
|
||||
let c1 = loaded
|
||||
.graph
|
||||
.get(track.blocks[0])
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
|
||||
.unwrap();
|
||||
assert_close(r_to_f(c1.core.length()), 4.0);
|
||||
assert_close(r_to_f(c1.core.media_in), 0.0);
|
||||
let f1 = loaded
|
||||
.graph
|
||||
.get(c1.footage.unwrap())
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<FootageBehavior>())
|
||||
.unwrap();
|
||||
assert_eq!(f1.filename, "/a/b.mp4");
|
||||
let c2 = loaded
|
||||
.graph
|
||||
.get(track.blocks[1])
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
|
||||
.unwrap();
|
||||
assert_close(r_to_f(c2.core.length()), 2.0);
|
||||
assert_close(r_to_f(c2.core.media_in), 0.4);
|
||||
let f2 = loaded
|
||||
.graph
|
||||
.get(c2.footage.unwrap())
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<FootageBehavior>())
|
||||
.unwrap();
|
||||
assert_eq!(f2.filename, "/a/c.mp4");
|
||||
}
|
||||
|
||||
/// Build a small project with a `projectname` setting (fast saves).
|
||||
pub(crate) fn build_named_project(name: &str) -> Arc<Mutex<Project>> {
|
||||
let project = Project::new();
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
p.initialize().unwrap();
|
||||
p.settings.insert("projectname".to_string(), name.to_string());
|
||||
}
|
||||
project
|
||||
}
|
||||
|
||||
/// Save a fresh named project, returning its uuid.
|
||||
pub(crate) fn save_named_project(backend: &DatabaseBackend, uri: &str, name: &str) -> String {
|
||||
let project = build_named_project(name);
|
||||
let uuid = uuid_of(&project);
|
||||
save_project(backend, &project, uri).unwrap();
|
||||
uuid
|
||||
}
|
||||
@@ -0,0 +1,996 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! PostgreSQL variants of the database-backend integration tests (plan
|
||||
//! M13 D3): the same behaviors `database_test.rs` runs against SQLite,
|
||||
//! driven by the shared fixtures in `tests/common/mod.rs`, against a
|
||||
//! real PostgreSQL server.
|
||||
//!
|
||||
//! Gating: when `OAK_TEST_PG_URL` (a libpq URL such as
|
||||
//! `postgres://user:pass@host:5432/db`) is set, every test connects to
|
||||
//! that server and runs for real; when it is absent the tests skip with
|
||||
//! a note on stderr, so `cargo test -p oakstorage` is green without a
|
||||
//! PostgreSQL server (CI included). The URL should point at a dedicated
|
||||
//! test database: each test resets the four schema tables (DROP + the
|
||||
//! migration recreates them on connect), and the tests run serialized
|
||||
//! against the shared database.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::{ConnectionTrait, QueryOrder};
|
||||
use oaknode::id::NodeId;
|
||||
use oaknode::value::NodeValue;
|
||||
use oakstorage::backend::StorageBackend;
|
||||
use oakstorage::backends::database::entities::{journal, project, snapshot};
|
||||
use oakstorage::backends::database::{
|
||||
derive_stats, DatabaseBackend, KIND_IMPORT, KIND_REDO, ProjectStats, SNAPSHOT_KEEP,
|
||||
};
|
||||
use oakstorage::error::{
|
||||
OAKSTORAGE_E_FORMAT, OAKSTORAGE_E_INVALID, OAKSTORAGE_E_NOT_FOUND, OAKSTORAGE_OK,
|
||||
};
|
||||
use oakstorage::nodeutil::project_arc;
|
||||
use oakstorage::registry::Registry;
|
||||
use oakstorage::uri::StorageUri;
|
||||
|
||||
use common::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gating and isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The `OAK_TEST_PG_URL` connection string (None when not configured).
|
||||
fn pg_url() -> Option<String> {
|
||||
std::env::var("OAK_TEST_PG_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Strip the `postgres://` / `postgresql://` scheme from a connection
|
||||
/// URL to get the `oakdb+pg://` uri body.
|
||||
fn pg_body(url: &str) -> &str {
|
||||
url.strip_prefix("postgres://")
|
||||
.or_else(|| url.strip_prefix("postgresql://"))
|
||||
.unwrap_or(url)
|
||||
}
|
||||
|
||||
/// Reset the four schema tables (the migration recreates them on the
|
||||
/// next connection). Serialized with the other PG tests.
|
||||
fn reset_tables(url: &str) {
|
||||
inspect_pg(url, |conn| async move {
|
||||
for table in ["journal", "snapshots", "settings", "projects"] {
|
||||
conn.execute_raw(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
format!("DROP TABLE IF EXISTS {table} CASCADE"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run `f` with `(pg_url, oakdb+pg uri)` for an isolated test session.
|
||||
/// Skips (with a note) when `OAK_TEST_PG_URL` is absent; otherwise the
|
||||
/// tables are reset and the test runs against the shared database (the
|
||||
/// tests share one server and one schema, so they run one at a time).
|
||||
fn with_pg_uri(tag: &str, f: impl FnOnce(&str, &str)) {
|
||||
let Some(url) = pg_url() else {
|
||||
eprintln!("OAK_TEST_PG_URL is not set — skipping the PostgreSQL variant of '{tag}'");
|
||||
return;
|
||||
};
|
||||
static PG_LOCK: Mutex<()> = Mutex::new(());
|
||||
let _g = PG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reset_tables(&url);
|
||||
let uri = format!("oakdb+pg://{}", pg_body(&url));
|
||||
f(&url, &uri);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Round-trip through a fresh session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pg_roundtrip_field_by_field() {
|
||||
with_pg_uri("roundtrip_field_by_field", |url, uri| {
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
let project = build_full_project();
|
||||
let uuid = uuid_of(&project);
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
|
||||
// A new backend instance = a new session; the library row is
|
||||
// selected explicitly by uuid.
|
||||
let session = DatabaseBackend::new();
|
||||
let (loaded_uuid, loaded) = load_project(&session, &project_uri(uri, &uuid));
|
||||
assert_eq!(loaded_uuid, uuid);
|
||||
{
|
||||
let o = project.lock().unwrap();
|
||||
let l = loaded.lock().unwrap();
|
||||
assert_full_fields(&o, &l);
|
||||
}
|
||||
|
||||
// The default (no ?project=) pick returns the same single row.
|
||||
let session = DatabaseBackend::new();
|
||||
let (loaded_uuid, _) = load_project(&session, uri);
|
||||
assert_eq!(loaded_uuid, uuid);
|
||||
|
||||
// The settings mirror carries the current keys.
|
||||
let uuid_c = uuid.clone();
|
||||
let row = inspect_pg(url, move |conn| async move {
|
||||
project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid_c))
|
||||
.one(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
});
|
||||
let mirrored = settings_rows_pg(url, row);
|
||||
assert!(
|
||||
mirrored.contains(&("projectname".to_string(), "full-fixture".to_string())),
|
||||
"{mirrored:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Journal semantics: import, diff, no-op save
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// First save is one `kind='import'` command carrying every node plus
|
||||
/// the settings pseudo-node (plan §2); the project row advances to seq 1.
|
||||
#[test]
|
||||
fn pg_first_save_is_an_import_command() {
|
||||
with_pg_uri("first_save_is_an_import_command", |url, uri| {
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
let project = build_full_project();
|
||||
let uuid = uuid_of(&project);
|
||||
let node_count = project.lock().unwrap().graph.node_count();
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
|
||||
let uuid_c = uuid.clone();
|
||||
let rows = inspect_pg(url, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid_c))
|
||||
.one(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let rows = journal::Entity::find()
|
||||
.filter(journal::Column::ProjectId.eq(proj.id))
|
||||
.order_by_asc(journal::Column::Seq)
|
||||
.order_by_asc(journal::Column::NodeIdentity)
|
||||
.all(&conn)
|
||||
.await
|
||||
.unwrap();
|
||||
(proj.id, proj.command_seq, rows)
|
||||
});
|
||||
let (pid, command_seq, rows) = rows;
|
||||
assert_eq!(command_seq, 1);
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
node_count + 1,
|
||||
"every node + the settings pseudo-node"
|
||||
);
|
||||
assert!(rows.iter().all(|r| r.seq == 1 && r.kind == KIND_IMPORT), "one import command");
|
||||
assert!(rows.iter().all(|r| r.old_xml.is_none()), "import has no before images");
|
||||
assert!(rows.iter().all(|r| r.new_xml.is_some()), "import has after images");
|
||||
// The settings pseudo-node (identity 0) is present.
|
||||
assert!(rows.iter().any(|r| r.node_identity == 0), "settings row present");
|
||||
assert_eq!(
|
||||
rows.iter().filter(|r| r.node_identity == 0).count(),
|
||||
1,
|
||||
"exactly one settings row"
|
||||
);
|
||||
// The real-node rows use the +1 offset (settings 0 reserved).
|
||||
assert!(rows.iter().all(|r| r.node_identity >= 1 || r.node_identity == 0));
|
||||
// The node fragments round-trip: the settings row holds the settings
|
||||
// element.
|
||||
let settings_row = rows.iter().find(|r| r.node_identity == 0).unwrap();
|
||||
assert!(settings_row.new_xml.as_deref().unwrap().starts_with("<settings>"));
|
||||
let _ = pid;
|
||||
});
|
||||
}
|
||||
|
||||
/// A later save is a diff: only the changed nodes land as `kind='redo'`
|
||||
/// rows with both before and after images (plan §0).
|
||||
#[test]
|
||||
fn pg_later_saves_are_diffs() {
|
||||
with_pg_uri("later_saves_are_diffs", |url, uri| {
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
let project = build_full_project();
|
||||
let uuid = uuid_of(&project);
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
|
||||
// Change one math node's value and one setting key.
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let math_ids: Vec<NodeId> = p
|
||||
.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.collect();
|
||||
let a = math_ids[0];
|
||||
p.graph
|
||||
.get_mut(a)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("param_a_in", -1, NodeValue::Float(9.5));
|
||||
p.settings.insert("projectname".to_string(), "diffed".to_string());
|
||||
}
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
|
||||
let uuid_c = uuid.clone();
|
||||
let rows = inspect_pg(url, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid_c))
|
||||
.one(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let rows = journal::Entity::find()
|
||||
.filter(journal::Column::ProjectId.eq(proj.id))
|
||||
.all(&conn)
|
||||
.await
|
||||
.unwrap();
|
||||
(proj.id, proj.command_seq, rows)
|
||||
});
|
||||
let (pid, command_seq, rows) = rows;
|
||||
assert_eq!(command_seq, 2);
|
||||
let seq2: Vec<&journal::Model> = rows.iter().filter(|r| r.seq == 2).collect();
|
||||
assert_eq!(seq2.len(), 2, "one changed node + the settings row, nothing else");
|
||||
assert!(seq2.iter().all(|r| r.kind == KIND_REDO));
|
||||
assert!(
|
||||
seq2.iter().all(|r| r.old_xml.is_some() && r.new_xml.is_some()),
|
||||
"diff carries both images"
|
||||
);
|
||||
let settings_row = seq2.iter().find(|r| r.node_identity == 0).unwrap();
|
||||
assert!(settings_row.new_xml.as_deref().unwrap().contains(">diffed<"));
|
||||
let _ = pid;
|
||||
|
||||
// Head state after the diff reflects both changes.
|
||||
let session = DatabaseBackend::new();
|
||||
let (_, loaded) = load_project(&session, &project_uri(uri, &uuid));
|
||||
{
|
||||
let l = loaded.lock().unwrap();
|
||||
let math_ids: Vec<NodeId> = l
|
||||
.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.filter(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1),
|
||||
NodeValue::Float(9.5)
|
||||
);
|
||||
assert_eq!(
|
||||
l.settings.get("projectname").cloned(),
|
||||
Some("diffed".to_string())
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A save that changes nothing bumps no command seq and writes no rows.
|
||||
#[test]
|
||||
fn pg_no_op_save_is_a_touch_only() {
|
||||
with_pg_uri("no_op_save_is_a_touch_only", |url, uri| {
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
let project = build_full_project();
|
||||
let uuid = uuid_of(&project);
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
|
||||
let uuid_c = uuid.clone();
|
||||
let (head, count) = inspect_pg(url, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid_c))
|
||||
.one(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let n = journal::Entity::find()
|
||||
.filter(journal::Column::ProjectId.eq(proj.id))
|
||||
.count(&conn)
|
||||
.await
|
||||
.unwrap();
|
||||
(proj.command_seq, n)
|
||||
});
|
||||
assert_eq!(head, 1, "no-op save keeps the head seq");
|
||||
// The import wrote 12 nodes + 1 settings row; the no-op added none.
|
||||
assert_eq!(count, 13, "only the import rows remain");
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot + journal replay and undo to any point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// With `Storage/SnapshotIntervalSec` ≤ 0 every dirty save writes a
|
||||
/// snapshot; the newest snapshot is the replay base and the journal rows
|
||||
/// after it are applied on top. Deleting every snapshot still recovers
|
||||
/// the same state from an empty base plus the full journal (plan §0).
|
||||
#[test]
|
||||
fn pg_snapshot_and_journal_replay() {
|
||||
with_pg_uri("snapshot_and_journal_replay", |url, uri| {
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
with_config("Storage", "SnapshotIntervalSec", 0, || {
|
||||
let project = build_full_project();
|
||||
let uuid = uuid_of(&project);
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
// Second command: one value change.
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let math_ids: Vec<NodeId> = p
|
||||
.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.collect();
|
||||
p.graph
|
||||
.get_mut(math_ids[0])
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("param_a_in", -1, NodeValue::Float(7.25));
|
||||
}
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
|
||||
// Both saves produced snapshots (head seq 2).
|
||||
let uuid_c = uuid.clone();
|
||||
let snaps = inspect_pg(url, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid_c))
|
||||
.one(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
snapshot::Entity::find()
|
||||
.filter(snapshot::Column::ProjectId.eq(proj.id))
|
||||
.all(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
assert_eq!(snaps.len(), 2, "one snapshot per dirty save");
|
||||
|
||||
// Load (head): snapshot seq 2 is the base, nothing after it.
|
||||
let session = DatabaseBackend::new();
|
||||
let (_, loaded) = load_project(&session, &project_uri(uri, &uuid));
|
||||
{
|
||||
let o = project.lock().unwrap();
|
||||
let l = loaded.lock().unwrap();
|
||||
assert_full_fields(&o, &l);
|
||||
}
|
||||
|
||||
// Destroy the snapshots: replay degrades to empty base + full
|
||||
// journal and still reconstructs the head.
|
||||
let uuid_c = uuid.clone();
|
||||
inspect_pg(url, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid_c))
|
||||
.one(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
snapshot::Entity::delete_many()
|
||||
.filter(snapshot::Column::ProjectId.eq(proj.id))
|
||||
.exec(&conn)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let session = DatabaseBackend::new();
|
||||
let (_, loaded) = load_project(&session, &project_uri(uri, &uuid));
|
||||
{
|
||||
let o = project.lock().unwrap();
|
||||
let l = loaded.lock().unwrap();
|
||||
assert_full_fields(&o, &l);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The journal is the persistent undo history: `load_at(seq)` replays to
|
||||
/// any point (plan §0 "撤销到任意点").
|
||||
#[test]
|
||||
fn pg_undo_to_any_point() {
|
||||
with_pg_uri("undo_to_any_point", |_url, uri| {
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
let project = build_full_project();
|
||||
let uuid = uuid_of(&project);
|
||||
let math_a = {
|
||||
let p = project.lock().unwrap();
|
||||
p.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.find(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.unwrap()
|
||||
};
|
||||
// Command 1: value 2.5 (fixture default).
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
// Command 2: value 6.0.
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
p.graph
|
||||
.get_mut(math_a)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("param_a_in", -1, NodeValue::Float(6.0));
|
||||
}
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
// Command 3: add a node.
|
||||
let (new_id, value) = {
|
||||
let mut p = project.lock().unwrap();
|
||||
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
|
||||
let id = p.graph.add_node(core, behavior);
|
||||
p.graph
|
||||
.get_mut(id)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("param_a_in", -1, NodeValue::Float(11.0));
|
||||
let v = p
|
||||
.graph
|
||||
.get(id)
|
||||
.unwrap()
|
||||
.core
|
||||
.standard_value("param_a_in", -1);
|
||||
(id, v)
|
||||
};
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
|
||||
// Head (seq 3): the extra node exists.
|
||||
let session = DatabaseBackend::new();
|
||||
let (_, head) = load_project(&session, &project_uri(uri, &uuid));
|
||||
{
|
||||
let h = head.lock().unwrap();
|
||||
assert!(h.graph.is_valid(new_id), "node added in command 3 is live");
|
||||
assert_eq!(
|
||||
h.graph.get(new_id).unwrap().core.standard_value("param_a_in", -1),
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
// Undo to seq 2: the node is gone, the value is 6.0.
|
||||
let session = DatabaseBackend::new();
|
||||
let at2 = load_at(&session, uri, &uuid, 2);
|
||||
{
|
||||
let l = at2.lock().unwrap();
|
||||
assert!(!l.graph.is_valid(new_id), "command 3 rolled back");
|
||||
let id = l
|
||||
.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.find(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
l.graph.get(id).unwrap().core.standard_value("param_a_in", -1),
|
||||
NodeValue::Float(6.0)
|
||||
);
|
||||
}
|
||||
|
||||
// Undo to seq 1: the value is back to the fixture default.
|
||||
let session = DatabaseBackend::new();
|
||||
let at1 = load_at(&session, uri, &uuid, 1);
|
||||
{
|
||||
let l = at1.lock().unwrap();
|
||||
assert_eq!(l.graph.node_count(), 12, "fixture node count");
|
||||
let id = l
|
||||
.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.find(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
l.graph.get(id).unwrap().core.standard_value("param_a_in", -1),
|
||||
NodeValue::Float(2.5)
|
||||
);
|
||||
}
|
||||
|
||||
// Undo to seq 0: an empty project.
|
||||
let session = DatabaseBackend::new();
|
||||
let at0 = load_at(&session, uri, &uuid, 0);
|
||||
{
|
||||
let l = at0.lock().unwrap();
|
||||
assert_eq!(l.graph.node_count(), 0, "empty project at seq 0");
|
||||
}
|
||||
|
||||
// Out of range -> E_INVALID.
|
||||
let session = DatabaseBackend::new();
|
||||
assert_eq!(
|
||||
session
|
||||
.load_at(&StorageUri::parse(uri).unwrap(), &uuid, 99)
|
||||
.err()
|
||||
.unwrap()
|
||||
.code(),
|
||||
OAKSTORAGE_E_INVALID
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Snapshot pruning keeps the newest [`SNAPSHOT_KEEP`] copies.
|
||||
#[test]
|
||||
fn pg_snapshot_pruning_keeps_three() {
|
||||
with_pg_uri("snapshot_pruning_keeps_three", |url, uri| {
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
with_config("Storage", "SnapshotIntervalSec", 0, || {
|
||||
let project = build_full_project();
|
||||
let uuid = uuid_of(&project);
|
||||
// 6 commands, each dirty-snapshotted.
|
||||
for i in 0..6i64 {
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let id = p.graph.node_ids()[0];
|
||||
p.graph.get_mut(id).unwrap().core.label = format!("step {i}");
|
||||
}
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
}
|
||||
let uuid_c = uuid.clone();
|
||||
let seqs = inspect_pg(url, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid_c))
|
||||
.one(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
snapshot::Entity::find()
|
||||
.filter(snapshot::Column::ProjectId.eq(proj.id))
|
||||
.order_by_desc(snapshot::Column::CommandSeq)
|
||||
.all(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| s.command_seq)
|
||||
.collect::<Vec<i64>>()
|
||||
});
|
||||
assert_eq!(seqs.len() as u64, SNAPSHOT_KEEP, "only the newest {SNAPSHOT_KEEP}");
|
||||
assert_eq!(seqs[0], 6, "the newest survives");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Journal retention (`Storage/JournalRetentionDays`): rows older than
|
||||
/// the window and covered by the newest snapshot are dropped; the head
|
||||
/// stays reconstructible (snapshot + remaining rows).
|
||||
#[test]
|
||||
fn pg_journal_retention_truncation() {
|
||||
with_pg_uri("journal_retention_truncation", |url, uri| {
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
let project = build_full_project();
|
||||
let uuid = uuid_of(&project);
|
||||
// Command 1 (import) + command 2 (a value change).
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let math_ids: Vec<NodeId> = p
|
||||
.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.collect();
|
||||
p.graph
|
||||
.get_mut(math_ids[0])
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("param_a_in", -1, NodeValue::Float(3.25));
|
||||
}
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
// Snapshot at the head so rows ≤ 2 are covered.
|
||||
backend
|
||||
.snapshot(&StorageUri::parse(uri).unwrap(), &uuid)
|
||||
.unwrap();
|
||||
|
||||
// Backdate every journal row two days, then save a third command
|
||||
// with a 1-day retention window.
|
||||
inspect_pg(url, |conn| async move {
|
||||
let old = chrono::Utc::now().naive_utc() - chrono::Duration::days(2);
|
||||
journal::Entity::update_many()
|
||||
.col_expr(journal::Column::At, sea_orm::sea_query::Expr::value(old))
|
||||
.exec(&conn)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
with_config("Storage", "JournalRetentionDays", 1, || {
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let math_ids: Vec<NodeId> = p
|
||||
.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.collect();
|
||||
p.graph
|
||||
.get_mut(math_ids[0])
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("param_a_in", -1, NodeValue::Float(4.5));
|
||||
}
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
});
|
||||
|
||||
// Only the third command's rows survive.
|
||||
let uuid_c = uuid.clone();
|
||||
let (pid, rows) = inspect_pg(url, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid_c))
|
||||
.one(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let rows = journal::Entity::find()
|
||||
.filter(journal::Column::ProjectId.eq(proj.id))
|
||||
.all(&conn)
|
||||
.await
|
||||
.unwrap();
|
||||
(proj.id, rows)
|
||||
});
|
||||
let seqs: Vec<i64> = rows.iter().map(|r| r.seq).collect();
|
||||
assert!(!seqs.contains(&1) && !seqs.contains(&2), "old commands pruned: {seqs:?}");
|
||||
assert_eq!(seqs, vec![3], "only the fresh command remains");
|
||||
let _ = pid;
|
||||
|
||||
// The head state is still correct (snapshot at seq 2 + command 3).
|
||||
let session = DatabaseBackend::new();
|
||||
let (_, loaded) = load_project(&session, &project_uri(uri, &uuid));
|
||||
{
|
||||
let l = loaded.lock().unwrap();
|
||||
let math_ids: Vec<NodeId> = l
|
||||
.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.filter(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1),
|
||||
NodeValue::Float(4.5)
|
||||
);
|
||||
assert_eq!(l.graph.node_count(), 12);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project-manager API surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pg_list_delete_duplicate_rename() {
|
||||
with_pg_uri("list_delete_duplicate_rename", |_url, uri| {
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
// Empty library lists nothing.
|
||||
assert!(backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap().is_empty());
|
||||
|
||||
let alpha = save_named_project(&backend, uri, "Alpha");
|
||||
let beta = save_named_project(&backend, uri, "Beta");
|
||||
|
||||
let list = backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap();
|
||||
assert_eq!(list.len(), 2, "two rows");
|
||||
// Most recently modified first.
|
||||
assert_eq!(list[0].name, "Beta");
|
||||
assert_eq!(list[1].name, "Alpha");
|
||||
assert_eq!(list[0].command_seq, 1);
|
||||
assert_eq!(list[0].schema_ver, oaknode::serializer::CURRENT_VERSION.0 as i32);
|
||||
|
||||
// Rename (library metadata).
|
||||
backend
|
||||
.rename_project(&StorageUri::parse(uri).unwrap(), &alpha, "Alpha Renamed")
|
||||
.unwrap();
|
||||
let list = backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap();
|
||||
assert!(list.iter().any(|p| p.name == "Alpha Renamed"), "{list:?}");
|
||||
assert_eq!(
|
||||
backend
|
||||
.rename_project(&StorageUri::parse(uri).unwrap(), "{missing}", "X")
|
||||
.err()
|
||||
.unwrap()
|
||||
.code(),
|
||||
OAKSTORAGE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
// Duplicate: fresh uuid, default "(copy)" name, history copied.
|
||||
let copy = backend
|
||||
.duplicate_project(&StorageUri::parse(uri).unwrap(), &beta, None)
|
||||
.unwrap();
|
||||
assert_ne!(copy.uuid, beta, "fresh uuid");
|
||||
assert_eq!(copy.name, "Beta (copy)");
|
||||
assert_eq!(copy.command_seq, 1);
|
||||
let list = backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap();
|
||||
assert_eq!(list.len(), 3);
|
||||
let renamed_copy = backend
|
||||
.duplicate_project(&StorageUri::parse(uri).unwrap(), &beta, Some("Beta Clone"))
|
||||
.unwrap();
|
||||
assert_eq!(renamed_copy.name, "Beta Clone");
|
||||
assert_eq!(
|
||||
backend
|
||||
.duplicate_project(&StorageUri::parse(uri).unwrap(), "{missing}", None)
|
||||
.err()
|
||||
.unwrap()
|
||||
.code(),
|
||||
OAKSTORAGE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
// Delete.
|
||||
for uuid in [&alpha, &beta, ©.uuid, &renamed_copy.uuid] {
|
||||
backend
|
||||
.delete_project(&StorageUri::parse(uri).unwrap(), uuid)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap().is_empty());
|
||||
assert_eq!(
|
||||
backend
|
||||
.delete_project(&StorageUri::parse(uri).unwrap(), "{missing}")
|
||||
.err()
|
||||
.unwrap()
|
||||
.code(),
|
||||
OAKSTORAGE_E_NOT_FOUND
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Duplicating a full-feature project copies the whole history: the copy
|
||||
/// loads identically under its own uuid and keeps the undo history.
|
||||
#[test]
|
||||
fn pg_duplicate_preserves_history() {
|
||||
with_pg_uri("duplicate_preserves_history", |_url, uri| {
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
let project = build_full_project();
|
||||
let uuid = uuid_of(&project);
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let math_ids: Vec<NodeId> = p
|
||||
.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.collect();
|
||||
p.graph
|
||||
.get_mut(math_ids[0])
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("param_a_in", -1, NodeValue::Float(6.0));
|
||||
}
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
|
||||
let copy = backend
|
||||
.duplicate_project(&StorageUri::parse(uri).unwrap(), &uuid, None)
|
||||
.unwrap();
|
||||
assert_eq!(copy.command_seq, 2);
|
||||
|
||||
// The copy loads through a fresh session, field-for-field (uuid is
|
||||
// fresh by design).
|
||||
let session = DatabaseBackend::new();
|
||||
let (loaded_uuid, loaded) = load_project(&session, &project_uri(uri, ©.uuid));
|
||||
assert_eq!(loaded_uuid, copy.uuid);
|
||||
{
|
||||
let o = project.lock().unwrap();
|
||||
let l = loaded.lock().unwrap();
|
||||
assert_full_state(&o, &l);
|
||||
}
|
||||
|
||||
// Undo history travels with the copy: load_at(1) on the copy gives
|
||||
// the pre-change state.
|
||||
let session = DatabaseBackend::new();
|
||||
let at1 = load_at(&session, uri, ©.uuid, 1);
|
||||
{
|
||||
let l = at1.lock().unwrap();
|
||||
let math_ids: Vec<NodeId> = l
|
||||
.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.filter(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1),
|
||||
NodeValue::Float(2.5)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Manager stats are derived from the node graph, not stored (plan §4).
|
||||
#[test]
|
||||
fn pg_project_stats_derived_from_graph() {
|
||||
with_pg_uri("project_stats_derived_from_graph", |_url, uri| {
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
let project = build_full_project();
|
||||
let uuid = uuid_of(&project);
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
|
||||
let stats = backend
|
||||
.project_stats(&StorageUri::parse(uri).unwrap(), &uuid)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stats,
|
||||
ProjectStats {
|
||||
duration_ms: 6000,
|
||||
track_count: 1,
|
||||
clip_count: 2,
|
||||
footage_count: 2,
|
||||
}
|
||||
);
|
||||
// Same numbers derive_stats yields directly on the live project.
|
||||
let guard = project.lock().unwrap();
|
||||
assert_eq!(derive_stats(&guard), stats);
|
||||
assert_eq!(
|
||||
backend
|
||||
.project_stats(&StorageUri::parse(uri).unwrap(), "{missing}")
|
||||
.err()
|
||||
.unwrap()
|
||||
.code(),
|
||||
OAKSTORAGE_E_NOT_FOUND
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_export_and_import_round_trip() {
|
||||
with_pg_uri("export_and_import_round_trip", |_url, uri| {
|
||||
let dir = temp_dir("pg_xi");
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
let project = build_full_project();
|
||||
let uuid = uuid_of(&project);
|
||||
save_project(&backend, &project, uri).unwrap();
|
||||
|
||||
// Export: .ove written from an in-memory assembly; the file backend
|
||||
// re-imports it byte-for-byte.
|
||||
let out = dir.join("exported.ove");
|
||||
let file = file_uri(&out);
|
||||
backend
|
||||
.export_to_file(
|
||||
&StorageUri::parse(uri).unwrap(),
|
||||
&uuid,
|
||||
&StorageUri::parse(&file).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(out.exists(), ".ove written");
|
||||
let text = std::fs::read_to_string(&out).unwrap();
|
||||
assert!(text.starts_with("<project version=\"1\">"), "{text}");
|
||||
let file_backend = Registry::global()
|
||||
.resolve(&StorageUri::parse(&file).unwrap())
|
||||
.unwrap();
|
||||
let result = file_backend
|
||||
.load(&StorageUri::parse(&file).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(result.version_info, OAKSTORAGE_OK);
|
||||
let loaded = unsafe { project_arc(&result.project) }.unwrap();
|
||||
release(result.project);
|
||||
{
|
||||
let o = project.lock().unwrap();
|
||||
let l = loaded.lock().unwrap();
|
||||
assert_full_fields(&o, &l);
|
||||
}
|
||||
|
||||
// Import: the .ove lands as a new library row under a fresh uuid;
|
||||
// importing it again yields a distinct row.
|
||||
let imported = backend
|
||||
.import_from_file(
|
||||
&StorageUri::parse(uri).unwrap(),
|
||||
&StorageUri::parse(&file).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(imported, uuid, "import gets a fresh uuid");
|
||||
let imported2 = backend
|
||||
.import_from_file(
|
||||
&StorageUri::parse(uri).unwrap(),
|
||||
&StorageUri::parse(&file).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(imported2, imported, "repeat imports are new rows");
|
||||
let list = backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap();
|
||||
assert_eq!(list.len(), 3, "original + two imports");
|
||||
let session = DatabaseBackend::new();
|
||||
let (imported_uuid, imported_proj) = load_project(&session, &project_uri(uri, &imported));
|
||||
assert_eq!(imported_uuid, imported);
|
||||
{
|
||||
let l = imported_proj.lock().unwrap();
|
||||
assert_eq!(l.graph.node_count(), 12);
|
||||
assert_eq!(
|
||||
l.settings.get("projectname").cloned(),
|
||||
Some("full-fixture".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// Error paths: unknown project on export; non-file target; corrupt
|
||||
// file on import.
|
||||
assert_eq!(
|
||||
backend
|
||||
.export_to_file(
|
||||
&StorageUri::parse(uri).unwrap(),
|
||||
"{missing}",
|
||||
&StorageUri::parse(&file).unwrap(),
|
||||
)
|
||||
.err()
|
||||
.unwrap()
|
||||
.code(),
|
||||
OAKSTORAGE_E_NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
backend
|
||||
.export_to_file(
|
||||
&StorageUri::parse(uri).unwrap(),
|
||||
&uuid,
|
||||
&StorageUri::parse("oakdb+sqlite:///tmp/x.db").unwrap(),
|
||||
)
|
||||
.err()
|
||||
.unwrap()
|
||||
.code(),
|
||||
OAKSTORAGE_E_INVALID
|
||||
);
|
||||
let corrupt = dir.join("corrupt.ove");
|
||||
std::fs::write(&corrupt, "<project><nodes><node></project>").unwrap();
|
||||
assert_eq!(
|
||||
backend
|
||||
.import_from_file(
|
||||
&StorageUri::parse(uri).unwrap(),
|
||||
&StorageUri::parse(&file_uri(&corrupt)).unwrap(),
|
||||
)
|
||||
.err()
|
||||
.unwrap()
|
||||
.code(),
|
||||
OAKSTORAGE_E_FORMAT
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project selection (`?project=` vs default)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pg_project_selection_via_query() {
|
||||
with_pg_uri("project_selection_via_query", |_url, uri| {
|
||||
let backend = DatabaseBackend::new();
|
||||
|
||||
let a = save_named_project(&backend, uri, "A");
|
||||
let b = save_named_project(&backend, uri, "B");
|
||||
|
||||
// Explicit uuid picks the right row regardless of recency.
|
||||
let session = DatabaseBackend::new();
|
||||
let (loaded_uuid, _) = load_project(&session, &project_uri(uri, &a));
|
||||
assert_eq!(loaded_uuid, a);
|
||||
|
||||
// Default pick = most recently modified (B was written last).
|
||||
let session = DatabaseBackend::new();
|
||||
let (loaded_uuid, loaded) = load_project(&session, uri);
|
||||
assert_eq!(loaded_uuid, b);
|
||||
assert_eq!(
|
||||
loaded.lock().unwrap().settings.get("projectname").cloned(),
|
||||
Some("B".to_string())
|
||||
);
|
||||
|
||||
// Unknown uuid -> E_NOT_FOUND; empty library -> E_NOT_FOUND.
|
||||
let session = DatabaseBackend::new();
|
||||
assert_eq!(
|
||||
session
|
||||
.load(&StorageUri::parse(&project_uri(uri, "{missing}")).unwrap())
|
||||
.err()
|
||||
.unwrap()
|
||||
.code(),
|
||||
OAKSTORAGE_E_NOT_FOUND
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -22,459 +22,33 @@
|
||||
//! journal (import + diff), snapshot + journal replay, undo to any
|
||||
//! point, snapshot pruning and journal retention, the project-manager
|
||||
//! API surface (list/delete/duplicate/rename/import/export) and the
|
||||
//! error/URI matrices. Nothing touches a real library.
|
||||
//! error/URI matrices. Nothing touches a real library. The same
|
||||
//! behaviors run against real PostgreSQL in `database_pg_test.rs`
|
||||
//! (gated on `OAK_TEST_PG_URL`); both suites share the fixtures and
|
||||
//! helpers in `tests/common/mod.rs`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
mod common;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::QueryOrder;
|
||||
use oakcore_rs::Rational;
|
||||
use oaknode::block::ClipBlockBehavior;
|
||||
use oaknode::footage::FootageBehavior;
|
||||
use oaknode::id::NodeId;
|
||||
use oaknode::keyframe::{Interpolation, Keyframe};
|
||||
use oaknode::node::NodeCore;
|
||||
use oaknode::project::Project;
|
||||
use oaknode::sequence::SequenceBehavior;
|
||||
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
|
||||
use oaknode::value::NodeValue;
|
||||
use oakstorage::backend::StorageBackend;
|
||||
use oakstorage::backends::database::entities::{journal, project, settings, snapshot};
|
||||
use oakstorage::backends::database::{derive_stats, DatabaseBackend, KIND_IMPORT, KIND_REDO, ProjectStats, SNAPSHOT_KEEP};
|
||||
use oakstorage::backends::database::entities::{journal, project, snapshot};
|
||||
use oakstorage::backends::database::{
|
||||
derive_stats, DatabaseBackend, KIND_IMPORT, KIND_REDO, ProjectStats, SNAPSHOT_KEEP,
|
||||
};
|
||||
use oakstorage::error::{
|
||||
OAKSTORAGE_E_FORMAT, OAKSTORAGE_E_INVALID, OAKSTORAGE_E_IO, OAKSTORAGE_E_NO_BACKEND,
|
||||
OAKSTORAGE_E_NOT_FOUND, OAKSTORAGE_OK,
|
||||
};
|
||||
use oakstorage::handle::CHandle;
|
||||
use oakstorage::nodeutil::{make_project_owned, project_arc};
|
||||
use oakstorage::nodeutil::project_arc;
|
||||
use oakstorage::registry::Registry;
|
||||
use oakstorage::uri::StorageUri;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A fresh, unique temp directory for one test.
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("oakstorage_db_{}_{}", std::process::id(), tag));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// `oakdb+sqlite:///…` URI for a database file.
|
||||
fn db_uri(path: &Path) -> String {
|
||||
format!("oakdb+sqlite://{}", path.display())
|
||||
}
|
||||
|
||||
/// `oakdb+sqlite:///…?project=<uuid>` URI selecting one library row.
|
||||
fn project_uri(db: &str, uuid: &str) -> String {
|
||||
format!("{db}?project={uuid}")
|
||||
}
|
||||
|
||||
/// `file://…` URI for a plain file.
|
||||
fn file_uri(path: &Path) -> String {
|
||||
format!("file://{}", path.display())
|
||||
}
|
||||
|
||||
/// Release an owned handle (refcount 1).
|
||||
fn release(h: CHandle) {
|
||||
if let Some(release) = h.release {
|
||||
unsafe { release(h.ctx) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Save an `Arc<Mutex<Project>>` through the database backend.
|
||||
fn save_project(
|
||||
backend: &DatabaseBackend,
|
||||
project: &Arc<Mutex<Project>>,
|
||||
uri: &str,
|
||||
) -> oakstorage::error::Result<()> {
|
||||
let parsed = StorageUri::parse(uri).unwrap();
|
||||
let handle = make_project_owned(project.clone());
|
||||
let result = backend.save(handle, &parsed, 0);
|
||||
release(handle);
|
||||
result
|
||||
}
|
||||
|
||||
/// Load a project through a *new* database backend session (a fresh
|
||||
/// connection pool = a fresh session), returning `(uuid, loaded)`.
|
||||
fn load_project(backend: &DatabaseBackend, uri: &str) -> (String, Arc<Mutex<Project>>) {
|
||||
let parsed = StorageUri::parse(uri).unwrap();
|
||||
let result = backend.load(&parsed).unwrap();
|
||||
assert_eq!(result.version_info, OAKSTORAGE_OK);
|
||||
let handle = result.project;
|
||||
let loaded = unsafe { project_arc(&handle) }.unwrap();
|
||||
let uuid = loaded.lock().unwrap().uuid.clone();
|
||||
release(handle);
|
||||
(uuid, loaded)
|
||||
}
|
||||
|
||||
/// Load the state at `seq` (undo to any point).
|
||||
fn load_at(backend: &DatabaseBackend, uri: &str, uuid: &str, seq: i64) -> Arc<Mutex<Project>> {
|
||||
let parsed = StorageUri::parse(uri).unwrap();
|
||||
let handle = backend.load_at(&parsed, uuid, seq).unwrap();
|
||||
let loaded = unsafe { project_arc(&handle) }.unwrap();
|
||||
release(handle);
|
||||
loaded
|
||||
}
|
||||
|
||||
fn r_to_f(r: Rational) -> f64 {
|
||||
r.numerator() as f64 / r.denominator() as f64
|
||||
}
|
||||
|
||||
fn assert_close(a: f64, b: f64) {
|
||||
assert!((a - b).abs() < 1e-6, "expected {a} close to {b}");
|
||||
}
|
||||
|
||||
/// Open a raw sea-orm connection to the database file (for inspecting
|
||||
/// and injecting rows behind the backend's back) and drive one future
|
||||
/// against it on a private current-thread runtime. The connection is
|
||||
/// handed to `f` by value (a clone of the pool).
|
||||
fn inspect_db<R, Fut>(path: &Path, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R
|
||||
where
|
||||
Fut: std::future::Future<Output = R>,
|
||||
{
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let options = sea_orm::sqlx::sqlite::SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.journal_mode(sea_orm::sqlx::sqlite::SqliteJournalMode::Wal)
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.foreign_keys(true);
|
||||
let pool = sea_orm::sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.unwrap();
|
||||
f(sea_orm::DatabaseConnection::from(pool)).await
|
||||
})
|
||||
}
|
||||
|
||||
/// The settings mirror rows of a project.
|
||||
fn settings_rows(path: &Path, project_id: i64) -> Vec<(String, String)> {
|
||||
inspect_db(path, |conn| async move {
|
||||
settings::Entity::find()
|
||||
.filter(settings::Column::ProjectId.eq(project_id))
|
||||
.all(&conn)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| (s.key, s.value))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The project uuid.
|
||||
fn uuid_of(project: &Arc<Mutex<Project>>) -> String {
|
||||
project.lock().unwrap().uuid.clone()
|
||||
}
|
||||
|
||||
/// Read a config value for the duration of a test (the config store is
|
||||
/// process-global, so restore it afterwards).
|
||||
fn with_config(group: &str, key: &str, value: i32, f: impl FnOnce()) {
|
||||
let store = oakcommon::configstore::ConfigStore::instance();
|
||||
let before = store.get_int(Some(group), key, 0);
|
||||
store.set_int(Some(group), key, value);
|
||||
f();
|
||||
store.set_int(Some(group), key, before);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full-feature fixture (footage / sequence / track / clip / effect /
|
||||
// keyframes) — the union of the ove round-trip and timeline fixtures.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MATH: &str = "org.olivevideoeditor.Olive.math";
|
||||
|
||||
/// Build the round-trip fixture: root folder + two math nodes with
|
||||
/// values/keyframes/label/color/link/connection + a sequence "My Seq"
|
||||
/// with one video track carrying two clips (footage /a/b.mp4, /a/c.mp4).
|
||||
fn build_full_project() -> Arc<Mutex<Project>> {
|
||||
let project = Project::new();
|
||||
let mut p = project.lock().unwrap();
|
||||
p.initialize().unwrap();
|
||||
|
||||
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
|
||||
let a = p.graph.add_node(core, behavior);
|
||||
{
|
||||
let e = p.graph.get_mut(a).unwrap();
|
||||
e.core.label = "Math A".to_string();
|
||||
e.core.override_color = 2;
|
||||
e.core.set_standard_value("param_a_in", -1, NodeValue::Float(2.5));
|
||||
e.core
|
||||
.keyframe_track_mut("param_a_in", -1)
|
||||
.set_key(Keyframe {
|
||||
time: Rational::new(0, 1),
|
||||
value: NodeValue::Float(1.0),
|
||||
interpolation: Interpolation::Linear,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
e.core
|
||||
.keyframe_track_mut("param_a_in", -1)
|
||||
.set_key(Keyframe {
|
||||
time: Rational::new(1, 1),
|
||||
value: NodeValue::Float(3.0),
|
||||
interpolation: Interpolation::Bezier,
|
||||
bezier_in: (0.1, 0.2),
|
||||
bezier_out: (0.3, 0.4),
|
||||
});
|
||||
}
|
||||
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
|
||||
let b = p.graph.add_node(core, behavior);
|
||||
p.graph
|
||||
.get_mut(b)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("param_a_in", -1, NodeValue::Float(4.0));
|
||||
p.graph.connect(a, b, "param_b_in", -1).unwrap();
|
||||
p.graph.link(a, b);
|
||||
|
||||
// Timeline: sequence + video track + two clips with footage.
|
||||
let (seq_id, lists) = oakstorage::nodeutil::create_sequence(&mut p.graph);
|
||||
p.graph.get_mut(seq_id).unwrap().core.label = "My Seq".to_string();
|
||||
|
||||
let mut tb = TrackBehavior::new(TrackType::Video);
|
||||
tb.track_list = Some(lists[0]);
|
||||
let track_id = p.graph.add_node(NodeCore::new(), Box::new(tb));
|
||||
|
||||
let foot1 = p
|
||||
.graph
|
||||
.add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/b.mp4")));
|
||||
let clip1 = {
|
||||
let (core, mut behavior) = oaknode::block::clip_create();
|
||||
let clip = behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
|
||||
.unwrap();
|
||||
clip.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(100, 25));
|
||||
clip.core.media_in = Rational::new(0, 1);
|
||||
clip.core.track = Some(track_id);
|
||||
clip.footage = Some(foot1);
|
||||
p.graph.add_node(core, behavior)
|
||||
};
|
||||
|
||||
let foot2 = p
|
||||
.graph
|
||||
.add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/c.mp4")));
|
||||
let clip2 = {
|
||||
let (core, mut behavior) = oaknode::block::clip_create();
|
||||
let clip = behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
|
||||
.unwrap();
|
||||
clip.core.range =
|
||||
oakcore_rs::TimeRange::new(Rational::new(100, 25), Rational::new(150, 25));
|
||||
clip.core.media_in = Rational::new(10, 25);
|
||||
clip.core.track = Some(track_id);
|
||||
clip.footage = Some(foot2);
|
||||
p.graph.add_node(core, behavior)
|
||||
};
|
||||
|
||||
if let Some(entry) = p.graph.get_mut(track_id) {
|
||||
entry
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<TrackBehavior>())
|
||||
.unwrap()
|
||||
.blocks = vec![clip1, clip2];
|
||||
}
|
||||
if let Some(entry) = p.graph.get_mut(lists[0]) {
|
||||
entry
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
|
||||
.unwrap()
|
||||
.tracks = vec![track_id];
|
||||
}
|
||||
|
||||
p.settings
|
||||
.insert("projectname".to_string(), "full-fixture".to_string());
|
||||
drop(p);
|
||||
project
|
||||
}
|
||||
|
||||
/// Field-by-field comparison of the full fixture after a round-trip
|
||||
/// (uuid included).
|
||||
fn assert_full_fields(orig: &Project, loaded: &Project) {
|
||||
assert_eq!(loaded.uuid, orig.uuid, "uuid");
|
||||
assert_full_state(orig, loaded);
|
||||
}
|
||||
|
||||
/// Field-by-field comparison that ignores the uuid (a duplicated row
|
||||
/// carries a fresh uuid by design).
|
||||
fn assert_full_state(orig: &Project, loaded: &Project) {
|
||||
assert_eq!(loaded.settings, orig.settings, "settings");
|
||||
|
||||
// Node set: count, type order (slot order preserved by the writer).
|
||||
let o_ids = orig.graph.node_ids();
|
||||
let l_ids = loaded.graph.node_ids();
|
||||
assert_eq!(l_ids.len(), o_ids.len(), "node count");
|
||||
let o_types: Vec<&str> = o_ids
|
||||
.iter()
|
||||
.map(|id| orig.graph.get(*id).unwrap().behavior.type_id())
|
||||
.collect();
|
||||
let l_types: Vec<&str> = l_ids
|
||||
.iter()
|
||||
.map(|id| loaded.graph.get(*id).unwrap().behavior.type_id())
|
||||
.collect();
|
||||
assert_eq!(l_types, o_types, "node types");
|
||||
|
||||
// Math A: label, color, value, keyframes.
|
||||
let (a_o, a_l) = (o_ids[1], l_ids[1]);
|
||||
assert_eq!(
|
||||
loaded.graph.get(a_l).unwrap().core.label,
|
||||
orig.graph.get(a_o).unwrap().core.label,
|
||||
"label"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.graph.get(a_l).unwrap().core.override_color,
|
||||
orig.graph.get(a_o).unwrap().core.override_color,
|
||||
"color"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded
|
||||
.graph
|
||||
.get(a_l)
|
||||
.unwrap()
|
||||
.core
|
||||
.standard_value("param_a_in", -1),
|
||||
orig.graph
|
||||
.get(a_o)
|
||||
.unwrap()
|
||||
.core
|
||||
.standard_value("param_a_in", -1),
|
||||
"value a"
|
||||
);
|
||||
let keys_o = orig
|
||||
.graph
|
||||
.get(a_o)
|
||||
.unwrap()
|
||||
.core
|
||||
.keyframe_track("param_a_in", -1)
|
||||
.unwrap()
|
||||
.keys()
|
||||
.to_vec();
|
||||
let keys_l = loaded
|
||||
.graph
|
||||
.get(a_l)
|
||||
.unwrap()
|
||||
.core
|
||||
.keyframe_track("param_a_in", -1)
|
||||
.unwrap()
|
||||
.keys()
|
||||
.to_vec();
|
||||
assert_eq!(keys_l.len(), keys_o.len(), "keyframe count");
|
||||
for (ko, kl) in keys_o.iter().zip(&keys_l) {
|
||||
assert_eq!(kl.time, ko.time, "key time");
|
||||
assert_eq!(kl.value.to_double(), ko.value.to_double(), "key value");
|
||||
assert_eq!(kl.interpolation, ko.interpolation, "key interpolation");
|
||||
assert_eq!(kl.bezier_in, ko.bezier_in, "key bezier in");
|
||||
assert_eq!(kl.bezier_out, ko.bezier_out, "key bezier out");
|
||||
}
|
||||
|
||||
// Connection a -> b.param_b_in and the link.
|
||||
assert_eq!(
|
||||
loaded.graph.connected_output(l_ids[2], "param_b_in", -1),
|
||||
Some(a_l),
|
||||
"connection"
|
||||
);
|
||||
assert!(loaded.graph.are_linked(a_l, l_ids[2]), "link");
|
||||
|
||||
// Timeline: sequence, track lists, track, clips, footage.
|
||||
let mut seq: Option<(NodeId, Vec<NodeId>)> = None;
|
||||
for id in loaded.graph.node_ids() {
|
||||
let entry = loaded.graph.get(id).unwrap();
|
||||
if entry.behavior.type_id() == "org.olivevideoeditor.Olive.sequence" {
|
||||
let s = entry
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
|
||||
.unwrap();
|
||||
seq = Some((id, s.track_lists.clone()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (_seq_id, lists) = seq.expect("sequence present");
|
||||
assert_eq!(lists.len(), 3, "video/audio/subtitle lists");
|
||||
let list = loaded
|
||||
.graph
|
||||
.get(lists[0])
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
|
||||
.unwrap();
|
||||
assert_eq!(list.kind, TrackType::Video);
|
||||
assert_eq!(list.array_base, 0);
|
||||
assert_eq!(list.tracks.len(), 1);
|
||||
let track = loaded
|
||||
.graph
|
||||
.get(list.tracks[0])
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<TrackBehavior>())
|
||||
.unwrap();
|
||||
assert_eq!(track.kind, TrackType::Video);
|
||||
assert_eq!(track.blocks.len(), 2);
|
||||
let c1 = loaded
|
||||
.graph
|
||||
.get(track.blocks[0])
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
|
||||
.unwrap();
|
||||
assert_close(r_to_f(c1.core.length()), 4.0);
|
||||
assert_close(r_to_f(c1.core.media_in), 0.0);
|
||||
let f1 = loaded
|
||||
.graph
|
||||
.get(c1.footage.unwrap())
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<FootageBehavior>())
|
||||
.unwrap();
|
||||
assert_eq!(f1.filename, "/a/b.mp4");
|
||||
let c2 = loaded
|
||||
.graph
|
||||
.get(track.blocks[1])
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
|
||||
.unwrap();
|
||||
assert_close(r_to_f(c2.core.length()), 2.0);
|
||||
assert_close(r_to_f(c2.core.media_in), 0.4);
|
||||
let f2 = loaded
|
||||
.graph
|
||||
.get(c2.footage.unwrap())
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<FootageBehavior>())
|
||||
.unwrap();
|
||||
assert_eq!(f2.filename, "/a/c.mp4");
|
||||
}
|
||||
|
||||
/// Build a small project with a `projectname` setting (fast saves).
|
||||
fn build_named_project(name: &str) -> Arc<Mutex<Project>> {
|
||||
let project = Project::new();
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
p.initialize().unwrap();
|
||||
p.settings.insert("projectname".to_string(), name.to_string());
|
||||
}
|
||||
project
|
||||
}
|
||||
use common::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Round-trip through a fresh session
|
||||
@@ -508,7 +82,7 @@ fn roundtrip_field_by_field() {
|
||||
assert_eq!(loaded_uuid, uuid);
|
||||
|
||||
// The settings mirror carries the current keys.
|
||||
let row = inspect_db(&db, |conn| async move {
|
||||
let row = inspect_sqlite(&db, |conn| async move {
|
||||
project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid))
|
||||
.one(&conn)
|
||||
@@ -566,7 +140,7 @@ fn first_save_is_an_import_command() {
|
||||
let node_count = project.lock().unwrap().graph.node_count();
|
||||
save_project(&backend, &project, &uri).unwrap();
|
||||
|
||||
let rows = inspect_db(&db, |conn| async move {
|
||||
let rows = inspect_sqlite(&db, |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid))
|
||||
.one(&conn)
|
||||
@@ -637,7 +211,7 @@ fn later_saves_are_diffs() {
|
||||
save_project(&backend, &project, &uri).unwrap();
|
||||
|
||||
let uuid_q = uuid.clone();
|
||||
let rows = inspect_db(&db, move |conn| async move {
|
||||
let rows = inspect_sqlite(&db, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid_q))
|
||||
.one(&conn)
|
||||
@@ -693,7 +267,7 @@ fn no_op_save_is_a_touch_only() {
|
||||
save_project(&backend, &project, &uri).unwrap();
|
||||
save_project(&backend, &project, &uri).unwrap();
|
||||
|
||||
let (head, count) = inspect_db(&db, |conn| async move {
|
||||
let (head, count) = inspect_sqlite(&db, |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid))
|
||||
.one(&conn)
|
||||
@@ -751,7 +325,7 @@ fn snapshot_and_journal_replay() {
|
||||
|
||||
// Both saves produced snapshots (head seq 2).
|
||||
let uuid_q = uuid.clone();
|
||||
let snaps = inspect_db(&db, move |conn| async move {
|
||||
let snaps = inspect_sqlite(&db, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid_q))
|
||||
.one(&conn)
|
||||
@@ -778,7 +352,7 @@ fn snapshot_and_journal_replay() {
|
||||
// Destroy the snapshots: replay degrades to empty base + full
|
||||
// journal and still reconstructs the head.
|
||||
let uuid_q = uuid.clone();
|
||||
inspect_db(&db, move |conn| async move {
|
||||
inspect_sqlite(&db, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid_q))
|
||||
.one(&conn)
|
||||
@@ -944,7 +518,7 @@ fn snapshot_pruning_keeps_three() {
|
||||
}
|
||||
save_project(&backend, &project, &uri).unwrap();
|
||||
}
|
||||
let seqs = inspect_db(&db, |conn| async move {
|
||||
let seqs = inspect_sqlite(&db, |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid))
|
||||
.one(&conn)
|
||||
@@ -1000,7 +574,7 @@ fn journal_retention_truncation() {
|
||||
|
||||
// Backdate every journal row two days, then save a third command
|
||||
// with a 1-day retention window.
|
||||
inspect_db(&db, |conn| async move {
|
||||
inspect_sqlite(&db, |conn| async move {
|
||||
let old = chrono::Utc::now().naive_utc() - chrono::Duration::days(2);
|
||||
journal::Entity::update_many()
|
||||
.col_expr(journal::Column::At, sea_orm::sea_query::Expr::value(old))
|
||||
@@ -1028,7 +602,7 @@ fn journal_retention_truncation() {
|
||||
|
||||
// Only the third command's rows survive.
|
||||
let uuid_q = uuid.clone();
|
||||
let (pid, rows) = inspect_db(&db, move |conn| async move {
|
||||
let (pid, rows) = inspect_sqlite(&db, move |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&uuid_q))
|
||||
.one(&conn)
|
||||
@@ -1070,13 +644,6 @@ fn journal_retention_truncation() {
|
||||
// Project-manager API surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn save_named_project(backend: &DatabaseBackend, uri: &str, name: &str) -> String {
|
||||
let project = build_named_project(name);
|
||||
let uuid = uuid_of(&project);
|
||||
save_project(backend, &project, uri).unwrap();
|
||||
uuid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_delete_duplicate_rename() {
|
||||
let dir = temp_dir("mgr");
|
||||
@@ -1404,8 +971,13 @@ fn project_selection_via_query() {
|
||||
// Error paths, locking, URI matrix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// PG targets are now live: malformed connection strings fail cleanly at
|
||||
/// parse time (E_INVALID, no network touched) and an unreachable server
|
||||
/// fails cleanly at connect (E_IO, no panic). Both run without a real
|
||||
/// PostgreSQL server — the connection-refused case uses port 1 on
|
||||
/// loopback, which nothing listens on.
|
||||
#[test]
|
||||
fn pg_target_rejected_until_d3() {
|
||||
fn pg_invalid_and_unreachable_targets_error_cleanly() {
|
||||
let dir = temp_dir("pg");
|
||||
let db = dir.join("lib.db");
|
||||
let uri = db_uri(&db);
|
||||
@@ -1414,27 +986,45 @@ fn pg_target_rejected_until_d3() {
|
||||
save_project(&backend, &project, &uri).unwrap();
|
||||
let uuid = uuid_of(&project);
|
||||
|
||||
let pg = "oakdb+pg://user:pass@host:5432/db";
|
||||
// Invalid connection strings -> E_INVALID at parse (no connection).
|
||||
for bad in ["oakdb+pg://", "oakdb+pg://?project={x}", "oakdb+pg://user@"] {
|
||||
let err = backend
|
||||
.list_projects(&StorageUri::parse(bad).unwrap())
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(err.code(), OAKSTORAGE_E_INVALID, "{bad}");
|
||||
}
|
||||
|
||||
// Unreachable server (port 1 on loopback) -> E_IO at connect, and the
|
||||
// error is clean (no panic) across every operation. The connect probe
|
||||
// fails on the refused TCP connection without waiting out the pool's
|
||||
// acquire timeout.
|
||||
let dead = "oakdb+pg://user@127.0.0.1:1/db";
|
||||
for call in [
|
||||
backend.load(&StorageUri::parse(pg).unwrap()).map(|_| ()),
|
||||
backend.save(CHandle::null(), &StorageUri::parse(pg).unwrap(), 0),
|
||||
backend.list_projects(&StorageUri::parse(pg).unwrap()).map(|_| ()),
|
||||
backend.delete_project(&StorageUri::parse(pg).unwrap(), &uuid),
|
||||
backend.rename_project(&StorageUri::parse(pg).unwrap(), &uuid, "X"),
|
||||
backend.load(&StorageUri::parse(dead).unwrap()).map(|_| ()),
|
||||
save_project(&backend, &project, dead),
|
||||
backend.list_projects(&StorageUri::parse(dead).unwrap()).map(|_| ()),
|
||||
backend.delete_project(&StorageUri::parse(dead).unwrap(), &uuid),
|
||||
backend.rename_project(&StorageUri::parse(dead).unwrap(), &uuid, "X"),
|
||||
backend
|
||||
.duplicate_project(&StorageUri::parse(pg).unwrap(), &uuid, None)
|
||||
.duplicate_project(&StorageUri::parse(dead).unwrap(), &uuid, None)
|
||||
.map(|_| ()),
|
||||
backend
|
||||
.export_to_file(
|
||||
&StorageUri::parse(pg).unwrap(),
|
||||
&StorageUri::parse(dead).unwrap(),
|
||||
&uuid,
|
||||
&StorageUri::parse("file:///tmp/x.ove").unwrap(),
|
||||
),
|
||||
backend.snapshot(&StorageUri::parse(pg).unwrap(), &uuid),
|
||||
backend.load_at(&StorageUri::parse(pg).unwrap(), &uuid, 1).map(|_| ()),
|
||||
backend.snapshot(&StorageUri::parse(dead).unwrap(), &uuid),
|
||||
backend.load_at(&StorageUri::parse(dead).unwrap(), &uuid, 1).map(|_| ()),
|
||||
] {
|
||||
assert_eq!(call.err().unwrap().code(), OAKSTORAGE_E_NO_BACKEND, "pg target");
|
||||
assert_eq!(call.err().unwrap().code(), OAKSTORAGE_E_IO, "dead pg target");
|
||||
}
|
||||
|
||||
// The sqlite library next to it is untouched by all of the above.
|
||||
let session = DatabaseBackend::new();
|
||||
let (loaded_uuid, _) = load_project(&session, &project_uri(&uri, &uuid));
|
||||
assert_eq!(loaded_uuid, uuid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1606,7 +1196,7 @@ fn concurrent_writers_are_serialized() {
|
||||
let session = DatabaseBackend::new();
|
||||
let (loaded_uuid, _) = load_project(&session, &project_uri(&uri, &same));
|
||||
assert_eq!(loaded_uuid, same, "library consistent after races");
|
||||
let (head, seqs) = inspect_db(&db, |conn| async move {
|
||||
let (head, seqs) = inspect_sqlite(&db, |conn| async move {
|
||||
let proj = project::Entity::find()
|
||||
.filter(project::Column::Uuid.eq(&same))
|
||||
.one(&conn)
|
||||
|
||||
+29
-2
@@ -91,12 +91,39 @@ removed clips.
|
||||
or otio backend; nothing is read from or written to the database
|
||||
beyond the current state.
|
||||
|
||||
## Configuration
|
||||
|
||||
Library selection and behavior are driven by the `Storage` config group
|
||||
(read by `crates/oakengine/src/storage.rs`):
|
||||
|
||||
- `Storage/Backend` — `"sqlite"` (the documented default), `"database"`
|
||||
or `"pg"` enables write-through; any other value (e.g. `"off"`)
|
||||
disables it. When the key is absent no library is configured and
|
||||
projects stay unbound (headless consumers and the test suite never
|
||||
touch the user's real library).
|
||||
- `Storage/SqlitePath` — the SQLite library file; default
|
||||
`<system data dir>/library.db` (honoring `OAK_CONFIG_DIR`).
|
||||
- `Storage/PgUrl` — the PostgreSQL connection string, used when
|
||||
`Backend = "pg"`: `user:pass@host:5432/dbname` (libpq URL form; an
|
||||
optional `postgres://`/`postgresql://` scheme is stripped). The
|
||||
resolved library URI is `oakdb+pg://<PgUrl>`.
|
||||
- `Storage/SnapshotIntervalSec` (default 600) and
|
||||
`Storage/JournalRetentionDays` (default 0 = keep forever) — see above.
|
||||
|
||||
## Multi-writer and platforms
|
||||
|
||||
v1 assumes a single writer per database (SQLite `busy_timeout`, PG row
|
||||
locks). Multi-writer collaboration is future work (M14). The default
|
||||
database is a single user-level SQLite file; PostgreSQL is selected
|
||||
with an `oakdb+pg://` URI.
|
||||
database is a single user-level SQLite file; PostgreSQL is selected with
|
||||
`Storage/Backend = "pg"` + `Storage/PgUrl`, or directly with an
|
||||
`oakdb+pg://` URI.
|
||||
|
||||
Database tests: `cargo test -p oakstorage` is green without PostgreSQL —
|
||||
the SQLite suite always runs; the PG suite (`tests/database_pg_test.rs`)
|
||||
connects to a real server when `OAK_TEST_PG_URL` is set (e.g.
|
||||
`postgres://user:pass@host:5432/db`) and skips with a note otherwise.
|
||||
The URL should point at a dedicated test database: each test resets the
|
||||
four tables.
|
||||
|
||||
See also: [M10 oakstorage manual](plans/riir/M10-oakstorage.md),
|
||||
[M13 write-through plan](plans/riir/M13-storage-live.md),
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 456 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 450 KiB After Width: | Height: | Size: 448 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 441 KiB After Width: | Height: | Size: 438 KiB |
@@ -109,6 +109,24 @@ facade 的 undo 推送路径挂钩(`oakengine_undo_push` / `undo_group_end`
|
||||
> 键缺失 = "无库配置"(工程不绑定、写穿不触发)——这是 §2"无库配置优雅
|
||||
> 降级"的默认形态,保证 headless 消费者(oak-cli)与测试进程永远不写
|
||||
> 用户的真实库。app 侧(D4/D5)在启动时显式设置该配置即可启用。
|
||||
>
|
||||
> D3 落地记录(2026-08):`oakdb+pg://` 全量走通(load/save/load_at/
|
||||
> snapshot/list/delete/duplicate/rename/export/import)。方言差异收敛在
|
||||
> 连接与 migration 两层:`crates/oakstorage/src/backends/database/
|
||||
> migration.rs` 按 `DatabaseBackend` 选 SQLite/PG DDL(PG 仅 `BIGSERIAL`
|
||||
> PK + `BIGINT` FK,其余同构;`CREATE TABLE IF NOT EXISTS` 幂等,每次
|
||||
> 连接时执行;payload 维持 TEXT,不做 BYTEA);sea-orm 实体与 save/replay
|
||||
> 逻辑两库共用零分支。`connect_pg` 先单次直连探测(pool 对被拒连接会
|
||||
> 退避重试到 acquire 超时,死库会挂起数秒),失败映射干净 E_IO。
|
||||
> **配置**:`Storage/Backend = "pg"` 启用,`Storage/PgUrl` 给连接串
|
||||
> (`user:pass@host:5432/dbname`,容忍 `postgres://` 前缀,oakdb uri
|
||||
> 剥掉);URI 的 `?project=` 选择器只在 query 含 `project=` 键时生效,
|
||||
> 否则整段(含 `?sslmode=…`)视为连接串。**测试矩阵**:SQLite 18 个
|
||||
> 常驻;PG 13 个变体(round-trip/journal 语义/快照重放/撤销/清理/管理
|
||||
> API/导入导出/选择)门控 `OAK_TEST_PG_URL`,未设置则早退打印说明
|
||||
> (CI 无 PG 也绿);非法连接串(E_INVALID,parse 期)+ 连不上(E_IO,
|
||||
> 单次探测)为常驻错误路径测试,无需真实 PG。共享 fixture 抽到
|
||||
> `tests/common/mod.rs`(建 fixture/保存加载/行检查 sqlite+pg 双探针)。
|
||||
|
||||
## 4. 项目管理器窗口(app)
|
||||
|
||||
@@ -119,6 +137,30 @@ facade 的 undo 推送路径挂钩(`oakengine_undo_push` / `undo_group_end`
|
||||
- 导入 .ove/.otio/.fcpxml 为新库行;选中工程导出为 .ove/.otio/.fcpxml。
|
||||
- 数据源走 oakstorage 会话 API(Rust 直调;需要 C ABI 时 facade 只增)。
|
||||
|
||||
> D4 落地记录(2026-08):app 只链接 `liboakengine` dylib,故数据源走
|
||||
> 新增 C ABI(`crates/oakengine/src/library.rs`,只增):
|
||||
> `oakengine_library_list`(JSON 行:uuid/name/created/modified + 派生
|
||||
> 统计)/ `_create` / `_delete` / `_rename` / `_duplicate` / `_import`
|
||||
> / `_export`(按扩展名走 oakstorage registry 分发 ove-xml 或 otio 后端)
|
||||
> / `oakengine_project_load_library`(载入并**绑定**写穿会话,与
|
||||
> `project_load` 同契约)。有副作用的 create/duplicate/import 返回 uuid
|
||||
> 用单次定长缓冲调用(不能两段式 measure-then-read——会在 C 侧执行两次)。
|
||||
> app 侧:`src/manager.rs`(`ProjectManager` 内容视图 + `ManagerEvent`
|
||||
> 请求枚举 + `NamePrompt`/`ConfirmContent` 子对话框;时间/时长格式化为
|
||||
> 无依赖纯函数)。`src/app.rs`:无 `--project` 启动时开管理器(驱动根
|
||||
> entity,不能走 `WindowHandle::update`——`spawn_modal` 的
|
||||
> `update_window` 会重入失败);`run_with` 启动时
|
||||
> `real::configure_storage()`(仅在 `Storage/Backend` 未配置时设
|
||||
> `sqlite`,路径用 facade 默认 `<数据目录>/library.db`),退出前
|
||||
> `storage_flush()`。菜单语义(提前并入 D5):保存/另存为 →
|
||||
> 导出工程文件…(⌘S,扩展名分发 ove/otio/fcpxml);打开 → 从库中打开…
|
||||
> (= 管理器)+ 打开工程文件…;新增 项目管理器…。状态栏自动保存段 →
|
||||
> 库写入状态(`storage_bound` / `last_error`,失败红色)。MockEngine 带
|
||||
> 内存假库(3 行种子 + 全操作),app gpui 测试覆盖建/删/复制/重命名/
|
||||
> 导入/导出/打开;facade 侧 `it_library.rs` 5 测试(建/列/打开绑定写穿/
|
||||
> 重命名复制删除/导出导入 round-trip/禁用降级)。截图
|
||||
> docs/screenshot-manager.png + -en.png。
|
||||
|
||||
## 5. 分期与判据
|
||||
|
||||
| 期 | 内容 | 完成判据 |
|
||||
|
||||
@@ -74,11 +74,35 @@ clip 行自带时间线区间(`<range in out/>`)、媒体偏移(`<media_in
|
||||
- 导出:内存序列化经 ove-xml 或 otio 后端写出;除当前状态外不读写
|
||||
数据库。
|
||||
|
||||
## 配置
|
||||
|
||||
库的选择与行为全部由 `Storage` 配置组驱动(写穿由
|
||||
`crates/oakengine/src/storage.rs` 读取):
|
||||
|
||||
- `Storage/Backend` — `"sqlite"`(默认值)、`"database"` 或 `"pg"`
|
||||
启用写穿;其它值(如 `"off"`)禁用。键缺失 = 无库配置,工程不
|
||||
绑定库(headless 消费者与测试进程永不写用户真实库)。
|
||||
- `Storage/SqlitePath` — SQLite 库文件;默认
|
||||
`<系统数据目录>/library.db`(尊重 `OAK_CONFIG_DIR`)。
|
||||
- `Storage/PgUrl` — PostgreSQL 连接串,`Backend = "pg"` 时使用:
|
||||
`user:pass@host:5432/dbname`(libpq URL 形式,带
|
||||
`postgres://`/`postgresql://` 前缀也会被剥掉)。解析出的库 URI 为
|
||||
`oakdb+pg://<PgUrl>`。
|
||||
- `Storage/SnapshotIntervalSec`(默认 600)、`Storage/JournalRetentionDays`
|
||||
(默认 0 = 全保留)见上。
|
||||
|
||||
## 多写者与平台
|
||||
|
||||
v1 假设单写者(SQLite `busy_timeout`,PG 行锁);多写者协作是后续
|
||||
工作(M14)。默认数据库是用户级单一 SQLite 文件;PostgreSQL 用
|
||||
`oakdb+pg://` 连接串选择。
|
||||
工作(M14)。默认数据库是用户级单一 SQLite 文件;PostgreSQL 通过
|
||||
`Storage/Backend = "pg"` + `Storage/PgUrl` 选择,或直接用
|
||||
`oakdb+pg://` 连接串 URI。
|
||||
|
||||
数据库测试:`cargo test -p oakstorage` 全绿无需 PostgreSQL——SQLite
|
||||
套件常驻运行;PG 套件(`tests/database_pg_test.rs`)在设置了
|
||||
`OAK_TEST_PG_URL`(如 `postgres://user:pass@host:5432/db`)时连接真实
|
||||
PG 全量运行,未设置则跳过并打印说明。该 URL 应指向专用测试库:每个
|
||||
测试会重置四张表。
|
||||
|
||||
另见:[M10 oakstorage 手册](plans/riir/M10-oakstorage.md)、
|
||||
[M13 写穿计划](plans/riir/M13-storage-live.md)、
|
||||
|
||||
+52
-17
@@ -19,9 +19,10 @@
|
||||
//! Renders the full [`OakApp`] shell at 1600×900 (2× = 3200×1866 px) in an
|
||||
//! offscreen macOS window and writes the PNGs to
|
||||
//! `docs/screenshot-window.png` (zh-CN) and `docs/screenshot-window-en.png`
|
||||
//! (en-US), using the same [`VisualTestAppContext`] machinery the gpui visual
|
||||
//! tests use. The window is created at `(-10000, -10000)` so nothing
|
||||
//! flickers on screen.
|
||||
//! (en-US), then opens the project manager (M13 D4) on the same shell and
|
||||
//! captures it to `docs/screenshot-manager.png` / `-en.png`, using the same
|
||||
//! [`VisualTestAppContext`] machinery the gpui visual tests use. The window
|
||||
//! is created at `(-10000, -10000)` so nothing flickers on screen.
|
||||
//!
|
||||
//! Unlike the real app's startup ([`oakapp::app::run`]) the example must
|
||||
//! initialize the i18n layer itself, or every menu renders in the en-US
|
||||
@@ -37,7 +38,7 @@
|
||||
//! cargo run --example screenshot -- 1100 900 # any size (same filenames)
|
||||
//! ```
|
||||
|
||||
use gpui::{px, size, AnyWindowHandle, AppContext, Result, VisualTestAppContext};
|
||||
use gpui::{px, size, AnyWindowHandle, AppContext, Entity, Result, VisualTestAppContext};
|
||||
use gpui_platform::current_platform;
|
||||
use oakapp::app::OakApp;
|
||||
use oakapp::i18n::{self, Language};
|
||||
@@ -47,6 +48,8 @@ const DEFAULT_WIDTH: f32 = 1600.0;
|
||||
const DEFAULT_HEIGHT: f32 = 900.0;
|
||||
const OUT_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window.png");
|
||||
const OUT_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window-en.png");
|
||||
const OUT_MGR_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-manager.png");
|
||||
const OUT_MGR_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-manager-en.png");
|
||||
|
||||
/// Logical y of the timeline toolbar row, which sits at the top of the
|
||||
/// bottom dock panel in the default layout: the dock starts at y 27.5 (the
|
||||
@@ -77,47 +80,62 @@ fn main() -> Result<()> {
|
||||
let original = i18n::language();
|
||||
i18n::set_language(Language::ZhCN);
|
||||
{
|
||||
let handle = open_shell(&mut cx, width, height);
|
||||
let image = cx.capture_screenshot(handle)?;
|
||||
let (handle, root) = open_shell(&mut cx, width, height);
|
||||
let image = cx.capture_screenshot(handle.into())?;
|
||||
std::fs::create_dir_all(std::path::Path::new(OUT_ZH).parent().unwrap())?;
|
||||
image.save(OUT_ZH)?;
|
||||
println!("wrote {OUT_ZH} ({}×{})", image.width(), image.height());
|
||||
assert_toolbar(&image, "zh-CN");
|
||||
capture_manager(&mut cx, handle, &root, OUT_MGR_ZH)?;
|
||||
}
|
||||
i18n::set_language(Language::EnUs);
|
||||
{
|
||||
let handle = open_shell(&mut cx, width, height);
|
||||
let image = cx.capture_screenshot(handle)?;
|
||||
let (handle, root) = open_shell(&mut cx, width, height);
|
||||
let image = cx.capture_screenshot(handle.into())?;
|
||||
std::fs::create_dir_all(std::path::Path::new(OUT_EN).parent().unwrap())?;
|
||||
image.save(OUT_EN)?;
|
||||
println!("wrote {OUT_EN} ({}×{})", image.width(), image.height());
|
||||
assert_toolbar(&image, "en-US");
|
||||
capture_manager(&mut cx, handle, &root, OUT_MGR_EN)?;
|
||||
}
|
||||
i18n::set_language(original);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens the app shell offscreen and draws enough frames for the layout to
|
||||
/// settle and the async toolbar-icon assets to decode: the node editor fits
|
||||
/// its graph once the canvas size is known, the viewers upload their first
|
||||
/// CPU frame, and the PNG toolbar icons load through the background executor
|
||||
/// on the frame after the asset future resolves.
|
||||
/// Opens the app shell offscreen and lets the layout settle (see
|
||||
/// [`settle`]). Returns the typed window handle and the root entity so the
|
||||
/// caller can still drive the shell (the manager capture) after the plain
|
||||
/// screenshot.
|
||||
fn open_shell(
|
||||
cx: &mut VisualTestAppContext,
|
||||
width: f32,
|
||||
height: f32,
|
||||
) -> AnyWindowHandle {
|
||||
) -> (
|
||||
gpui::WindowHandle<OakApp<MockEngine>>,
|
||||
Entity<OakApp<MockEngine>>,
|
||||
) {
|
||||
let mut root_slot = None;
|
||||
let window = cx
|
||||
.open_offscreen_window(size(px(width), px(height)), |window, cx| {
|
||||
// Compact pro-app text metrics, matching the real app's startup
|
||||
// (`src/app.rs run_with` sets rem 14px; gpui's default is 16px).
|
||||
window.set_rem_size(px(14.0));
|
||||
cx.new(|cx| OakApp::<MockEngine>::new(window, None, cx))
|
||||
let root = cx.new(|cx| OakApp::<MockEngine>::new(window, None, cx));
|
||||
root_slot = Some(root.clone());
|
||||
root
|
||||
})
|
||||
.expect("offscreen window opens");
|
||||
let handle: AnyWindowHandle = window.into();
|
||||
settle(cx, window.into());
|
||||
(window, root_slot.expect("root entity"))
|
||||
}
|
||||
|
||||
/// Draws enough frames for the layout to settle and the async toolbar-icon
|
||||
/// assets to decode: the node editor fits its graph once the canvas size is
|
||||
/// known, the viewers upload their first CPU frame, and the PNG toolbar
|
||||
/// icons load through the background executor on the frame after the asset
|
||||
/// future resolves.
|
||||
fn settle(cx: &mut VisualTestAppContext, handle: AnyWindowHandle) {
|
||||
for _ in 0..16 {
|
||||
cx.run_until_parked();
|
||||
cx.update_window(handle, |_root, window, app| {
|
||||
@@ -135,7 +153,24 @@ fn open_shell(
|
||||
.expect("window still open");
|
||||
}
|
||||
cx.run_until_parked();
|
||||
handle
|
||||
}
|
||||
|
||||
/// Opens the project manager on the shell (M13 D4) and captures it: the
|
||||
/// modal card lists the mock library with its per-project stats. Drives the
|
||||
/// root ENTITY (not the window handle — a window update borrows the window,
|
||||
/// and building the modal inside it would re-enter it).
|
||||
fn capture_manager(
|
||||
cx: &mut VisualTestAppContext,
|
||||
handle: gpui::WindowHandle<OakApp<MockEngine>>,
|
||||
root: &Entity<OakApp<MockEngine>>,
|
||||
out: &str,
|
||||
) -> Result<()> {
|
||||
root.update(cx, |app, cx| app.show_project_manager(cx));
|
||||
settle(cx, handle.into());
|
||||
let image = cx.capture_screenshot(handle.into())?;
|
||||
image.save(out)?;
|
||||
println!("wrote {out} ({}×{})", image.width(), image.height());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The timeline toolbar's tool icons (16px at 2× = 32px on 48px pitch) must
|
||||
|
||||
+641
-41
@@ -31,7 +31,7 @@
|
||||
//! │ dock: 项目 | 素材查看器 | 序列查看器+节点编辑器 | 检查器+历史记录
|
||||
//! │ (vertical split) 时间线 (full width, 31px toolbar on top)
|
||||
//! ├─────────────────────────────────────────────────────
|
||||
//! └ status bar: 就绪 | 缓存 | 代理 | 自动保存 || 时间码/时长 | 帧率 | 分辨率 | 引擎
|
||||
//! └ status bar: 就绪 | 缓存 | 代理 | 库写入状态 || 时间码/时长 | 帧率 | 分辨率 | 引擎
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
@@ -69,12 +69,13 @@ use crate::panels::timeline::TimelinePanel;
|
||||
mod menu_ids {
|
||||
pub const NEW_PROJECT: usize = 101;
|
||||
pub const OPEN_PROJECT: usize = 102;
|
||||
pub const SAVE: usize = 103;
|
||||
pub const SAVE_AS: usize = 104;
|
||||
pub const EXPORT_PROJECT: usize = 103;
|
||||
pub const CLOSE: usize = 105;
|
||||
pub const EXPORT: usize = 106;
|
||||
pub const QUIT: usize = 107;
|
||||
pub const IMPORT_FOOTAGE: usize = 108;
|
||||
pub const PROJECT_MANAGER: usize = 109;
|
||||
pub const OPEN_FROM_LIBRARY: usize = 110;
|
||||
|
||||
pub const UNDO: usize = 201;
|
||||
pub const REDO: usize = 202;
|
||||
@@ -116,6 +117,9 @@ mod modal_ids {
|
||||
pub const PREFERENCES: usize = 3;
|
||||
pub const EXPORT: usize = 4;
|
||||
pub const EXPORT_PROGRESS: usize = 5;
|
||||
pub const MANAGER: usize = 6;
|
||||
pub const MANAGER_RENAME: usize = 7;
|
||||
pub const MANAGER_DELETE: usize = 8;
|
||||
}
|
||||
|
||||
/// What a picked platform-dialog path should do.
|
||||
@@ -123,11 +127,18 @@ mod modal_ids {
|
||||
enum FileAction {
|
||||
ImportFootage,
|
||||
Open,
|
||||
/// Export the current project to a file (`.ove` / `.otio` / `.fcpxml`,
|
||||
/// dispatched by extension).
|
||||
SaveAs,
|
||||
/// Import a project file into the library (the manager's 导入).
|
||||
ImportProject,
|
||||
/// Export the selected library project (the manager's 导出; the row's
|
||||
/// uuid is stashed in [`OakApp::pending_export`]).
|
||||
ExportProject,
|
||||
}
|
||||
|
||||
/// The modal currently layered on top of the shell, if any.
|
||||
enum ModalState {
|
||||
enum ModalState<E: AppEngine> {
|
||||
None,
|
||||
Preferences {
|
||||
modal: Entity<Modal>,
|
||||
@@ -141,6 +152,19 @@ enum ModalState {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<ProgressContent>,
|
||||
},
|
||||
/// The project manager (M13 D4).
|
||||
Manager {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<crate::manager::ProjectManager<E>>,
|
||||
},
|
||||
/// The manager's rename prompt.
|
||||
ManagerRename {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<crate::manager::NamePrompt>,
|
||||
uuid: String,
|
||||
},
|
||||
/// The manager's delete confirmation.
|
||||
ManagerDelete { modal: Entity<Modal>, uuid: String },
|
||||
}
|
||||
|
||||
/// A running export: the session the tick loop drains for progress.
|
||||
@@ -148,14 +172,17 @@ struct ExportRun {
|
||||
session: ExportSession,
|
||||
}
|
||||
|
||||
impl ModalState {
|
||||
impl<E: AppEngine> ModalState<E> {
|
||||
/// The modal entity currently shown, if any.
|
||||
fn modal_entity(&self) -> Option<Entity<Modal>> {
|
||||
match self {
|
||||
ModalState::None => None,
|
||||
ModalState::Preferences { modal, .. }
|
||||
| ModalState::Export { modal, .. }
|
||||
| ModalState::Progress { modal, .. } => Some(modal.clone()),
|
||||
| ModalState::Progress { modal, .. }
|
||||
| ModalState::Manager { modal, .. }
|
||||
| ModalState::ManagerRename { modal, .. }
|
||||
| ModalState::ManagerDelete { modal, .. } => Some(modal.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,9 +282,11 @@ pub struct OakApp<E: AppEngine> {
|
||||
/// Whether the dark theme is active (toggles via 视图 → 主题).
|
||||
dark: bool,
|
||||
/// The modal currently shown on top of the shell, if any.
|
||||
modal: ModalState,
|
||||
modal: ModalState<E>,
|
||||
/// The running export session, if any.
|
||||
export: Option<ExportRun>,
|
||||
/// The library row pending an export save dialog (manager 导出).
|
||||
pending_export: Option<String>,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> OakApp<E> {
|
||||
@@ -459,6 +488,7 @@ impl<E: AppEngine> OakApp<E> {
|
||||
dark: true,
|
||||
modal: ModalState::None,
|
||||
export: None,
|
||||
pending_export: None,
|
||||
};
|
||||
|
||||
// Open the CLI-provided project once the shell is up.
|
||||
@@ -491,11 +521,11 @@ impl<E: AppEngine> OakApp<E> {
|
||||
use menu_ids::*;
|
||||
match item {
|
||||
// --- File ------------------------------------------------------
|
||||
NEW_PROJECT => self.engine.update(cx, |engine, cx| engine.new_project(cx)),
|
||||
NEW_PROJECT => self.new_project(cx),
|
||||
OPEN_PROJECT => self.open_file_dialog(FileAction::Open, cx),
|
||||
OPEN_FROM_LIBRARY | PROJECT_MANAGER => self.show_project_manager(cx),
|
||||
IMPORT_FOOTAGE => self.open_file_dialog(FileAction::ImportFootage, cx),
|
||||
SAVE => self.save_project(None, cx),
|
||||
SAVE_AS => self.open_file_dialog(FileAction::SaveAs, cx),
|
||||
EXPORT_PROJECT => self.open_file_dialog(FileAction::SaveAs, cx),
|
||||
CLOSE => self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.close_project(cx)),
|
||||
@@ -580,7 +610,8 @@ impl<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the project (to its own filename, or the given `path`).
|
||||
/// Exports the project to a file (the 导出工程文件… action's target; the
|
||||
/// format is dispatched by the picked path's extension).
|
||||
fn save_project(&mut self, path: Option<PathBuf>, cx: &mut Context<Self>) {
|
||||
let result = self
|
||||
.engine
|
||||
@@ -590,6 +621,20 @@ impl<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建项目: creates a blank project in the library and opens it (the
|
||||
/// write-through persists it from the first edit). Falls back to the
|
||||
/// engine's plain new-project path when the library is unavailable.
|
||||
fn new_project(&mut self, cx: &mut Context<Self>) {
|
||||
let name = crate::i18n::tr("manager.new.default_name").to_string();
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.library_create_project(&name, cx));
|
||||
if let Err(err) = result {
|
||||
println!("[file] library create failed ({err}); plain new project");
|
||||
self.engine.update(cx, |engine, cx| engine.new_project(cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the timeline's selected clips (ripple or gap) through the
|
||||
/// engine's edit commands.
|
||||
fn delete_timeline_selection(&mut self, ripple: bool, cx: &mut Context<Self>) {
|
||||
@@ -660,6 +705,228 @@ impl<E: AppEngine> OakApp<E> {
|
||||
.detach();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Project manager (M13 D4)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Opens the project manager (startup without a project argument, and
|
||||
/// 文件 → 项目管理器 / 从库中打开…). Re-entrant: an already-open
|
||||
/// manager just reloads its list.
|
||||
pub fn show_project_manager(&mut self, cx: &mut Context<Self>) {
|
||||
if let ModalState::Manager { content, .. } = &self.modal {
|
||||
let content = content.clone();
|
||||
content.update(cx, |manager, cx| manager.reload(cx));
|
||||
return;
|
||||
}
|
||||
let engine = self.engine.clone();
|
||||
self.spawn_modal(cx, move |window, app| {
|
||||
let content = app.new(|cx| crate::manager::ProjectManager::new(engine, window, cx));
|
||||
let modal = app.new(|cx| {
|
||||
Modal::new(
|
||||
modal_ids::MANAGER,
|
||||
ModalOptions::new(crate::i18n::tr("manager.title"), px(880.0))
|
||||
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.close"))),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_content(content.clone())
|
||||
});
|
||||
ModalState::Manager { modal, content }
|
||||
});
|
||||
// The content's requests (open / create / rename / ...) route here.
|
||||
if let ModalState::Manager { content, .. } = &self.modal {
|
||||
let content = content.clone();
|
||||
cx.subscribe(
|
||||
&content,
|
||||
|this, _content, event: &crate::manager::ManagerEvent, cx| {
|
||||
this.on_manager_event(event, cx);
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a project-manager request through the engine. Open / Create
|
||||
/// close the dialog on success; the mutating actions reload the list;
|
||||
/// failures land in the dialog's status line.
|
||||
fn on_manager_event(&mut self, event: &crate::manager::ManagerEvent, cx: &mut Context<Self>) {
|
||||
use crate::manager::ManagerEvent as E;
|
||||
match event {
|
||||
E::Create => {
|
||||
let name = crate::i18n::tr("manager.new.default_name").to_string();
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.library_create_project(&name, cx));
|
||||
match result {
|
||||
Ok(()) => self.close_modal(cx),
|
||||
Err(err) => self.manager_status(err, cx),
|
||||
}
|
||||
}
|
||||
E::Open(uuid) => {
|
||||
let uuid = uuid.clone();
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.library_open_project(&uuid, cx));
|
||||
match result {
|
||||
Ok(()) => self.close_modal(cx),
|
||||
Err(err) => self.manager_status(err, cx),
|
||||
}
|
||||
}
|
||||
E::Rename(uuid) => self.open_manager_rename(uuid.clone(), cx),
|
||||
E::Duplicate(uuid) => {
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, _cx| engine.library_duplicate_project(uuid));
|
||||
match result {
|
||||
Ok(()) => self.reload_manager(cx),
|
||||
Err(err) => self.manager_status(err, cx),
|
||||
}
|
||||
}
|
||||
E::Delete(uuid) => self.open_manager_delete(uuid.clone(), cx),
|
||||
E::Import => self.open_file_dialog(FileAction::ImportProject, cx),
|
||||
E::Export(uuid) => self.open_manager_export(uuid.clone(), cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reloads the open manager's list (after a library mutation).
|
||||
fn reload_manager(&mut self, cx: &mut Context<Self>) {
|
||||
if let ModalState::Manager { content, .. } = &self.modal {
|
||||
let content = content.clone();
|
||||
content.update(cx, |manager, cx| manager.reload(cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows an engine error in the open manager's status line (or logs it
|
||||
/// when the manager is gone).
|
||||
fn manager_status(&mut self, message: String, cx: &mut Context<Self>) {
|
||||
if let ModalState::Manager { content, .. } = &self.modal {
|
||||
let content = content.clone();
|
||||
content.update(cx, |manager, cx| manager.set_status(Some(message), cx));
|
||||
} else {
|
||||
println!("[manager] {message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected row's display name in the open manager (used to seed
|
||||
/// the rename prompt / the delete confirmation / the export filename).
|
||||
fn manager_selected_name(&self, cx: &App) -> Option<String> {
|
||||
match &self.modal {
|
||||
ModalState::Manager { content, .. } => content.read(cx).selected_name(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Swaps the manager for the rename prompt of row `uuid`.
|
||||
fn open_manager_rename(&mut self, uuid: String, cx: &mut Context<Self>) {
|
||||
let initial = self.manager_selected_name(cx).unwrap_or_default();
|
||||
self.spawn_modal(cx, move |window, app| {
|
||||
let content = app.new(|cx| crate::manager::NamePrompt::new(&initial, cx));
|
||||
let modal = app.new(|cx| {
|
||||
Modal::new(
|
||||
modal_ids::MANAGER_RENAME,
|
||||
ModalOptions::new(crate::i18n::tr("manager.rename.title"), px(380.0))
|
||||
.with_button(DialogButton::primary(crate::i18n::tr(
|
||||
"manager.rename.title",
|
||||
)))
|
||||
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_content(content.clone())
|
||||
});
|
||||
ModalState::ManagerRename { modal, content, uuid }
|
||||
});
|
||||
}
|
||||
|
||||
/// Swaps the manager for the delete confirmation of row `uuid`.
|
||||
fn open_manager_delete(&mut self, uuid: String, cx: &mut Context<Self>) {
|
||||
let name = self.manager_selected_name(cx).unwrap_or_default();
|
||||
let text = crate::i18n::tr("manager.delete.confirm").replace("{name}", &name);
|
||||
self.spawn_modal(cx, move |window, app| {
|
||||
let content = app.new(|_cx| crate::manager::ConfirmContent::new(text));
|
||||
let modal = app.new(|cx| {
|
||||
Modal::new(
|
||||
modal_ids::MANAGER_DELETE,
|
||||
ModalOptions::new(crate::i18n::tr("manager.delete.title"), px(420.0))
|
||||
.with_button(DialogButton::primary(crate::i18n::tr("manager.delete")))
|
||||
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_content(content.clone())
|
||||
});
|
||||
ModalState::ManagerDelete { modal, uuid }
|
||||
});
|
||||
}
|
||||
|
||||
/// Confirms the rename prompt (button 0).
|
||||
fn confirm_manager_rename(&mut self, cx: &mut Context<Self>) {
|
||||
let ModalState::ManagerRename { content, uuid, .. } = &self.modal else {
|
||||
return;
|
||||
};
|
||||
let name = content.read(cx).value(cx);
|
||||
let uuid = uuid.clone();
|
||||
if name.is_empty() {
|
||||
self.back_to_manager(cx);
|
||||
return;
|
||||
}
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, _cx| engine.library_rename_project(&uuid, &name));
|
||||
match result {
|
||||
Ok(()) => self.back_to_manager(cx),
|
||||
Err(err) => {
|
||||
self.back_to_manager(cx);
|
||||
self.manager_status(err, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirms the delete confirmation (button 0).
|
||||
fn confirm_manager_delete(&mut self, cx: &mut Context<Self>) {
|
||||
let ModalState::ManagerDelete { uuid, .. } = &self.modal else {
|
||||
return;
|
||||
};
|
||||
let uuid = uuid.clone();
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, _cx| engine.library_delete_project(&uuid));
|
||||
match result {
|
||||
Ok(()) => self.back_to_manager(cx),
|
||||
Err(err) => {
|
||||
self.back_to_manager(cx);
|
||||
self.manager_status(err, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns from a manager sub-dialog (rename / delete) to the manager.
|
||||
fn back_to_manager(&mut self, cx: &mut Context<Self>) {
|
||||
self.modal = ModalState::None;
|
||||
self.show_project_manager(cx);
|
||||
}
|
||||
|
||||
/// Opens the platform save dialog for exporting the library row `uuid`
|
||||
/// (the suggested name is `<project>.ove`; the format follows the
|
||||
/// extension the user picks).
|
||||
fn open_manager_export(&mut self, uuid: String, cx: &mut Context<Self>) {
|
||||
let name = self
|
||||
.manager_selected_name(cx)
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| "project".to_string());
|
||||
self.pending_export = Some(uuid);
|
||||
let receiver =
|
||||
cx.prompt_for_new_path(&PathBuf::from("."), Some(&format!("{name}.ove")));
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(Ok(Some(path))) = receiver.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.on_file_paths(FileAction::ExportProject, vec![path], cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Modal dialogs
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -679,10 +946,15 @@ impl<E: AppEngine> OakApp<E> {
|
||||
/// through a weak handle *inside* the window callback would re-enter this
|
||||
/// entity while it is already being updated (the crash seen when opening
|
||||
/// Preferences from a menu action).
|
||||
///
|
||||
/// The caller must NOT be inside a window update itself (e.g.
|
||||
/// `WindowHandle::update`): the nested `update_window` below would fail
|
||||
/// and the modal would silently not open. Drive the root entity instead
|
||||
/// (menu actions and entity updates are fine).
|
||||
fn spawn_modal(
|
||||
&mut self,
|
||||
cx: &mut Context<Self>,
|
||||
build: impl FnOnce(&mut Window, &mut App) -> ModalState,
|
||||
build: impl FnOnce(&mut Window, &mut App) -> ModalState<E>,
|
||||
) {
|
||||
let windows = cx.windows();
|
||||
let Some(handle) = windows.first() else {
|
||||
@@ -704,14 +976,15 @@ impl<E: AppEngine> OakApp<E> {
|
||||
|
||||
/// Opens the platform file dialog for `action` and routes the picked
|
||||
/// path(s) through the engine. Open / Import use the path picker (import
|
||||
/// allows multiple files); Save As asks for a new path next to the current
|
||||
/// project. The picker resolves asynchronously, so the chosen path is
|
||||
/// applied in a spawned task via [`Self::on_file_paths`].
|
||||
/// footage allows multiple files); Save As and the manager's export ask
|
||||
/// for a new path. The picker resolves asynchronously, so the chosen
|
||||
/// path is applied in a spawned task via [`Self::on_file_paths`].
|
||||
fn open_file_dialog(&mut self, action: FileAction, cx: &mut Context<Self>) {
|
||||
match action {
|
||||
FileAction::Open | FileAction::ImportFootage => {
|
||||
FileAction::Open | FileAction::ImportFootage | FileAction::ImportProject => {
|
||||
let prompt = match action {
|
||||
FileAction::Open => crate::i18n::tr("file.open.title"),
|
||||
FileAction::ImportProject => crate::i18n::tr("manager.import.title"),
|
||||
_ => crate::i18n::tr("file.import_footage.title"),
|
||||
};
|
||||
let receiver = cx.prompt_for_paths(PathPromptOptions {
|
||||
@@ -757,12 +1030,49 @@ impl<E: AppEngine> OakApp<E> {
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
// The manager's export prompts in `open_manager_export` (it needs
|
||||
// the selection's suggested filename).
|
||||
FileAction::ExportProject => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies paths picked in the platform dialog through the engine, using
|
||||
/// the action's routing (open / import / save-as).
|
||||
/// the action's routing (open / import / export).
|
||||
fn on_file_paths(&mut self, action: FileAction, paths: Vec<PathBuf>, cx: &mut Context<Self>) {
|
||||
// The manager's library import/export refresh the open manager and
|
||||
// report failures in its status line.
|
||||
match action {
|
||||
FileAction::ImportProject => {
|
||||
let Some(path) = paths.first() else {
|
||||
return;
|
||||
};
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, _cx| engine.library_import_project(path.clone()));
|
||||
match result {
|
||||
Ok(uuid) => {
|
||||
println!("[manager] imported \"{}\" as {uuid}", path.display());
|
||||
self.reload_manager(cx);
|
||||
}
|
||||
Err(err) => self.manager_status(err, cx),
|
||||
}
|
||||
return;
|
||||
}
|
||||
FileAction::ExportProject => {
|
||||
let (Some(uuid), Some(path)) = (self.pending_export.take(), paths.first()) else {
|
||||
return;
|
||||
};
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, _cx| engine.library_export_project(&uuid, path.clone()));
|
||||
match result {
|
||||
Ok(()) => println!("[manager] exported to \"{}\"", path.display()),
|
||||
Err(err) => self.manager_status(err, cx),
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let result = self.engine.update(cx, |engine, cx| match action {
|
||||
FileAction::Open => match paths.first() {
|
||||
Some(path) => engine.open_project_path(path.clone(), cx),
|
||||
@@ -786,6 +1096,8 @@ impl<E: AppEngine> OakApp<E> {
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
// Handled above (the manager's library import/export).
|
||||
FileAction::ImportProject | FileAction::ExportProject => Ok(()),
|
||||
});
|
||||
if let Err(err) = result {
|
||||
println!("[file] {action:?} failed: {err}");
|
||||
@@ -963,6 +1275,21 @@ impl<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
}
|
||||
modal_ids::PREFERENCES => self.close_modal(cx),
|
||||
modal_ids::MANAGER => self.close_modal(cx),
|
||||
modal_ids::MANAGER_RENAME => {
|
||||
if *button == 0 {
|
||||
self.confirm_manager_rename(cx);
|
||||
} else {
|
||||
self.back_to_manager(cx);
|
||||
}
|
||||
}
|
||||
modal_ids::MANAGER_DELETE => {
|
||||
if *button == 0 {
|
||||
self.confirm_manager_delete(cx);
|
||||
} else {
|
||||
self.back_to_manager(cx);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ModalEvent::Dismissed { control } => match *control {
|
||||
@@ -971,6 +1298,10 @@ impl<E: AppEngine> OakApp<E> {
|
||||
self.cancel_export(cx);
|
||||
self.close_modal(cx);
|
||||
}
|
||||
// Escape from a manager sub-dialog returns to the manager.
|
||||
modal_ids::MANAGER_RENAME | modal_ids::MANAGER_DELETE => {
|
||||
self.back_to_manager(cx);
|
||||
}
|
||||
_ => self.close_modal(cx),
|
||||
},
|
||||
}
|
||||
@@ -1020,11 +1351,12 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
tr("menu.file"),
|
||||
Menu::new(vec![
|
||||
MenuItem::new(NEW_PROJECT, tr("menu.file.new_project")).with_shortcut("⌘N"),
|
||||
MenuItem::new(OPEN_FROM_LIBRARY, tr("menu.file.open_library")),
|
||||
MenuItem::new(OPEN_PROJECT, tr("menu.file.open_project")).with_shortcut("⌘O"),
|
||||
MenuItem::new(PROJECT_MANAGER, tr("menu.file.project_manager")).separated(),
|
||||
MenuItem::new(IMPORT_FOOTAGE, tr("menu.file.import_footage")),
|
||||
MenuItem::new(SAVE, tr("menu.file.save")).with_shortcut("⌘S"),
|
||||
MenuItem::new(SAVE_AS, tr("menu.file.save_as"))
|
||||
.with_shortcut("⇧⌘S")
|
||||
MenuItem::new(EXPORT_PROJECT, tr("menu.file.export_project"))
|
||||
.with_shortcut("⌘S")
|
||||
.separated(),
|
||||
MenuItem::new(CLOSE, tr("menu.file.close")),
|
||||
MenuItem::new(EXPORT, tr("menu.file.export"))
|
||||
@@ -1174,28 +1506,53 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
// Restore the persisted UI language (config `Language` key) before the
|
||||
// first window renders.
|
||||
crate::i18n::init();
|
||||
// M13 D4: enable the write-through project library (SQLite at the
|
||||
// default location) unless the user configured the backend
|
||||
// explicitly.
|
||||
crate::oakui::real::configure_storage();
|
||||
cx.init_colors();
|
||||
let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx);
|
||||
let initial = initial.clone();
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| {
|
||||
// Compact pro-app text metrics: gpui's default rem is
|
||||
// 16px (desktop-app large); 14px matches the design's
|
||||
// density. All rem-based text scales; px spacing is
|
||||
// unaffected.
|
||||
window.set_rem_size(px(14.0));
|
||||
build_root::<E>(window, initial, cx)
|
||||
},
|
||||
)
|
||||
.expect("failed to open the main window");
|
||||
let show_manager = initial.is_none();
|
||||
let mut root_slot = None;
|
||||
let window = cx
|
||||
.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| {
|
||||
// Compact pro-app text metrics: gpui's default rem is
|
||||
// 16px (desktop-app large); 14px matches the design's
|
||||
// density. All rem-based text scales; px spacing is
|
||||
// unaffected.
|
||||
window.set_rem_size(px(14.0));
|
||||
let root = build_root::<E>(window, initial, cx);
|
||||
root_slot = Some(root.clone());
|
||||
root
|
||||
},
|
||||
)
|
||||
.expect("failed to open the main window");
|
||||
let _ = window;
|
||||
|
||||
// No project on the command line: the DaVinci-style project manager
|
||||
// greets instead of an empty shell. Drives the ROOT ENTITY (not the
|
||||
// window handle): a window update borrows the window, and building a
|
||||
// modal inside it would re-enter it (spawn_modal needs a free
|
||||
// `update_window`).
|
||||
if show_manager {
|
||||
if let Some(root) = &root_slot {
|
||||
root.update(cx, |app, cx| app.show_project_manager(cx));
|
||||
}
|
||||
}
|
||||
|
||||
cx.activate(true);
|
||||
cx.on_window_closed(|cx, _| {
|
||||
if cx.windows().is_empty() {
|
||||
// Exit path (plan M13 §2): drain the write-through backlog
|
||||
// (save + snapshot of every still-bound project) and stop
|
||||
// the facade's snapshot thread before quitting.
|
||||
crate::oakui::real::storage_flush();
|
||||
cx.quit();
|
||||
}
|
||||
})
|
||||
@@ -1206,6 +1563,7 @@ fn run_with<E: AppEngine>(args: AppArgs) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::EngineGateway as _;
|
||||
use gpui::{px, size, TestAppContext};
|
||||
|
||||
/// The 视图/View menu carries a 语言/Language submenu whose items are
|
||||
@@ -1299,9 +1657,10 @@ mod tests {
|
||||
assert_eq!(dark_item(false).checked, Some(false));
|
||||
}
|
||||
|
||||
/// The File menu exposes the full project lifecycle actions (open /
|
||||
/// save / save-as / close / export) and the Edit menu the undo stack
|
||||
/// plus the delete variants, across both languages.
|
||||
/// The File menu exposes the full project lifecycle actions (new /
|
||||
/// open-from-library / open-file / manager / export-project / close /
|
||||
/// export) and the Edit menu the undo stack plus the delete variants,
|
||||
/// across both languages.
|
||||
#[test]
|
||||
fn file_and_edit_menus_cover_the_project_lifecycle() {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
@@ -1317,9 +1676,10 @@ mod tests {
|
||||
let file = entry("File(F)");
|
||||
for id in [
|
||||
menu_ids::NEW_PROJECT,
|
||||
menu_ids::OPEN_FROM_LIBRARY,
|
||||
menu_ids::OPEN_PROJECT,
|
||||
menu_ids::SAVE,
|
||||
menu_ids::SAVE_AS,
|
||||
menu_ids::PROJECT_MANAGER,
|
||||
menu_ids::EXPORT_PROJECT,
|
||||
menu_ids::CLOSE,
|
||||
menu_ids::EXPORT,
|
||||
menu_ids::QUIT,
|
||||
@@ -1349,7 +1709,7 @@ mod tests {
|
||||
.menu
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.id == menu_ids::SAVE_AS));
|
||||
.any(|item| item.id == menu_ids::EXPORT_PROJECT));
|
||||
}
|
||||
|
||||
/// Opening 视图 → Preferences… must not crash: the dialog content and the
|
||||
@@ -1463,4 +1823,244 @@ mod tests {
|
||||
assert!(d.project.is_none());
|
||||
assert!(!d.mock);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Project manager (M13 D4)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// A running app shell on the mock engine (en-US), plus its root. The
|
||||
/// caller holds the language lock (the tests flip the process-global
|
||||
/// language).
|
||||
fn mock_shell(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
gpui::WindowHandle<OakApp<MockEngine>>,
|
||||
Entity<OakApp<MockEngine>>,
|
||||
) {
|
||||
crate::i18n::set_language(crate::i18n::Language::EnUs);
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| {
|
||||
OakApp::<MockEngine>::new(window, None, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let root = window.root(cx).expect("app root");
|
||||
(window, root)
|
||||
}
|
||||
|
||||
/// Opens the manager and returns its content entity.
|
||||
fn open_manager(
|
||||
cx: &mut TestAppContext,
|
||||
root: &Entity<OakApp<MockEngine>>,
|
||||
) -> Entity<crate::manager::ProjectManager<MockEngine>> {
|
||||
cx.update(|app| root.update(app, |app, cx| app.show_project_manager(cx)));
|
||||
cx.run_until_parked();
|
||||
let content = cx.read(|app| match &root.read(app).modal {
|
||||
ModalState::Manager { content, .. } => content.clone(),
|
||||
_ => panic!("the manager modal should be open"),
|
||||
});
|
||||
content
|
||||
}
|
||||
|
||||
/// The manager's listed rows.
|
||||
fn manager_rows(
|
||||
cx: &mut TestAppContext,
|
||||
content: &Entity<crate::manager::ProjectManager<MockEngine>>,
|
||||
) -> Vec<crate::oakui::LibraryProject> {
|
||||
cx.read(|app| content.read(app).rows().to_vec())
|
||||
}
|
||||
|
||||
/// The manager lists the mock library; opening a row (the double-click
|
||||
/// / 打开 route) drives the engine's library open and closes the dialog.
|
||||
#[gpui::test]
|
||||
async fn manager_lists_and_opens(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (_window, root) = mock_shell(cx);
|
||||
let content = open_manager(cx, &root);
|
||||
let rows = manager_rows(cx, &content);
|
||||
assert_eq!(rows.len(), 3, "the mock library seeds three rows");
|
||||
assert_eq!(rows[0].name, "第一稿", "most recently modified first");
|
||||
assert!(rows[0].track_count > 0 && rows[0].footage_count > 0);
|
||||
|
||||
// Select the second row and open it.
|
||||
let uuid = rows[1].uuid.clone();
|
||||
cx.update(|app| content.update(app, |m, cx| m.select(&uuid, cx)));
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(&crate::manager::ManagerEvent::Open(uuid.clone()), cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let opened = cx.read(|app| root.read(app).engine.read(app).library_opened().to_vec());
|
||||
assert_eq!(opened, vec![uuid], "the engine opened the selected row");
|
||||
let name = cx.read(|app| root.read(app).engine.read(app).project().unwrap().name.clone());
|
||||
assert_eq!(name, "宣传片 v3");
|
||||
let modal_none = cx.read(|app| matches!(root.read(app).modal, ModalState::None));
|
||||
assert!(modal_none, "a successful open closes the manager");
|
||||
}
|
||||
|
||||
/// Create / rename / duplicate / delete round-trip through the manager
|
||||
/// and its sub-dialogs.
|
||||
#[gpui::test]
|
||||
async fn manager_create_rename_duplicate_delete(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (_window, root) = mock_shell(cx);
|
||||
let content = open_manager(cx, &root);
|
||||
|
||||
// Create: a new row appears and the project opens (dialog closes).
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(&crate::manager::ManagerEvent::Create, cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
|
||||
assert_eq!(rows.len(), 4);
|
||||
let created = rows
|
||||
.iter()
|
||||
.find(|row| row.name == "Untitled Project")
|
||||
.expect("the created row")
|
||||
.clone();
|
||||
let project_name = cx.read(|app| root.read(app).engine.read(app).project().unwrap().name.clone());
|
||||
assert_eq!(project_name, "Untitled Project", "create opens the new project");
|
||||
|
||||
// Reopen the manager, select the created row, rename it.
|
||||
let content = open_manager(cx, &root);
|
||||
cx.update(|app| content.update(app, |m, cx| m.select(&created.uuid, cx)));
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(&crate::manager::ManagerEvent::Rename(created.uuid.clone()), cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let prompt = cx.read(|app| match &root.read(app).modal {
|
||||
ModalState::ManagerRename { content, uuid, .. } => {
|
||||
assert_eq!(uuid, &created.uuid);
|
||||
content.clone()
|
||||
}
|
||||
_ => panic!("the rename prompt should be open"),
|
||||
});
|
||||
cx.update(|app| prompt.update(app, |p, cx| p.set_value("改名为正稿", cx)));
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_modal(
|
||||
&ModalEvent::ButtonClicked {
|
||||
control: modal_ids::MANAGER_RENAME,
|
||||
button: 0,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
// The confirmation swaps in a FRESH manager (the captured content is
|
||||
// stale from here on); assert against the engine's library instead.
|
||||
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
|
||||
let renamed = rows.iter().find(|row| row.uuid == created.uuid).unwrap();
|
||||
assert_eq!(renamed.name, "改名为正稿");
|
||||
let back = cx.read(|app| matches!(root.read(app).modal, ModalState::Manager { .. }));
|
||||
assert!(back, "a confirmed rename returns to the manager");
|
||||
|
||||
// Duplicate the renamed row.
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(
|
||||
&crate::manager::ManagerEvent::Duplicate(created.uuid.clone()),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
|
||||
assert_eq!(rows.len(), 5);
|
||||
assert!(
|
||||
rows.iter().any(|row| row.name == "改名为正稿 (copy)"),
|
||||
"the copy is named '<name> (copy)': {rows:?}"
|
||||
);
|
||||
|
||||
// Delete the original through the confirmation dialog.
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(&crate::manager::ManagerEvent::Delete(created.uuid.clone()), cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let confirming = cx.read(|app| matches!(root.read(app).modal, ModalState::ManagerDelete { .. }));
|
||||
assert!(confirming, "the delete confirmation should be open");
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_modal(
|
||||
&ModalEvent::ButtonClicked {
|
||||
control: modal_ids::MANAGER_DELETE,
|
||||
button: 0,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
|
||||
assert_eq!(rows.len(), 4);
|
||||
assert!(!rows.iter().any(|row| row.uuid == created.uuid));
|
||||
let back = cx.read(|app| matches!(root.read(app).modal, ModalState::Manager { .. }));
|
||||
assert!(back, "a confirmed delete returns to the manager");
|
||||
}
|
||||
|
||||
/// The manager's 导入 opens the platform path picker and lands the file
|
||||
/// as a new row; 导出 asks for a new path and routes it to the engine.
|
||||
#[gpui::test]
|
||||
async fn manager_import_and_export_route_through_the_platform_dialogs(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (_window, root) = mock_shell(cx);
|
||||
let content = open_manager(cx, &root);
|
||||
|
||||
// Import.
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(&crate::manager::ManagerEvent::Import, cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(cx.did_prompt_for_paths(), "import shows the path picker");
|
||||
cx.simulate_path_prompt_response(|options| {
|
||||
assert!(!options.multiple, "project import is single-file");
|
||||
Some(vec![PathBuf::from("/library/先导片.ove")])
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let rows = manager_rows(cx, &content);
|
||||
assert_eq!(rows.len(), 4);
|
||||
assert!(
|
||||
rows.iter().any(|row| row.name == "先导片"),
|
||||
"the imported file becomes a row named by its stem: {rows:?}"
|
||||
);
|
||||
|
||||
// Export the imported row.
|
||||
let uuid = rows
|
||||
.iter()
|
||||
.find(|row| row.name == "先导片")
|
||||
.unwrap()
|
||||
.uuid
|
||||
.clone();
|
||||
cx.update(|app| content.update(app, |m, cx| m.select(&uuid, cx)));
|
||||
cx.update(|app| {
|
||||
root.update(app, |app, cx| {
|
||||
app.on_manager_event(&crate::manager::ManagerEvent::Export(uuid.clone()), cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
cx.did_prompt_for_new_path(),
|
||||
"export shows the save dialog"
|
||||
);
|
||||
cx.simulate_new_path_selection(|_dir| Some(PathBuf::from("/library/先导片.otio")));
|
||||
cx.run_until_parked();
|
||||
let exported = cx.read(|app| root.read(app).engine.read(app).library_exported().to_vec());
|
||||
assert_eq!(
|
||||
exported,
|
||||
vec![(uuid, PathBuf::from("/library/先导片.otio"))],
|
||||
"the picked path routes to the engine's library export"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+66
-11
@@ -180,10 +180,11 @@ const EN: &[(&str, &str)] = &[
|
||||
("menu.help", "Help(H)"),
|
||||
// --- File ---
|
||||
("menu.file.new_project", "New Project…"),
|
||||
("menu.file.open_project", "Open Project…"),
|
||||
("menu.file.open_project", "Open Project File…"),
|
||||
("menu.file.open_library", "Open from Library…"),
|
||||
("menu.file.project_manager", "Project Manager…"),
|
||||
("menu.file.import_footage", "Import Footage…"),
|
||||
("menu.file.save", "Save"),
|
||||
("menu.file.save_as", "Save As…"),
|
||||
("menu.file.export_project", "Export Project File…"),
|
||||
("menu.file.close", "Close Project"),
|
||||
("menu.file.export", "Export…"),
|
||||
("menu.file.quit", "Quit"),
|
||||
@@ -240,9 +241,34 @@ const EN: &[(&str, &str)] = &[
|
||||
("status.ready", "Ready"),
|
||||
("status.cache", "Cache: Enabled"),
|
||||
("status.proxy", "Proxy: Off"),
|
||||
("status.autosave", "Autosave: 3 min ago"),
|
||||
("status.storage.written", "Library: written"),
|
||||
("status.storage.unbound", "Library: off"),
|
||||
("status.storage.error", "Library: write failed"),
|
||||
("status.untitled", "Untitled Project"),
|
||||
("status.backend", "Engine:"),
|
||||
// --- project manager ---
|
||||
("manager.title", "Project Manager"),
|
||||
("manager.new", "New Project"),
|
||||
("manager.new.default_name", "Untitled Project"),
|
||||
("manager.open", "Open"),
|
||||
("manager.rename", "Rename…"),
|
||||
("manager.rename.title", "Rename Project"),
|
||||
("manager.rename.label", "New name"),
|
||||
("manager.duplicate", "Duplicate"),
|
||||
("manager.delete", "Delete"),
|
||||
("manager.delete.title", "Delete Project"),
|
||||
("manager.delete.confirm", "Delete project \"{name}\" from the library? This cannot be undone."),
|
||||
("manager.import", "Import…"),
|
||||
("manager.import.title", "Import Project"),
|
||||
("manager.export", "Export…"),
|
||||
("manager.export.title", "Export Project"),
|
||||
("manager.col.name", "Name"),
|
||||
("manager.col.modified", "Modified"),
|
||||
("manager.col.duration", "Duration"),
|
||||
("manager.col.tracks", "Tracks"),
|
||||
("manager.col.clips", "Clips"),
|
||||
("manager.col.footage", "Footage"),
|
||||
("manager.empty", "No projects in the library yet."),
|
||||
// --- timeline toolbar ---
|
||||
("timeline.tool.select", "Select"),
|
||||
("timeline.tool.razor", "Razor"),
|
||||
@@ -327,10 +353,11 @@ const ZH: &[(&str, &str)] = &[
|
||||
("menu.help", "帮助(H)"),
|
||||
// --- File ---
|
||||
("menu.file.new_project", "新建项目…"),
|
||||
("menu.file.open_project", "打开项目…"),
|
||||
("menu.file.open_project", "打开工程文件…"),
|
||||
("menu.file.open_library", "从库中打开…"),
|
||||
("menu.file.project_manager", "项目管理器…"),
|
||||
("menu.file.import_footage", "导入素材…"),
|
||||
("menu.file.save", "保存"),
|
||||
("menu.file.save_as", "另存为…"),
|
||||
("menu.file.export_project", "导出工程文件…"),
|
||||
("menu.file.close", "关闭项目"),
|
||||
("menu.file.export", "导出…"),
|
||||
("menu.file.quit", "退出"),
|
||||
@@ -387,9 +414,37 @@ const ZH: &[(&str, &str)] = &[
|
||||
("status.ready", "就绪"),
|
||||
("status.cache", "缓存:已启用"),
|
||||
("status.proxy", "代理:关"),
|
||||
("status.autosave", "自动保存:3分钟前"),
|
||||
("status.storage.written", "库:已写入"),
|
||||
("status.storage.unbound", "库:未启用"),
|
||||
("status.storage.error", "库:写入失败"),
|
||||
("status.untitled", "未命名项目"),
|
||||
("status.backend", "引擎:"),
|
||||
// --- project manager ---
|
||||
("manager.title", "项目管理器"),
|
||||
("manager.new", "新建项目"),
|
||||
("manager.new.default_name", "未命名项目"),
|
||||
("manager.open", "打开"),
|
||||
("manager.rename", "重命名…"),
|
||||
("manager.rename.title", "重命名工程"),
|
||||
("manager.rename.label", "新名称"),
|
||||
("manager.duplicate", "复制"),
|
||||
("manager.delete", "删除"),
|
||||
("manager.delete.title", "删除工程"),
|
||||
(
|
||||
"manager.delete.confirm",
|
||||
"从库中删除工程“{name}”?此操作不可撤销。",
|
||||
),
|
||||
("manager.import", "导入…"),
|
||||
("manager.import.title", "导入工程"),
|
||||
("manager.export", "导出…"),
|
||||
("manager.export.title", "导出工程"),
|
||||
("manager.col.name", "名称"),
|
||||
("manager.col.modified", "修改时间"),
|
||||
("manager.col.duration", "时长"),
|
||||
("manager.col.tracks", "轨道"),
|
||||
("manager.col.clips", "片段"),
|
||||
("manager.col.footage", "素材"),
|
||||
("manager.empty", "库中还没有工程。"),
|
||||
// --- timeline toolbar ---
|
||||
("timeline.tool.select", "选择"),
|
||||
("timeline.tool.razor", "剃刀"),
|
||||
@@ -594,11 +649,11 @@ mod tests {
|
||||
fn switching_flips_a_sample_string() {
|
||||
let _guard = lang_lock().lock().unwrap();
|
||||
set_language(Language::EnUs);
|
||||
assert_eq!(tr("menu.file.save"), "Save");
|
||||
assert_eq!(tr("menu.file.export_project"), "Export Project File…");
|
||||
set_language(Language::ZhCN);
|
||||
assert_eq!(tr("menu.file.save"), "保存");
|
||||
assert_eq!(tr("menu.file.export_project"), "导出工程文件…");
|
||||
set_language(Language::EnUs);
|
||||
assert_eq!(tr("menu.file.save"), "Save");
|
||||
assert_eq!(tr("menu.file.export_project"), "Export Project File…");
|
||||
}
|
||||
|
||||
/// `sync_widgets` installs the active language's strings into the widget
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
//! * [`app`] — the window shell: menu bar, dock layout, status bar, modal
|
||||
//! dialogs (file open/save-as, preferences, export), tick loop.
|
||||
//! * [`dialogs`] — the preferences and export dialog content views.
|
||||
//! * [`manager`] — the project manager window (M13 D4): the library browser
|
||||
//! with new / open / rename / duplicate / delete / import / export.
|
||||
//! * [`panels`] — the dockable panels (viewers, timeline, inspector, ...).
|
||||
//! * [`oakui`] — the engine gateway trait, the mock + real implementations,
|
||||
//! and the pure view-state logic (timecode, transport).
|
||||
@@ -51,6 +53,7 @@
|
||||
pub mod app;
|
||||
pub mod dialogs;
|
||||
pub mod i18n;
|
||||
pub mod manager;
|
||||
pub mod oakui;
|
||||
pub mod panels;
|
||||
|
||||
|
||||
+538
@@ -0,0 +1,538 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// 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 project manager (M13 D4): the DaVinci-style library browser the app
|
||||
//! shows at startup (no `--project` argument) and from 文件 → 项目管理器.
|
||||
//!
|
||||
//! The view is a modal content view ([`ProjectManager`]) hosted by
|
||||
//! `crate::app::OakApp` inside the standard `Modal` card: a toolbar
|
||||
//! (新建 / 导入), a column list of the library rows (name, modified time,
|
||||
//! duration, tracks, clips, footage — the backend-derived stats), and an
|
||||
//! action row (打开 / 重命名 / 复制 / 删除 / 导出). The view emits
|
||||
//! [`ManagerEvent`] requests; the app routes them through the engine
|
||||
//! ([`AppEngine`]'s library surface) and swaps in the rename / delete
|
||||
//! confirmation modals.
|
||||
//!
|
||||
//! Pure formatting helpers ([`format_modified`], [`format_duration_ms`])
|
||||
//! are unit tested here; the app-level flows are covered by the
|
||||
//! `OakApp<MockEngine>` tests in `crate::app`.
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
div, App, ClickEvent, Context, ElementId, Entity, EventEmitter, Hsla, Render, SharedString,
|
||||
Window,
|
||||
};
|
||||
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
|
||||
|
||||
use crate::i18n;
|
||||
use crate::oakui::{AppEngine, LibraryProject};
|
||||
|
||||
/// A request the manager view emits for the host (the app shell) to route
|
||||
/// through the engine.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ManagerEvent {
|
||||
/// Open the project (double-click / the 打开 button).
|
||||
Open(String),
|
||||
/// Create a new blank project and open it.
|
||||
Create,
|
||||
/// Rename the project (the host prompts for the new name).
|
||||
Rename(String),
|
||||
/// Duplicate the project (history included).
|
||||
Duplicate(String),
|
||||
/// Delete the project (the host confirms first).
|
||||
Delete(String),
|
||||
/// Import a `.ove` / `.otio` / `.fcpxml` file as a new library row.
|
||||
Import,
|
||||
/// Export the project to a file.
|
||||
Export(String),
|
||||
}
|
||||
|
||||
/// The project manager content view: the library list plus its toolbars.
|
||||
pub struct ProjectManager<E: AppEngine> {
|
||||
engine: Entity<E>,
|
||||
/// The listed rows (most recently modified first, as the engine
|
||||
/// reports them).
|
||||
rows: Vec<LibraryProject>,
|
||||
/// The selected row index.
|
||||
selected: Option<usize>,
|
||||
/// The last operation error (shown under the list).
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> ProjectManager<E> {
|
||||
/// Builds the view and loads the library.
|
||||
pub fn new(engine: Entity<E>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let mut this = Self {
|
||||
engine,
|
||||
rows: Vec::new(),
|
||||
selected: None,
|
||||
status: None,
|
||||
};
|
||||
this.reload(cx);
|
||||
this
|
||||
}
|
||||
|
||||
/// Reloads the library from the engine, keeping the selection on the
|
||||
/// same row (by uuid) when it still exists.
|
||||
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
let selected_uuid = self.selected_uuid();
|
||||
match self.engine.read(cx).library_projects() {
|
||||
Ok(rows) => {
|
||||
self.rows = rows;
|
||||
self.selected = selected_uuid
|
||||
.and_then(|uuid| self.rows.iter().position(|row| row.uuid == uuid));
|
||||
self.status = None;
|
||||
}
|
||||
Err(err) => {
|
||||
self.rows = Vec::new();
|
||||
self.selected = None;
|
||||
self.status = Some(err);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The selected row's uuid, if any.
|
||||
pub fn selected_uuid(&self) -> Option<String> {
|
||||
self.selected
|
||||
.and_then(|index| self.rows.get(index))
|
||||
.map(|row| row.uuid.clone())
|
||||
}
|
||||
|
||||
/// The selected row's display name, if any.
|
||||
pub fn selected_name(&self) -> Option<String> {
|
||||
self.selected
|
||||
.and_then(|index| self.rows.get(index))
|
||||
.map(|row| row.name.clone())
|
||||
}
|
||||
|
||||
/// Shows an operation error under the list (the host reports engine
|
||||
/// failures here so a failed action is visible in the dialog).
|
||||
pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
|
||||
self.status = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The listed rows (tests).
|
||||
#[cfg(test)]
|
||||
pub fn rows(&self) -> &[LibraryProject] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
/// Selects the row with `uuid` (tests and the host's post-action
|
||||
/// reselection).
|
||||
pub fn select(&mut self, uuid: &str, cx: &mut Context<Self>) {
|
||||
self.selected = self.rows.iter().position(|row| row.uuid == uuid);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Emits the event through the view's subscribers.
|
||||
fn emit(&mut self, event: ManagerEvent, cx: &mut Context<Self>) {
|
||||
cx.emit(event);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A click on row `index`: select, or open on a double click.
|
||||
fn row_clicked(&mut self, index: usize, clicks: usize, cx: &mut Context<Self>) {
|
||||
if index >= self.rows.len() {
|
||||
return;
|
||||
}
|
||||
self.selected = Some(index);
|
||||
if clicks >= 2 {
|
||||
let uuid = self.rows[index].uuid.clone();
|
||||
self.emit(ManagerEvent::Open(uuid), cx);
|
||||
} else {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// An action-row button targeting the selection.
|
||||
fn selected_action(&mut self, action: impl FnOnce(String) -> ManagerEvent, cx: &mut Context<Self>) {
|
||||
if let Some(uuid) = self.selected_uuid() {
|
||||
self.emit(action(uuid), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<ManagerEvent> for ProjectManager<E> {}
|
||||
|
||||
impl<E: AppEngine> Render for ProjectManager<E> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
|
||||
// Toolbar buttons (新建 / 导入) and the selection-targeting action
|
||||
// row (打开 / 重命名 / 复制 / 删除 / 导出).
|
||||
let tool = |id: &'static str, label: &'static str| -> gpui::Stateful<gpui::Div> {
|
||||
div()
|
||||
.id(id)
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded_md()
|
||||
.bg(colors.background)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.text)
|
||||
.cursor_pointer()
|
||||
.child(label)
|
||||
};
|
||||
let has_selection = self.selected.is_some();
|
||||
let action = |id: &'static str, label: &'static str| -> gpui::Stateful<gpui::Div> {
|
||||
let mut b = div().id(id).px_3().py_1().rounded_md();
|
||||
if has_selection {
|
||||
b = b
|
||||
.bg(colors.background)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.text)
|
||||
.cursor_pointer();
|
||||
} else {
|
||||
b = b.text_color(colors.disabled);
|
||||
}
|
||||
b.child(label)
|
||||
};
|
||||
|
||||
// The column header.
|
||||
let header_cell = |label: &'static str, width: f32| -> gpui::Div {
|
||||
let mut cell = div()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(colors.disabled)
|
||||
.whitespace_nowrap()
|
||||
.child(label);
|
||||
if width <= 0.0 {
|
||||
cell = cell.flex_1();
|
||||
} else {
|
||||
cell = cell.w(gpui::px(width)).text_right();
|
||||
}
|
||||
cell
|
||||
};
|
||||
|
||||
let list = div()
|
||||
.h(gpui::px(340.0))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.rounded_md()
|
||||
.bg(colors.background)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.py_1()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(header_cell(i18n::tr("manager.col.name"), 0.0))
|
||||
.child(header_cell(i18n::tr("manager.col.modified"), 128.0))
|
||||
.child(header_cell(i18n::tr("manager.col.duration"), 72.0))
|
||||
.child(header_cell(i18n::tr("manager.col.tracks"), 56.0))
|
||||
.child(header_cell(i18n::tr("manager.col.clips"), 56.0))
|
||||
.child(header_cell(i18n::tr("manager.col.footage"), 56.0)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("manager-list")
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.children(if self.rows.is_empty() {
|
||||
vec![
|
||||
div()
|
||||
.p_4()
|
||||
.text_color(colors.disabled)
|
||||
.child(i18n::tr("manager.empty"))
|
||||
.into_any_element(),
|
||||
]
|
||||
} else {
|
||||
self.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| {
|
||||
let selected = self.selected == Some(index);
|
||||
let mut line = div()
|
||||
.id(ElementId::Name(format!("manager-row-{index}").into()))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.py_1()
|
||||
.cursor_pointer()
|
||||
.on_click(cx.listener(move |this, event: &ClickEvent, _w, cx| {
|
||||
this.row_clicked(index, event.click_count(), cx);
|
||||
}));
|
||||
if selected {
|
||||
line = line.bg(colors.selected).text_color(colors.selected_text);
|
||||
} else {
|
||||
line = line.text_color(colors.text);
|
||||
}
|
||||
let cell = |text: String, width: f32| -> gpui::Div {
|
||||
let mut c = div().px_2().whitespace_nowrap().child(text);
|
||||
if width <= 0.0 {
|
||||
c = c.flex_1();
|
||||
} else {
|
||||
c = c.w(gpui::px(width)).text_right();
|
||||
}
|
||||
c
|
||||
};
|
||||
line.child(cell(row.name.clone(), 0.0))
|
||||
.child(cell(format_modified(row.modified_at), 128.0))
|
||||
.child(cell(format_duration_ms(row.duration_ms), 72.0))
|
||||
.child(cell(row.track_count.to_string(), 56.0))
|
||||
.child(cell(row.clip_count.to_string(), 56.0))
|
||||
.child(cell(row.footage_count.to_string(), 56.0))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
);
|
||||
|
||||
let mut root = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
tool("manager-new", i18n::tr("manager.new")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.emit(ManagerEvent::Create, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
tool("manager-import", i18n::tr("manager.import")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.emit(ManagerEvent::Import, cx);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(list)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.child(
|
||||
action("manager-open", i18n::tr("manager.open")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Open, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-rename", i18n::tr("manager.rename")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Rename, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-duplicate", i18n::tr("manager.duplicate")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Duplicate, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-delete", i18n::tr("manager.delete")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Delete, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-export", i18n::tr("manager.export")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Export, cx);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
if let Some(status) = &self.status {
|
||||
root = root.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(Hsla {
|
||||
h: 0.0,
|
||||
s: 0.6,
|
||||
l: 0.55,
|
||||
a: 1.0,
|
||||
})
|
||||
.child(status.clone()),
|
||||
);
|
||||
}
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rename prompt / delete confirmation contents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The rename prompt's content: one text field with the new name.
|
||||
pub struct NamePrompt {
|
||||
editor: Entity<EditableTextState>,
|
||||
}
|
||||
|
||||
impl NamePrompt {
|
||||
/// Builds the prompt seeded with `initial`.
|
||||
pub fn new(initial: &str, cx: &mut Context<Self>) -> Self {
|
||||
let editor = cx.new(|cx| {
|
||||
let editor = EditableTextState::new(StringStorage::default(), cx);
|
||||
editor
|
||||
});
|
||||
editor.update(cx, |editor, cx| editor.emplace(initial, cx));
|
||||
Self { editor }
|
||||
}
|
||||
|
||||
/// The name currently entered (trimmed).
|
||||
pub fn value(&self, app: &App) -> String {
|
||||
self.editor.read(app).as_str().trim().to_string()
|
||||
}
|
||||
|
||||
/// Replaces the entered name (tests / prefill).
|
||||
pub fn set_value(&mut self, value: &str, cx: &mut Context<Self>) {
|
||||
self.editor.update(cx, |editor, cx| editor.emplace(value, cx));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NamePrompt {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let weak = self.editor.downgrade();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.text)
|
||||
.child(i18n::tr("manager.rename.label")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.background)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.child(
|
||||
text_input("gpui-widgets-rename-field")
|
||||
.state(weak)
|
||||
.accepts_input(true),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The delete confirmation's content: the warning text.
|
||||
pub struct ConfirmContent {
|
||||
text: SharedString,
|
||||
}
|
||||
|
||||
impl ConfirmContent {
|
||||
/// Builds the confirmation with `text`.
|
||||
pub fn new(text: impl Into<SharedString>) -> Self {
|
||||
Self { text: text.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ConfirmContent {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
div().text_color(colors.text).child(self.text.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Formats a unix timestamp (UTC) as `YYYY-MM-DD HH:MM` for the modified
|
||||
/// column.
|
||||
pub fn format_modified(unix_secs: i64) -> String {
|
||||
if unix_secs <= 0 {
|
||||
return "—".to_string();
|
||||
}
|
||||
let days = unix_secs.div_euclid(86_400);
|
||||
let secs = unix_secs.rem_euclid(86_400);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
|
||||
secs / 3600,
|
||||
(secs % 3600) / 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Days since the unix epoch → (year, month, day), UTC (Howard Hinnant's
|
||||
/// civil-from-days algorithm).
|
||||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let year = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
||||
let year = if month <= 2 { year + 1 } else { year };
|
||||
(year, month, day)
|
||||
}
|
||||
|
||||
/// Formats a duration in milliseconds as `H:MM:SS` for the duration
|
||||
/// column.
|
||||
pub fn format_duration_ms(ms: i64) -> String {
|
||||
if ms <= 0 {
|
||||
return "—".to_string();
|
||||
}
|
||||
let secs = ms / 1000;
|
||||
format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The civil-date conversion matches known dates.
|
||||
#[test]
|
||||
fn format_modified_known_dates() {
|
||||
assert_eq!(format_modified(0), "—");
|
||||
assert_eq!(format_modified(-5), "—");
|
||||
// 2026-08-16 00:54:01 UTC.
|
||||
assert_eq!(format_modified(1_786_841_641), "2026-08-16 00:54");
|
||||
// 1970-01-01 00:00 UTC.
|
||||
assert_eq!(format_modified(1), "1970-01-01 00:00");
|
||||
// 2000-02-29 (a leap day) 12:34 UTC.
|
||||
assert_eq!(format_modified(951_827_640), "2000-02-29 12:34");
|
||||
}
|
||||
|
||||
/// Durations format as H:MM:SS with a dash for empty projects.
|
||||
#[test]
|
||||
fn format_duration_ms_shapes() {
|
||||
assert_eq!(format_duration_ms(0), "—");
|
||||
assert_eq!(format_duration_ms(61_500), "0:01:01");
|
||||
assert_eq!(format_duration_ms(3_725_000), "1:02:05");
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,30 @@ pub struct Project {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// A project-library row, as the project manager lists it (M13 D4). The
|
||||
/// stats are derived from the row's head state by the backend (they are
|
||||
/// never stored in the library).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LibraryProject {
|
||||
/// The library row uuid (the open / rename / duplicate / delete /
|
||||
/// export selector).
|
||||
pub uuid: String,
|
||||
/// The row's display name.
|
||||
pub name: String,
|
||||
/// Row creation time (unix seconds, UTC).
|
||||
pub created_at: i64,
|
||||
/// Last-write time (unix seconds, UTC; the manager sort key).
|
||||
pub modified_at: i64,
|
||||
/// Longest sequence duration in milliseconds.
|
||||
pub duration_ms: i64,
|
||||
/// Total tracks across all sequences.
|
||||
pub track_count: i32,
|
||||
/// Total clip blocks.
|
||||
pub clip_count: i32,
|
||||
/// Total footage nodes.
|
||||
pub footage_count: i32,
|
||||
}
|
||||
|
||||
/// The sequence currently open in the project.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Sequence {
|
||||
@@ -293,6 +317,73 @@ pub trait AppEngine:
|
||||
|
||||
/// Closes the current project, leaving the app with no sequence.
|
||||
fn close_project(&mut self, cx: &mut Context<Self>);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Project library (M13 D4: the write-through database the manager
|
||||
// window browses). Default: unsupported (empty list / error strings).
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Whether the open project is bound to the library write-through
|
||||
/// session (the status bar's write state).
|
||||
fn storage_bound(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The last write-through / snapshot error of the open project, if any.
|
||||
fn storage_last_error(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Lists the project library, most recently modified first (the
|
||||
/// project manager's data source).
|
||||
fn library_projects(&self) -> Result<Vec<LibraryProject>, String> {
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Creates a blank project named `name` in the library and opens it.
|
||||
fn library_create_project(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let _ = (name, cx);
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Opens the library project `uuid` (closing the current project).
|
||||
fn library_open_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let _ = (uuid, cx);
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Deletes the library project `uuid` (the manager confirms first).
|
||||
fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> {
|
||||
let _ = uuid;
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Renames the library project `uuid` (the manager's list name).
|
||||
fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> {
|
||||
let _ = (uuid, name);
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Duplicates the library project `uuid` (history included) under a
|
||||
/// fresh uuid.
|
||||
fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> {
|
||||
let _ = uuid;
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Imports a `.ove` / `.otio` / `.fcpxml` project file into the library
|
||||
/// as a new row; returns the new row's uuid.
|
||||
fn library_import_project(&mut self, path: PathBuf) -> Result<String, String> {
|
||||
let _ = path;
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// Exports the library project `uuid` to `path`; the format is
|
||||
/// dispatched by extension (`.ove` / `.otio` / `.fcpxml`).
|
||||
fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> {
|
||||
let _ = (uuid, path);
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
/// The timeline waveform cache (M12 P4); `None` when the backend
|
||||
/// does not provide waveforms.
|
||||
fn waveform_cache(&self) -> Option<std::sync::Arc<crate::oakui::waveform::WaveformCache>> {
|
||||
|
||||
@@ -281,6 +281,67 @@ unsafe extern "C" {
|
||||
/// `oakengine_config_set_string` — write a string value.
|
||||
pub fn oakengine_config_set_string(key: *const c_char, value: *const c_char) -> c_int;
|
||||
|
||||
// -- oakengine::storage (write-through session state) --
|
||||
|
||||
/// `oakengine_storage_flush` — flush every bound project and stop the
|
||||
/// snapshot thread (the app calls it on exit).
|
||||
pub fn oakengine_storage_flush() -> c_int;
|
||||
/// `oakengine_storage_is_bound` — 1 when the project is bound to a
|
||||
/// library session.
|
||||
pub fn oakengine_storage_is_bound(project: *mut OakEngineProject) -> c_int;
|
||||
/// `oakengine_storage_last_error` — the last write-through / snapshot
|
||||
/// error (buf/size; empty when none or not bound).
|
||||
pub fn oakengine_storage_last_error(
|
||||
project: *mut OakEngineProject,
|
||||
buf: *mut c_char,
|
||||
buf_size: c_int,
|
||||
) -> c_int;
|
||||
|
||||
// -- oakengine::library (project manager, M13 D4) --
|
||||
|
||||
/// `oakengine_library_list` — the library rows as a JSON array
|
||||
/// (buf/size), most recently modified first; `"[]"` with storage off.
|
||||
pub fn oakengine_library_list(buf: *mut c_char, buf_size: c_int) -> c_int;
|
||||
/// `oakengine_library_create` — create a blank project row; reports its
|
||||
/// uuid (buf/size on `out_uuid`; the return value is the uuid length,
|
||||
/// negative on error).
|
||||
pub fn oakengine_library_create(
|
||||
name: *const c_char,
|
||||
out_uuid: *mut c_char,
|
||||
out_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_library_delete` — delete a row by uuid.
|
||||
pub fn oakengine_library_delete(uuid: *const c_char) -> c_int;
|
||||
/// `oakengine_library_rename` — rename a row.
|
||||
pub fn oakengine_library_rename(uuid: *const c_char, name: *const c_char) -> c_int;
|
||||
/// `oakengine_library_duplicate` — copy a row (history included);
|
||||
/// reports the new uuid like `oakengine_library_create`.
|
||||
pub fn oakengine_library_duplicate(
|
||||
uuid: *const c_char,
|
||||
name: *const c_char,
|
||||
out_uuid: *mut c_char,
|
||||
out_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_library_import` — import a `.ove`/`.otio`/`.fcpxml` file
|
||||
/// as a new row; reports the new uuid like `oakengine_library_create`.
|
||||
pub fn oakengine_library_import(
|
||||
path: *const c_char,
|
||||
out_uuid: *mut c_char,
|
||||
out_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_library_export` — export a row to `path` (format by
|
||||
/// extension).
|
||||
pub fn oakengine_library_export(uuid: *const c_char, path: *const c_char) -> c_int;
|
||||
/// `oakengine_project_load_library` — load a library row into a fresh
|
||||
/// project shell (same contract as `oakengine_project_load`); binds the
|
||||
/// project to the library session.
|
||||
pub fn oakengine_project_load_library(
|
||||
self_: *mut OakEngineProject,
|
||||
uuid: *const c_char,
|
||||
err: *mut c_char,
|
||||
err_size: c_int,
|
||||
) -> c_int;
|
||||
|
||||
// -- oakengine::node (project) --
|
||||
|
||||
/// `oakengine_project_create` — owned project box (no content yet).
|
||||
|
||||
+185
-2
@@ -62,14 +62,60 @@ use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry};
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
use super::engine::{
|
||||
AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence,
|
||||
VideoFormat,
|
||||
AppEngine, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, Project,
|
||||
ScopeData, Sequence, VideoFormat,
|
||||
};
|
||||
use super::transport::TransportState;
|
||||
|
||||
/// The demo sequence length: 00:04:18:18 at 25 fps.
|
||||
const SEQUENCE_LENGTH: i64 = 6468;
|
||||
|
||||
/// The current unix time in seconds (0 on clock failure).
|
||||
fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The demo library rows the project manager opens with (M13 D4): three
|
||||
/// projects with plausible stats, most recently modified first.
|
||||
fn demo_library() -> Vec<LibraryProject> {
|
||||
let now = now_unix();
|
||||
vec![
|
||||
LibraryProject {
|
||||
uuid: "mock-1".into(),
|
||||
name: "第一稿".into(),
|
||||
created_at: now - 86400,
|
||||
modified_at: now - 300,
|
||||
duration_ms: SEQUENCE_LENGTH * 1000 / 25,
|
||||
track_count: 4,
|
||||
clip_count: 5,
|
||||
footage_count: 3,
|
||||
},
|
||||
LibraryProject {
|
||||
uuid: "mock-2".into(),
|
||||
name: "宣传片 v3".into(),
|
||||
created_at: now - 3 * 86400,
|
||||
modified_at: now - 86400,
|
||||
duration_ms: 95_000,
|
||||
track_count: 6,
|
||||
clip_count: 14,
|
||||
footage_count: 8,
|
||||
},
|
||||
LibraryProject {
|
||||
uuid: "mock-3".into(),
|
||||
name: "采访粗剪".into(),
|
||||
created_at: now - 9 * 86400,
|
||||
modified_at: now - 7 * 86400,
|
||||
duration_ms: 612_000,
|
||||
track_count: 3,
|
||||
clip_count: 22,
|
||||
footage_count: 5,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clocks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -431,6 +477,19 @@ pub struct MockEngine {
|
||||
/// has no media pipeline, so it just records them (drives app-level tests
|
||||
/// of the import flow).
|
||||
imported_footage: Vec<PathBuf>,
|
||||
/// The fake project library the project manager browses (M13 D4): an
|
||||
/// in-memory row set the library trait methods operate on, so the app
|
||||
/// flow (list / open / create / rename / duplicate / delete / import /
|
||||
/// export) is testable without a database.
|
||||
library: Vec<LibraryProject>,
|
||||
/// Id allocator for library rows created at runtime.
|
||||
next_library_id: u64,
|
||||
/// The uuids handed to [`AppEngine::library_open_project`] (test
|
||||
/// observability).
|
||||
library_opened: Vec<String>,
|
||||
/// (uuid, path) pairs handed to [`AppEngine::library_export_project`]
|
||||
/// (test observability).
|
||||
library_exported: Vec<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
impl MockEngine {
|
||||
@@ -674,6 +733,10 @@ impl MockEngine {
|
||||
node_selection: BTreeSet::new(),
|
||||
cpu_frame_cache: Mutex::new(HashMap::new()),
|
||||
imported_footage: Vec::new(),
|
||||
library: demo_library(),
|
||||
next_library_id: 100,
|
||||
library_opened: Vec::new(),
|
||||
library_exported: Vec::new(),
|
||||
};
|
||||
// The demo graph is born connected: derive every port's `connected`
|
||||
// flag from the edge list.
|
||||
@@ -1274,6 +1337,114 @@ impl AppEngine for MockEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
// --- project library (M13 D4, in-memory fake) -------------------------
|
||||
|
||||
fn storage_bound(&self) -> bool {
|
||||
// The mock pretends the demo project lives in the library.
|
||||
true
|
||||
}
|
||||
|
||||
fn library_projects(&self) -> Result<Vec<LibraryProject>, String> {
|
||||
let mut rows = self.library.clone();
|
||||
rows.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn library_create_project(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let uuid = format!("mock-{}", self.next_library_id);
|
||||
self.next_library_id += 1;
|
||||
let now = now_unix();
|
||||
self.library.push(LibraryProject {
|
||||
uuid,
|
||||
name: name.to_string(),
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
duration_ms: 0,
|
||||
track_count: 0,
|
||||
clip_count: 0,
|
||||
footage_count: 0,
|
||||
});
|
||||
self.project.name = name.to_string();
|
||||
self.project.path = PathBuf::new();
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_open_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let Some(row) = self.library.iter().find(|row| row.uuid == uuid) else {
|
||||
return Err(format!("library project {uuid} not found"));
|
||||
};
|
||||
self.project.name = row.name.clone();
|
||||
self.project.path = PathBuf::new();
|
||||
self.library_opened.push(uuid.to_string());
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> {
|
||||
let before = self.library.len();
|
||||
self.library.retain(|row| row.uuid != uuid);
|
||||
if self.library.len() == before {
|
||||
return Err(format!("library project {uuid} not found"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> {
|
||||
let Some(row) = self.library.iter_mut().find(|row| row.uuid == uuid) else {
|
||||
return Err(format!("library project {uuid} not found"));
|
||||
};
|
||||
row.name = name.to_string();
|
||||
row.modified_at = now_unix();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> {
|
||||
let Some(row) = self.library.iter().find(|row| row.uuid == uuid).cloned() else {
|
||||
return Err(format!("library project {uuid} not found"));
|
||||
};
|
||||
let now = now_unix();
|
||||
self.library.push(LibraryProject {
|
||||
uuid: format!("mock-{}", self.next_library_id),
|
||||
name: format!("{} (copy)", row.name),
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
..row
|
||||
});
|
||||
self.next_library_id += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_import_project(&mut self, path: PathBuf) -> Result<String, String> {
|
||||
let name = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| format!("invalid project file \"{}\"", path.display()))?;
|
||||
let uuid = format!("mock-{}", self.next_library_id);
|
||||
self.next_library_id += 1;
|
||||
let now = now_unix();
|
||||
self.library.push(LibraryProject {
|
||||
uuid: uuid.clone(),
|
||||
name,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
duration_ms: 0,
|
||||
track_count: 0,
|
||||
clip_count: 0,
|
||||
footage_count: 0,
|
||||
});
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> {
|
||||
if !self.library.iter().any(|row| row.uuid == uuid) {
|
||||
return Err(format!("library project {uuid} not found"));
|
||||
}
|
||||
self.library_exported.push((uuid.to_string(), path));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_export(&mut self, _format: i32, _path: PathBuf) -> Result<ExportSession, String> {
|
||||
// Mock export: fake progress on a background thread, no file.
|
||||
let (tx, rx) = mpsc::channel::<ExportEvent>();
|
||||
@@ -1421,6 +1592,18 @@ impl MockEngine {
|
||||
&self.imported_footage
|
||||
}
|
||||
|
||||
/// The uuids opened via [`AppEngine::library_open_project`] so far
|
||||
/// (mock state; drives app-level tests of the manager's open flow).
|
||||
pub fn library_opened(&self) -> &[String] {
|
||||
&self.library_opened
|
||||
}
|
||||
|
||||
/// The (uuid, path) pairs exported via
|
||||
/// [`AppEngine::library_export_project`] so far (mock state).
|
||||
pub fn library_exported(&self) -> &[(String, PathBuf)] {
|
||||
&self.library_exported
|
||||
}
|
||||
|
||||
/// The selected material-bin entry id (demo state).
|
||||
pub fn selected_item(&self) -> Option<u64> {
|
||||
self.selected_item
|
||||
|
||||
+2
-2
@@ -53,8 +53,8 @@ pub mod transport;
|
||||
pub mod waveform;
|
||||
|
||||
pub use engine::{
|
||||
AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData,
|
||||
Sequence, VideoFormat,
|
||||
AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor,
|
||||
Project, ScopeData, Sequence, VideoFormat,
|
||||
};
|
||||
pub use mock::{MockClock, MockEngine};
|
||||
pub use real::{RealClock, RealEngine};
|
||||
|
||||
+223
-2
@@ -101,8 +101,8 @@ use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry};
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
use super::engine::{
|
||||
AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence,
|
||||
VideoFormat,
|
||||
AppEngine, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, Project,
|
||||
ScopeData, Sequence, VideoFormat,
|
||||
};
|
||||
use super::ffi::*;
|
||||
use super::frames::{f32_rgba_to_bgra_image, synthetic_frame_samples};
|
||||
@@ -2230,6 +2230,112 @@ impl AppEngine for RealEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
// --- project library (M13 D4) --------------------------------------
|
||||
|
||||
fn storage_bound(&self) -> bool {
|
||||
self.project_ptr()
|
||||
.map(|p| unsafe { oakengine_storage_is_bound(p) } != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn storage_last_error(&self) -> Option<String> {
|
||||
let project = self.project_ptr()?;
|
||||
let message = read_string(|buf, size| unsafe {
|
||||
oakengine_storage_last_error(project, buf, size)
|
||||
});
|
||||
if message.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(message)
|
||||
}
|
||||
}
|
||||
|
||||
fn library_projects(&self) -> Result<Vec<LibraryProject>, String> {
|
||||
library_list()
|
||||
}
|
||||
|
||||
fn library_create_project(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let uuid = library_create(name)?;
|
||||
self.open_library_project(&uuid, cx)
|
||||
}
|
||||
|
||||
fn library_open_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
self.open_library_project(uuid, cx)
|
||||
}
|
||||
|
||||
fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> {
|
||||
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
|
||||
let rc = unsafe { oakengine_library_delete(uuid_c.as_ptr()) };
|
||||
if rc != 0 {
|
||||
return Err(format!("failed to delete the project (error {rc})"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> {
|
||||
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
|
||||
let name_c = CString::new(name).map_err(|_| "invalid name".to_string())?;
|
||||
let rc = unsafe { oakengine_library_rename(uuid_c.as_ptr(), name_c.as_ptr()) };
|
||||
if rc != 0 {
|
||||
return Err(format!("failed to rename the project (error {rc})"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> {
|
||||
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
|
||||
let mut buf = [0 as c_char; 256];
|
||||
// Single call with a stack buffer: the duplicate has a side effect,
|
||||
// so the two-stage (measure-then-read) pattern must not be used.
|
||||
let rc = unsafe {
|
||||
oakengine_library_duplicate(
|
||||
uuid_c.as_ptr(),
|
||||
std::ptr::null(),
|
||||
buf.as_mut_ptr(),
|
||||
buf.len() as c_int,
|
||||
)
|
||||
};
|
||||
if rc < 0 {
|
||||
return Err(format!("failed to duplicate the project (error {rc})"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn library_import_project(&mut self, path: PathBuf) -> Result<String, String> {
|
||||
let path_c = cstr_path(&path).ok_or("invalid import path")?;
|
||||
let mut buf = [0 as c_char; 256];
|
||||
// Single call with a stack buffer (side effect; see duplicate).
|
||||
let rc = unsafe {
|
||||
oakengine_library_import(path_c.as_ptr(), buf.as_mut_ptr(), buf.len() as c_int)
|
||||
};
|
||||
if rc < 0 {
|
||||
return Err(format!(
|
||||
"failed to import \"{}\" (error {rc})",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
Ok(
|
||||
String::from_utf8_lossy(unsafe {
|
||||
std::slice::from_raw_parts(buf.as_ptr() as *const u8, len)
|
||||
})
|
||||
.into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> {
|
||||
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
|
||||
let path_c = cstr_path(&path).ok_or("invalid export path")?;
|
||||
let rc = unsafe { oakengine_library_export(uuid_c.as_ptr(), path_c.as_ptr()) };
|
||||
if rc != 0 {
|
||||
return Err(format!(
|
||||
"failed to export the project to \"{}\" (error {rc})",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_export(&mut self, format: i32, path: PathBuf) -> Result<ExportSession, String> {
|
||||
let Some(seq) = self.seq_ptr() else {
|
||||
return Err("no sequence open".into());
|
||||
@@ -2589,6 +2695,121 @@ pub fn renderer_backends() -> Vec<&'static str> {
|
||||
vec!["opengl", "metal", "vulkan", "none"]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project library (M13 D4: the write-through database the manager browses)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The config key selecting the storage backend (see
|
||||
/// `crates/oakengine/src/storage.rs`).
|
||||
pub const CONFIG_KEY_STORAGE_BACKEND: &str = "Storage/Backend";
|
||||
|
||||
/// Enables the SQLite write-through library unless the user configured the
|
||||
/// backend explicitly (any existing value — including "off" — wins over the
|
||||
/// app's default). The library path defaults facade-side to
|
||||
/// `<system data directory>/library.db`.
|
||||
pub fn configure_storage() {
|
||||
if config_get_string(CONFIG_KEY_STORAGE_BACKEND).is_empty() {
|
||||
config_set_string(CONFIG_KEY_STORAGE_BACKEND, "sqlite");
|
||||
}
|
||||
}
|
||||
|
||||
/// Flushes every bound project (write-through + snapshot) and stops the
|
||||
/// facade's snapshot thread. The app calls this on exit.
|
||||
pub fn storage_flush() {
|
||||
unsafe {
|
||||
oakengine_storage_flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The library rows, most recently modified first (the project manager's
|
||||
/// data source; JSON over the facade's `oakengine_library_list`).
|
||||
pub fn library_list() -> Result<Vec<LibraryProject>, String> {
|
||||
let needed = unsafe { oakengine_library_list(std::ptr::null_mut(), 0) };
|
||||
if needed < 0 {
|
||||
return Err(format!("failed to list the library (error {needed})"));
|
||||
}
|
||||
let mut buf = vec![0 as c_char; needed as usize + 1];
|
||||
unsafe { oakengine_library_list(buf.as_mut_ptr(), needed + 1) };
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
let json =
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
|
||||
.into_owned();
|
||||
let rows: serde_json::Value =
|
||||
serde_json::from_str(&json).map_err(|e| format!("malformed library list: {e}"))?;
|
||||
let Some(rows) = rows.as_array() else {
|
||||
return Err("malformed library list (not an array)".into());
|
||||
};
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let s = |key: &str| row.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let n = |key: &str| row.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
LibraryProject {
|
||||
uuid: s("uuid"),
|
||||
name: s("name"),
|
||||
created_at: n("created_at"),
|
||||
modified_at: n("modified_at"),
|
||||
duration_ms: n("duration_ms"),
|
||||
track_count: n("track_count") as i32,
|
||||
clip_count: n("clip_count") as i32,
|
||||
footage_count: n("footage_count") as i32,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Creates a blank project row in the library; returns its uuid. Single
|
||||
/// call with a stack buffer: the create has a side effect, so the
|
||||
/// two-stage (measure-then-read) pattern must not be used.
|
||||
fn library_create(name: &str) -> Result<String, String> {
|
||||
let name_c = CString::new(name).map_err(|_| "invalid name".to_string())?;
|
||||
let mut buf = [0 as c_char; 256];
|
||||
let rc = unsafe { oakengine_library_create(name_c.as_ptr(), buf.as_mut_ptr(), buf.len() as c_int) };
|
||||
if rc < 0 {
|
||||
return Err(format!("failed to create the project (error {rc})"));
|
||||
}
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
Ok(
|
||||
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
|
||||
.into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
impl RealEngine {
|
||||
/// Opens the library row `uuid` through the facade's library-load path
|
||||
/// (which binds the project to the write-through session) and adopts it.
|
||||
fn open_library_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let project = unsafe { oakengine_project_create() };
|
||||
if project.is_null() {
|
||||
return Err("failed to create a project".into());
|
||||
}
|
||||
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
|
||||
let mut err = [0 as c_char; 4096];
|
||||
let rc = unsafe {
|
||||
oakengine_project_load_library(
|
||||
project,
|
||||
uuid_c.as_ptr(),
|
||||
err.as_mut_ptr(),
|
||||
err.len() as c_int,
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
let message = load_error(&mut err);
|
||||
unsafe { oakengine_project_free(project) };
|
||||
return Err(format!("failed to open the library project: {message}"));
|
||||
}
|
||||
self.adopt_project(project, cx);
|
||||
// The facade's project name is filename-derived ("(untitled)" for a
|
||||
// library row); display the library row name instead.
|
||||
if let Ok(rows) = library_list() {
|
||||
if let Some(row) = rows.iter().find(|row| row.uuid == uuid) {
|
||||
self.project_info.name = row.name.clone();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
// 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 global status bar (状态栏): ready state, cache, proxy and autosave
|
||||
//! info on the left; current timecode / duration, frame rate and resolution
|
||||
//! on the right.
|
||||
//! The global status bar (状态栏): ready state, cache, proxy and the
|
||||
//! library write state (M13 D4: the write-through replaces the manual save,
|
||||
//! so the old autosave hint becomes "written to the library / library off /
|
||||
//! write failed") on the left; current timecode / duration, frame rate and
|
||||
//! resolution on the right.
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::timeline::Frame;
|
||||
@@ -66,6 +68,25 @@ impl<E: AppEngine> Render for StatusBar<E> {
|
||||
div().px_2().py_1().text_color(colors.text).child(text)
|
||||
};
|
||||
|
||||
// The write-through state (M13 D4): a bound project with no recorded
|
||||
// error is written through; an error turns the segment red.
|
||||
let (storage_text, storage_color) = if engine.storage_last_error().is_some() {
|
||||
(
|
||||
crate::i18n::tr("status.storage.error"),
|
||||
gpui::rgba(0xcc6666ff),
|
||||
)
|
||||
} else if engine.storage_bound() {
|
||||
(
|
||||
crate::i18n::tr("status.storage.written"),
|
||||
colors.disabled,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
crate::i18n::tr("status.storage.unbound"),
|
||||
colors.disabled,
|
||||
)
|
||||
};
|
||||
|
||||
div()
|
||||
.h_6()
|
||||
.flex()
|
||||
@@ -77,7 +98,13 @@ impl<E: AppEngine> Render for StatusBar<E> {
|
||||
.child(segment(&colors, crate::i18n::tr("status.ready").into()))
|
||||
.child(segment(&colors, crate::i18n::tr("status.cache").into()))
|
||||
.child(segment(&colors, crate::i18n::tr("status.proxy").into()))
|
||||
.child(segment(&colors, crate::i18n::tr("status.autosave").into()))
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.text_color(storage_color)
|
||||
.child(storage_text),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(segment(
|
||||
&colors,
|
||||
|
||||
Reference in New Issue
Block a user