refactor(app): retire manual-save semantics (M13 D5)
- AppEngine: save_project -> export_project_path(explicit path); project_modified removed (write-through has no dirty flag) - File menu's save/save-as are the export path (extension-dispatched ove/otio/fcpxml); the library row export path is unchanged - facade project_save/is_modified stay frozen, documented as the legacy export surface - docs: M13 D5 checked off; M12 inventory updated
This commit is contained in:
@@ -518,8 +518,16 @@ pub unsafe extern "C" fn oakengine_project_load(
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_project_save` — save the project to `path` (or its own
|
||||
/// `oakengine_project_save` — write the project to `path` (or its own
|
||||
/// filename when `path` is NULL).
|
||||
///
|
||||
/// Legacy manual-save ABI (frozen, keep exporting): since M13 D5 the
|
||||
/// write-through library is the primary persistence, and this entry is used
|
||||
/// only as the .ove export path — the app's 导出工程文件… always passes an
|
||||
/// explicit `path`, and `project_load_library` / `storage` cover the rest.
|
||||
/// The NULL branch (save to the recorded filename) survives for older
|
||||
/// callers. Behavior unchanged: on success the target filename is recorded
|
||||
/// and the modified flag cleared.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_project_save(
|
||||
self_: *mut OakEngineProject,
|
||||
@@ -564,7 +572,11 @@ pub unsafe extern "C" fn oakengine_project_save(
|
||||
})
|
||||
}
|
||||
|
||||
/// `oakengine_project_is_modified`.
|
||||
/// `oakengine_project_is_modified` — the legacy dirty flag.
|
||||
///
|
||||
/// Frozen ABI, kept for older callers and the undo/save machinery: the app
|
||||
/// (M13 D5) no longer reads it — write-through means a bound project is
|
||||
/// always persisted — so nothing on the UI side consults this anymore.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn oakengine_project_is_modified(self_: *const OakEngineProject) -> c_int {
|
||||
guard_int(|| unsafe {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 456 KiB After Width: | Height: | Size: 455 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 440 KiB After Width: | Height: | Size: 439 KiB |
@@ -46,8 +46,9 @@
|
||||
- 示波器:直方图/波形/矢量三个,数据来自已渲染帧(BT.709)。
|
||||
- 效果栈:选中 clip 的效果链枚举/插入/删除/排序/开关,全部
|
||||
undoable;检查器内嵌加效果菜单。
|
||||
- 工程:新建/打开/保存/另存(facade task,后台线程)、修改标记、
|
||||
导出对话框 + 导出任务(进度事件经 task subscribe 回调)。
|
||||
- 工程:新建/从库打开(写穿持久化,无保存按钮);导出工程文件…
|
||||
(ove/otio/fcpxml,扩展名分发)、导出对话框 + 导出任务(进度事件经
|
||||
task subscribe 回调)。
|
||||
- CLI / worker 两个前端同在 workspace(oak-cli、oak-worker)。
|
||||
- 测试:根 crate 60+ 测试全绿(含真渲染 e2e、示波器分析数学、
|
||||
偏好设置崩溃回归、i18n 未翻译检测)。
|
||||
|
||||
@@ -169,10 +169,21 @@ facade 的 undo 推送路径挂钩(`oakengine_undo_push` / `undo_group_end`
|
||||
| D2 | diff 写穿挂钩(facade undo 路径)+ 快照线程 + 退出 flush | 编辑工程 → kill -9 → 重开恢复到最后一条命令;撤销历史跨会话可用(集成测试) |
|
||||
| D3 | PG 后端(同 schema)+ 连接配置 | `OAK_TEST_PG_URL` 存在时 PG 测试全绿,否则自动 ignore |
|
||||
| D4 | 项目管理器窗口 + 导入导出 + 启动接线 | app 测试:建/删/复制/重命名/导入/导出;中英截图入 docs |
|
||||
| D5 | .ove 手动保存语义退役(菜单改导入/导出,脏标记改写入状态)+ 文档更新 | 全量测试绿 |
|
||||
| D5 | ✅ .ove 手动保存语义退役(菜单改导入/导出,脏标记改写入状态)+ 文档更新 | 全量测试绿 |
|
||||
|
||||
依赖:D1→D2→D3;D4 可在 D1 后并行;D5 最后。
|
||||
|
||||
> D5 落地记录(2026-08):手动保存残留清空。app 侧 trait
|
||||
> `AppEngine::project_modified`/`save_project` 删除(写穿后工程永远"已
|
||||
> 写入",无脏标记可读);菜单的 导出工程文件… 改走
|
||||
> `export_project_path`(扩展名分发 ove/otio/fcpxml,.ove 分支经冻结
|
||||
> ABI `oakengine_project_save` 写出,facade 注释标注 legacy/导出路径,
|
||||
> 行为不变);`RealEngine` 的 `modified` 字段及全部更新点移除,
|
||||
> `oakengine_project_is_modified` 不再被 app 侧调用(FFI 声明删除,
|
||||
> facade 保留)。状态栏写入状态(D4)为唯一落点。文档:本计划 §5 勾
|
||||
> 掉 D5、M12 现状盘点更新、project-storage 双语文档无手动保存残留
|
||||
> ("没有保存按钮"描述本就准确)。全量测试绿。
|
||||
|
||||
## 6. 风险与对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
|
||||
+11
-22
@@ -127,9 +127,9 @@ mod modal_ids {
|
||||
enum FileAction {
|
||||
ImportFootage,
|
||||
Open,
|
||||
/// Export the current project to a file (`.ove` / `.otio` / `.fcpxml`,
|
||||
/// dispatched by extension).
|
||||
SaveAs,
|
||||
/// Export the current project to a file (the 导出工程文件… action's
|
||||
/// target; `.ove` / `.otio` / `.fcpxml`, dispatched by extension).
|
||||
ExportProjectFile,
|
||||
/// Import a project file into the library (the manager's 导入).
|
||||
ImportProject,
|
||||
/// Export the selected library project (the manager's 导出; the row's
|
||||
@@ -525,7 +525,7 @@ impl<E: AppEngine> OakApp<E> {
|
||||
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),
|
||||
EXPORT_PROJECT => self.open_file_dialog(FileAction::SaveAs, cx),
|
||||
EXPORT_PROJECT => self.open_file_dialog(FileAction::ExportProjectFile, cx),
|
||||
CLOSE => self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.close_project(cx)),
|
||||
@@ -610,17 +610,6 @@ impl<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
.update(cx, |engine, cx| engine.save_project(path, cx));
|
||||
if let Err(err) = result {
|
||||
println!("[file] save failed: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建项目: 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.
|
||||
@@ -976,9 +965,9 @@ 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
|
||||
/// 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`].
|
||||
/// footage allows multiple files); the 导出工程文件… 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::ImportProject => {
|
||||
@@ -1002,7 +991,7 @@ impl<E: AppEngine> OakApp<E> {
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
FileAction::SaveAs => {
|
||||
FileAction::ExportProjectFile => {
|
||||
let current = self
|
||||
.engine
|
||||
.read(cx)
|
||||
@@ -1024,7 +1013,7 @@ impl<E: AppEngine> OakApp<E> {
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(Ok(Some(path))) = receiver.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.on_file_paths(FileAction::SaveAs, vec![path], cx);
|
||||
this.on_file_paths(FileAction::ExportProjectFile, vec![path], cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -1078,8 +1067,8 @@ impl<E: AppEngine> OakApp<E> {
|
||||
Some(path) => engine.open_project_path(path.clone(), cx),
|
||||
None => Ok(()),
|
||||
},
|
||||
FileAction::SaveAs => match paths.first() {
|
||||
Some(path) => engine.save_project(Some(path.clone()), cx),
|
||||
FileAction::ExportProjectFile => match paths.first() {
|
||||
Some(path) => engine.export_project_path(path.clone(), cx),
|
||||
None => Ok(()),
|
||||
},
|
||||
FileAction::ImportFootage => {
|
||||
|
||||
@@ -323,7 +323,6 @@ const EN: &[(&str, &str)] = &[
|
||||
("dialog.close", "Close"),
|
||||
("file.open.title", "Open Project"),
|
||||
("file.import_footage.title", "Import Footage"),
|
||||
("file.save_as.title", "Save Project As"),
|
||||
("preferences.title", "Preferences"),
|
||||
("preferences.backend", "Renderer backend"),
|
||||
("preferences.backend.placeholder", "Select a backend…"),
|
||||
@@ -499,7 +498,6 @@ const ZH: &[(&str, &str)] = &[
|
||||
("dialog.close", "关闭"),
|
||||
("file.open.title", "打开项目"),
|
||||
("file.import_footage.title", "导入素材"),
|
||||
("file.save_as.title", "项目另存为"),
|
||||
("preferences.title", "偏好设置"),
|
||||
("preferences.backend", "渲染后端"),
|
||||
("preferences.backend.placeholder", "选择一个后端…"),
|
||||
|
||||
+6
-7
@@ -298,9 +298,6 @@ pub trait AppEngine:
|
||||
/// Steps the undo stack forward one entry.
|
||||
fn redo(&mut self, cx: &mut Context<Self>);
|
||||
|
||||
/// Whether the project has unsaved changes.
|
||||
fn project_modified(&self) -> bool;
|
||||
|
||||
/// Starts a new blank project with a single default sequence.
|
||||
fn new_project(&mut self, cx: &mut Context<Self>);
|
||||
|
||||
@@ -309,10 +306,12 @@ pub trait AppEngine:
|
||||
/// interchange loader.
|
||||
fn open_project_path(&mut self, path: PathBuf, cx: &mut Context<Self>) -> Result<(), String>;
|
||||
|
||||
/// Saves the project to `path` (or its own filename when `None`). The
|
||||
/// format is dispatched by extension like [`open_project_path`]
|
||||
/// (AppEngine::open_project_path).
|
||||
fn save_project(&mut self, path: Option<PathBuf>, cx: &mut Context<Self>)
|
||||
/// Exports the current project to the file `path` (the 导出工程文件…
|
||||
/// action's target). The format is dispatched by extension like
|
||||
/// [`open_project_path`] (AppEngine::open_project_path). Exporting is a
|
||||
/// pure file write — the write-through library already persists every
|
||||
/// edit, so there is no "save" anymore.
|
||||
fn export_project_path(&mut self, path: PathBuf, cx: &mut Context<Self>)
|
||||
-> Result<(), String>;
|
||||
|
||||
/// Closes the current project, leaving the app with no sequence.
|
||||
|
||||
+4
-4
@@ -358,11 +358,11 @@ unsafe extern "C" {
|
||||
err: *mut c_char,
|
||||
err_size: c_int,
|
||||
) -> c_int;
|
||||
/// `oakengine_project_save` — save to `path` (or the recorded
|
||||
/// filename when NULL).
|
||||
/// `oakengine_project_save` — write the project to `path` (or the
|
||||
/// recorded filename when NULL). Legacy manual-save ABI, now used only
|
||||
/// as the .ove export path (导出工程文件…); the write-through library is
|
||||
/// the primary persistence.
|
||||
pub fn oakengine_project_save(self_: *mut OakEngineProject, path: *const c_char) -> c_int;
|
||||
/// `oakengine_project_is_modified`.
|
||||
pub fn oakengine_project_is_modified(self_: *const OakEngineProject) -> c_int;
|
||||
/// `oakengine_project_name` (buf/size).
|
||||
pub fn oakengine_project_name(
|
||||
self_: *const OakEngineProject,
|
||||
|
||||
+2
-10
@@ -1302,10 +1302,6 @@ impl AppEngine for MockEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn project_modified(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn new_project(&mut self, cx: &mut Context<Self>) {
|
||||
println!("[mock engine] new project: demo data stays (mock mode)");
|
||||
cx.notify();
|
||||
@@ -1322,12 +1318,8 @@ impl AppEngine for MockEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_project(
|
||||
&mut self,
|
||||
_path: Option<PathBuf>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
println!("[mock engine] save: no persistence in mock mode");
|
||||
fn export_project_path(&mut self, _path: PathBuf, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
println!("[mock engine] export: no persistence in mock mode");
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+20
-38
@@ -580,8 +580,6 @@ pub struct RealEngine {
|
||||
/// the selection changes or the project is dropped — the renderer binds
|
||||
/// the footage node, so a new selection must bind the new node.
|
||||
source_renderer: Mutex<RendererSlot>,
|
||||
/// Whether the project has unsaved changes (mirrors the facade flag).
|
||||
modified: bool,
|
||||
}
|
||||
|
||||
impl RealEngine {
|
||||
@@ -656,7 +654,6 @@ impl RealEngine {
|
||||
cpu_frame_cache: Mutex::new(HashMap::new()),
|
||||
renderer: Mutex::new(RendererSlot::Untried),
|
||||
source_renderer: Mutex::new(RendererSlot::Untried),
|
||||
modified: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,7 +984,6 @@ impl RealEngine {
|
||||
},
|
||||
path,
|
||||
};
|
||||
self.modified = unsafe { oakengine_project_is_modified(project) != 0 };
|
||||
|
||||
// The sequence: the project's first, or a blank default.
|
||||
let count = unsafe { oakengine_project_sequence_count(project) };
|
||||
@@ -1019,7 +1015,6 @@ impl RealEngine {
|
||||
self.tracks.clear();
|
||||
|
||||
self.sequence_info = None;
|
||||
self.modified = false;
|
||||
self.project_info = Project {
|
||||
name: UNTITLED.into(),
|
||||
path: PathBuf::new(),
|
||||
@@ -1398,7 +1393,6 @@ impl RealEngine {
|
||||
self.rebuild_timeline();
|
||||
// The sequence content changed: cached rendered frames are stale.
|
||||
self.cpu_frame_cache.lock().unwrap().clear();
|
||||
self.modified = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -2154,7 +2148,6 @@ impl AppEngine for RealEngine {
|
||||
self.refresh_sequence_info();
|
||||
self.rebuild_timeline();
|
||||
self.cpu_frame_cache.lock().unwrap().clear();
|
||||
self.modified = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -2167,15 +2160,10 @@ impl AppEngine for RealEngine {
|
||||
self.refresh_sequence_info();
|
||||
self.rebuild_timeline();
|
||||
self.cpu_frame_cache.lock().unwrap().clear();
|
||||
self.modified = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn project_modified(&self) -> bool {
|
||||
self.modified
|
||||
}
|
||||
|
||||
fn new_project(&mut self, cx: &mut Context<Self>) {
|
||||
let project = unsafe { oakengine_project_create() };
|
||||
if project.is_null() {
|
||||
@@ -2201,28 +2189,22 @@ impl AppEngine for RealEngine {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_project(
|
||||
fn export_project_path(
|
||||
&mut self,
|
||||
path: Option<PathBuf>,
|
||||
path: PathBuf,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
if self.project_ptr().is_none() {
|
||||
return Err("no project open".into());
|
||||
}
|
||||
let ext = path
|
||||
.as_ref()
|
||||
.and_then(|p| p.extension())
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase());
|
||||
let result = match ext.as_deref() {
|
||||
Some("otio") | Some("fcpxml") => self.save_interchange(&path.unwrap(), cx),
|
||||
_ => self.save_ove(path.as_deref(), cx),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.modified = false;
|
||||
cx.notify();
|
||||
match ext.as_deref() {
|
||||
Some("otio") | Some("fcpxml") => self.export_interchange(&path, cx),
|
||||
_ => self.export_ove(&path, cx),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn close_project(&mut self, cx: &mut Context<Self>) {
|
||||
@@ -2586,22 +2568,22 @@ impl RealEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves to the project's own filename (or `path`) through the OVE
|
||||
/// serializer.
|
||||
fn save_ove(&mut self, path: Option<&Path>, _cx: &mut Context<Self>) -> Result<(), String> {
|
||||
/// Exports the project to `path` through the OVE serializer (the .ove
|
||||
/// branch of 导出工程文件…; the write-through library stays the primary
|
||||
/// persistence, this only writes a file).
|
||||
fn export_ove(&mut self, path: &Path, _cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let Some(project) = self.project_ptr() else {
|
||||
return Err("no project open".into());
|
||||
};
|
||||
let cpath = path.and_then(cstr_path);
|
||||
let ptr = cpath
|
||||
.as_ref()
|
||||
.map(|c| c.as_ptr())
|
||||
.unwrap_or(std::ptr::null());
|
||||
let rc = unsafe { oakengine_project_save(project, ptr) };
|
||||
let Some(cpath) = cstr_path(path) else {
|
||||
return Err("invalid project path".into());
|
||||
};
|
||||
let rc = unsafe { oakengine_project_save(project, cpath.as_ptr()) };
|
||||
if rc != 0 {
|
||||
return Err(format!("failed to save the project (error {rc})"));
|
||||
return Err(format!("failed to export the project (error {rc})"));
|
||||
}
|
||||
// The facade recorded the target filename; refresh the display name.
|
||||
// The facade records the target filename (legacy save side effect);
|
||||
// refresh the display name to match.
|
||||
let name = read_string(|buf, size| unsafe { oakengine_project_name(project, buf, size) });
|
||||
if !name.is_empty() {
|
||||
self.project_info.name = name;
|
||||
@@ -2614,9 +2596,9 @@ impl RealEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves as `.otio` / `.fcpxml` through the oaktask save task (the
|
||||
/// Exports as `.otio` / `.fcpxml` through the oaktask save task (the
|
||||
/// facade derives the output filename from the project's own filename).
|
||||
fn save_interchange(&mut self, path: &PathBuf, _cx: &mut Context<Self>) -> Result<(), String> {
|
||||
fn export_interchange(&mut self, path: &PathBuf, _cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let Some(project) = self.project_ptr() else {
|
||||
return Err("no project open".into());
|
||||
};
|
||||
@@ -2635,7 +2617,7 @@ impl RealEngine {
|
||||
let error = Self::task_error(task);
|
||||
unsafe { oakengine_task_free(task) };
|
||||
if rc == 0 {
|
||||
return Err(format!("failed to save \"{}\": {error}", path.display()));
|
||||
return Err(format!("failed to export \"{}\": {error}", path.display()));
|
||||
}
|
||||
self.project_info.path = path.clone();
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user