diff --git a/crates/oakengine/src/node.rs b/crates/oakengine/src/node.rs index f2dcf07ab..64b83be30 100644 --- a/crates/oakengine/src/node.rs +++ b/crates/oakengine/src/node.rs @@ -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 { diff --git a/docs/screenshot-manager-en.png b/docs/screenshot-manager-en.png index 5d0dee27a..780c6835c 100644 Binary files a/docs/screenshot-manager-en.png and b/docs/screenshot-manager-en.png differ diff --git a/docs/screenshot-manager.png b/docs/screenshot-manager.png index 846379da7..ac732c86b 100644 Binary files a/docs/screenshot-manager.png and b/docs/screenshot-manager.png differ diff --git a/docs/zh/plans/riir/M12-app.md b/docs/zh/plans/riir/M12-app.md index b0bc292be..b4fb50735 100644 --- a/docs/zh/plans/riir/M12-app.md +++ b/docs/zh/plans/riir/M12-app.md @@ -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 未翻译检测)。 diff --git a/docs/zh/plans/riir/M13-storage-live.md b/docs/zh/plans/riir/M13-storage-live.md index 5dfbb9471..631261863 100644 --- a/docs/zh/plans/riir/M13-storage-live.md +++ b/docs/zh/plans/riir/M13-storage-live.md @@ -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. 风险与对策 | 风险 | 对策 | diff --git a/src/app.rs b/src/app.rs index 8f1800dbe..13e14c09d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 OakApp { 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 OakApp { } } - /// 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, cx: &mut Context) { - 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 OakApp { /// 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) { match action { FileAction::Open | FileAction::ImportFootage | FileAction::ImportProject => { @@ -1002,7 +991,7 @@ impl OakApp { }) .detach(); } - FileAction::SaveAs => { + FileAction::ExportProjectFile => { let current = self .engine .read(cx) @@ -1024,7 +1013,7 @@ impl OakApp { 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 OakApp { 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 => { diff --git a/src/i18n.rs b/src/i18n.rs index 15be26e41..46762142d 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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", "选择一个后端…"), diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index ebb2ceb36..3d274f2c3 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -298,9 +298,6 @@ pub trait AppEngine: /// Steps the undo stack forward one entry. fn redo(&mut self, cx: &mut Context); - /// 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); @@ -309,10 +306,12 @@ pub trait AppEngine: /// interchange loader. fn open_project_path(&mut self, path: PathBuf, cx: &mut Context) -> 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, cx: &mut Context) + /// 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) -> Result<(), String>; /// Closes the current project, leaving the app with no sequence. diff --git a/src/oakui/ffi.rs b/src/oakui/ffi.rs index 0f73d33c4..02e450c0f 100644 --- a/src/oakui/ffi.rs +++ b/src/oakui/ffi.rs @@ -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, diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index 9cd165a57..c7e5bf456 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -1302,10 +1302,6 @@ impl AppEngine for MockEngine { cx.notify(); } - fn project_modified(&self) -> bool { - false - } - fn new_project(&mut self, cx: &mut Context) { 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, - cx: &mut Context, - ) -> Result<(), String> { - println!("[mock engine] save: no persistence in mock mode"); + fn export_project_path(&mut self, _path: PathBuf, cx: &mut Context) -> Result<(), String> { + println!("[mock engine] export: no persistence in mock mode"); cx.notify(); Ok(()) } diff --git a/src/oakui/real.rs b/src/oakui/real.rs index ebdf0ed7d..ad87ab62a 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -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, - /// 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) { 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, + path: PathBuf, cx: &mut Context, ) -> 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) { @@ -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) -> 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) -> 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) -> Result<(), String> { + fn export_interchange(&mut self, path: &PathBuf, _cx: &mut Context) -> 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(())