Merge pull request #750 from vangel76/fix-620-project-notes

#620: Project Notes panel, saved in the project
This commit is contained in:
Brandon Thomas 2026-10-10 15:53:02 -04:00 committed by GitHub
commit 9fb10ded25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 197 additions and 5 deletions

View File

@ -4,6 +4,7 @@
//! |---|---|
//! | `events.list` / `events.clear` | the Events panel: warnings and errors of commands, background jobs and the auto-save worker ([`EventLog`]) |
//! | `metadata.get` / `metadata.set` | the Metadata panel: an item's clip and file properties; editable log fields (undoable) |
//! | `project.notes` / `project.setNotes` | the Project Notes panel: free plain text saved in the project (undoable) |
//!
//! The Progress panel uses `jobs.list` / `jobs.cancel`; the Timecode panel and the Reference
//! Monitor are frontend state only.
@ -339,6 +340,38 @@ fn metadata_set(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"item": id.0, "name": it.name, "label": it.label.name(), "metadata": it.metadata, "changed": !unchanged}))
}
// ------------------------------------------------------------------------------------ project notes
/// Longest project notes accepted (bytes of UTF-8), so a script or a pasted file cannot grow the
/// project (and every undo step) without bound.
pub const MAX_NOTES_BYTES: usize = 1 << 20;
/// `project.notes`: the project notes.
fn notes_get(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"text": s.project.notes}))
}
/// `project.setNotes {text, merge?}`: replace the project notes as one undo step. Calls with the
/// same `merge` key and no other edit in between share that step (typing in the panel).
fn notes_set(s: &mut Session, p: &Value) -> Result<Value> {
let text = p.get("text").and_then(Value::as_str).ok_or_else(|| bad("project.setNotes", "need `text` (a string)"))?;
if text.len() > MAX_NOTES_BYTES {
return Err(bad("project.setNotes", format!("the notes are too long ({} bytes; at most {MAX_NOTES_BYTES})", text.len())));
}
let changed = s.project.notes != text;
if changed {
let body = |pr: &mut Project, _: &mut crate::EditorState| {
pr.notes = text.to_string();
Ok(())
};
match str_p(p, "merge") {
Some(key) => s.edit_merged("Edit Project Notes", &format!("project.notes:{key}"), body)?,
None => s.edit("Edit Project Notes", body)?,
}
}
Ok(json!({"text": s.project.notes, "changed": changed}))
}
// ------------------------------------------------------------------------------------ registry
pub(crate) fn commands() -> Vec<CommandSpec> {
@ -370,5 +403,16 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
run: metadata_set,
journal: true,
},
q("project.notes", "Get Project Notes", "{}", notes_get),
CommandSpec {
id: "project.setNotes",
label: "Edit Project Notes",
menu: &[],
shortcut: None,
params: r#"{"text":str,"merge":str?}"#,
enabled: always,
run: notes_set,
journal: true,
},
]
}

View File

@ -156,3 +156,40 @@ fn events_report_background_jobs() {
s.poll_persistence();
assert_eq!(s.log.entries.len(), 6);
}
/// #620: project notes are saved in the project file; typing (one `merge` key) is one undo step.
#[test]
fn project_notes_are_saved_and_typing_is_one_undo_step() {
let mut s = demo();
assert_eq!(s.execute("project.notes", json!({})).unwrap()["text"], "");
let undo0 = s.history.undo.len();
for text in ["T", "To", "To do: fix the logo at 00:01:02:03"] {
s.execute("project.setNotes", json!({"text": text, "merge": "1"})).unwrap();
}
assert_eq!(s.history.undo.len(), undo0 + 1, "one typing session, one undo step");
assert_eq!(s.history.undo.last().unwrap().0, "Edit Project Notes");
// the same text again changes nothing and adds no step
let r = s.execute("project.setNotes", json!({"text": "To do: fix the logo at 00:01:02:03"})).unwrap();
assert_eq!(r["changed"], json!(false));
assert_eq!(s.history.undo.len(), undo0 + 1);
let loaded = filmcraft_format::decode(&filmcraft_format::encode(&s.project, false)).unwrap();
assert_eq!(loaded.project.notes, "To do: fix the logo at 00:01:02:03");
// an older file without notes still loads, with none
let mut doc: Value = serde_json::from_slice(&filmcraft_format::encode(&filmcraft_project::Project::new("old"), false)).unwrap();
doc["schema_version"] = json!(14);
assert_eq!(filmcraft_format::decode(&serde_json::to_vec(&doc).unwrap()).unwrap().project.notes, "");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.notes, "");
}
#[test]
fn project_notes_refuse_bad_input() {
let mut s = demo();
assert!(s.execute("project.setNotes", json!({})).is_err());
assert!(s.execute("project.setNotes", json!({"text": 5})).is_err());
let huge = "x".repeat(crate::panels::MAX_NOTES_BYTES + 1);
assert!(s.execute("project.setNotes", json!({"text": huge})).is_err());
assert_eq!(s.project.notes, "");
}

View File

@ -37,6 +37,8 @@
//! `audio` objects read as stream zero; older builds must refuse new multi-stream projects.
//! - **v14** (M7.9): narrations (`Project::narrations`, Text to Speech). No-op step; older builds
//! would drop them when saving.
//! - **v15**: project notes (`Project::notes`, Window ▸ Project Notes). No-op step; older builds
//! would drop them when saving.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
@ -58,7 +60,7 @@ pub type Migration = fn(Value) -> Result<Value, String>;
/// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never
/// edit a shipped one.
pub const MIGRATIONS: &[Migration] =
&[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13, v13_to_v14];
&[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13, v13_to_v14, v14_to_v15];
/// The schema version this build writes (and the newest it reads).
pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1;
@ -354,13 +356,18 @@ fn v13_to_v14(doc: Value) -> Result<Value, String> {
Ok(doc)
}
/// v14 → v15: project notes. Existing data needs no change.
fn v14_to_v15(doc: Value) -> Result<Value, String> {
Ok(doc)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_version_matches_table() {
assert_eq!(SCHEMA_VERSION, 14);
assert_eq!(SCHEMA_VERSION, 15);
}
#[test]

View File

@ -1253,6 +1253,10 @@ pub struct Project {
/// made from, keyed by that item. Schema v14.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub narrations: BTreeMap<ItemId, Narration>,
/// Project notes (Window ▸ Project Notes): free plain text kept with the project, for to-dos,
/// instructions for whoever opens it next, frame numbers and the like. Schema v15.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub notes: String,
}
/// How a sequence is shown in the Timeline panel: its zoom, scroll position and track heights.
@ -1348,6 +1352,7 @@ impl Project {
search_bins: Vec::new(),
source_graphics: BTreeMap::new(),
narrations: BTreeMap::new(),
notes: String::new(),
}
}

View File

@ -37,10 +37,11 @@ pub enum PanelKind {
Progress,
ReferenceMonitor,
Timecode,
ProjectNotes,
}
impl PanelKind {
pub const ALL: [PanelKind; 27] = [
pub const ALL: [PanelKind; 28] = [
PanelKind::Project,
PanelKind::MediaBrowser,
PanelKind::Libraries,
@ -68,6 +69,7 @@ impl PanelKind {
PanelKind::Progress,
PanelKind::ReferenceMonitor,
PanelKind::Timecode,
PanelKind::ProjectNotes,
];
pub fn title(self) -> &'static str {
match self {
@ -98,6 +100,7 @@ impl PanelKind {
PanelKind::Progress => "Progress",
PanelKind::ReferenceMonitor => "Reference Monitor",
PanelKind::Timecode => "Timecode",
PanelKind::ProjectNotes => "Project Notes",
}
}
pub fn id(self) -> String {

View File

@ -3525,4 +3525,8 @@
Composite on the GPU when available Componer en la GPU cuando esté disponible
Text to Speech Texto a voz
Edit Narration Editar narración
Project Notes Notas del proyecto
Get Project Notes Obtener notas del proyecto
Edit Project Notes Editar notas del proyecto
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas de este proyecto: tareas pendientes, instrucciones, números de fotograma… Se guardan con el proyecto.
Use Effect Shutter Angle Usar el ángulo de obturación del efecto

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -3513,4 +3513,8 @@
Edit Narration ナレーションを編集
Clear Transition トランジションを消去
Set Transition Duration トランジションのデュレーションを設定
Project Notes プロジェクトメモ
Get Project Notes プロジェクトメモを取得
Edit Project Notes プロジェクトメモを編集
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. このプロジェクトのメモ: やること、指示、フレーム番号など… プロジェクトと一緒に保存されます。
GPU acceleration is unavailable; preview renders on the CPU GPU アクセラレーションを利用できません。プレビューは CPU でレンダリングされます

Can't render this file because it contains an unexpected character in line 11 and column 59.

View File

@ -355,3 +355,4 @@
# System appearance mode menu command.
Next Appearance Mode Próximo modo de aparência
Project Notes Notas do projeto

Can't render this file because it has a wrong number of fields in line 8.

View File

@ -355,3 +355,4 @@
Zoom In Збільшити
Zoom Out Зменшити
Zoom to Sequence Умістити секвенцію
Project Notes Нотатки проєкту

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -3502,6 +3502,10 @@
Edit Narration 编辑旁白
Clear Transition 清除过渡
Set Transition Duration 设置过渡持续时间
Project Notes 项目备注
Get Project Notes 获取项目备注
Edit Project Notes 编辑项目备注
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. 此项目的备注:待办事项、说明、帧号等… 随项目一起保存。
GPU acceleration is unavailable; preview renders on the CPU GPU 加速不可用;预览在 CPU 上渲染
Caption Tracks 字幕轨道
Delete Caption Track 删除字幕轨道

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -29,6 +29,7 @@ pub mod mixer;
pub mod monitor;
pub mod monitor_view;
pub mod multicam;
pub mod notes;
pub mod panel_state;
pub mod presets;
pub mod project;
@ -101,6 +102,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect)
PanelKind::Text => text::show(app, ui, rect),
PanelKind::EssentialSound => essential_sound::show(app, ui, rect),
PanelKind::TextToSpeech => tts::show(app, ui, rect),
PanelKind::ProjectNotes => notes::show(app, ui, rect),
other => crate::dock::placeholder(ui, rect, &app.tokens, &tlf!("{panel} — coming in a later milestone", panel = crate::i18n::t(other.title()))),
}
}

View File

@ -0,0 +1,53 @@
//! Project Notes panel (#620): free plain text kept in the project file, for to-dos,
//! instructions for whoever opens the project next, frame numbers and the like. Every change is
//! a `project.setNotes`; one typing session (from focusing the editor until it loses focus) is
//! one undo step.
//!
//! Automation ids: `notes.text` (the editor; its label is the start of the notes). Agents read
//! and write the notes with `project.notes` / `project.setNotes`.
use egui::{Rect, vec2};
use serde_json::json;
use crate::FilmcraftApp;
/// Longest automation label (characters): the full notes are `project.notes`.
const LABEL_CHARS: usize = 200;
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 6.0))));
let ui = &mut child;
let mut text = app.session.project.notes.clone();
let height = ui.available_height();
let r = egui::ScrollArea::vertical()
.id_salt("project-notes-scroll")
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut text)
.id_salt("project-notes")
.hint_text(tl!("Notes for this project: to-dos, instructions, frame numbers… Saved with the project."))
.text_color(t.text)
.desired_width(f32::INFINITY)
.min_size(vec2(0.0, height)),
)
})
.inner;
// each focus of the editor starts a new undo step; the edits while it keeps focus join it
let typing = egui::Id::new("project-notes-typing");
if r.gained_focus() {
ui.data_mut(|d| {
let n = d.get_temp::<u64>(typing).unwrap_or(0).wrapping_add(1);
d.insert_temp(typing, n);
});
}
if r.changed() {
let key = ui.data(|d| d.get_temp::<u64>(typing)).unwrap_or(0);
if let Err(e) = app.session.execute("project.setNotes", json!({"text": text, "merge": key.to_string()})) {
app.ui.status = e.to_string();
}
}
let label: String = app.session.project.notes.chars().take(LABEL_CHARS).collect();
app.auto.add("notes.text", r.rect, &label);
}

View File

@ -158,7 +158,7 @@ fn every_panel_opens_from_the_window_menu() {
let menu = d.ok("ui.menu.list", json!({}));
let window: Vec<String> =
menu.as_array().unwrap().iter().filter(|m| m["path"] == json!(["Window"])).filter_map(|m| m["label"].as_str().map(str::to_string)).collect();
for p in ["Events", "Lumetri Scopes", "Metadata", "Progress", "Reference Monitor", "Timecode"] {
for p in ["Events", "Lumetri Scopes", "Metadata", "Progress", "Project Notes", "Reference Monitor", "Timecode"] {
assert!(window.iter().any(|w| w == p), "{p} not in Window: {window:?}");
}
for (panel, key) in [
@ -168,6 +168,7 @@ fn every_panel_opens_from_the_window_menu() {
("Events", "events.clearAll"),
("Progress", "progress.showFinished"),
("ReferenceMonitor", "reference.gang"),
("ProjectNotes", "notes.text"),
] {
d.ok("ui.menu.invoke", json!({"id": format!("window.panel.{panel}")}));
d.frames(3);
@ -267,6 +268,31 @@ fn metadata_panel_edits_a_field_with_undo() {
assert_eq!(d.label("metadata.field.TapeName"), "A001");
}
/// #620: notes typed into Project Notes land in the project; one typing session is one undo step.
#[test]
fn project_notes_panel_types_into_the_project_with_undo() {
let mut d = Driver::demo();
d.ok("ui.menu.invoke", json!({"id": "window.panel.ProjectNotes"}));
d.frames(3);
let undo0 = d.app().session.history.undo.len();
d.click("notes.text");
d.ok("ui.type", json!({"text": "Fix logo"}));
d.frames(2);
d.ok("ui.key", json!({"key": "Enter"}));
d.ok("ui.type", json!({"text": "at 1:02"}));
d.frames(3);
assert_eq!(d.app().session.project.notes, "Fix logo\nat 1:02");
assert_eq!(d.app().session.history.undo.len(), undo0 + 1);
assert_eq!(d.label("notes.text"), "Fix logo\nat 1:02");
d.exec("edit.undo", json!({}));
d.frames(2);
assert_eq!(d.app().session.project.notes, "");
// an agent writes them, the panel shows them
d.exec("project.setNotes", json!({"text": "From the script"}));
d.frames(2);
assert_eq!(d.label("notes.text"), "From the script");
}
#[test]
fn timecode_panel_rows_and_modes() {
let mut d = Driver::demo();

View File

@ -731,7 +731,8 @@ All of these dispatch the same command ids.
- **Panels with engine data** (M8.9 / M12.6): Lumetri Scopes (`scopes.read` returns the same
scopes as numbers), Metadata (`metadata.get` / `metadata.set`, one undo step per edit, stored in
`ProjectItem::metadata`), Events (`events.list` / `events.clear`), Progress (`jobs.list` /
`jobs.cancel`). Timecode and Reference Monitor are frontend-only views. Their settings are
`jobs.cancel`), Project Notes (`project.notes` / `project.setNotes`, plain text in
`Project::notes`, schema v15; one typing session is one undo step). Timecode and Reference Monitor are frontend-only views. Their settings are
`UiState::panels` (`ui.set {"panels": {...}}`).
- **Project panel and Media Browser** (M12.7). View settings (List / Icon / Freeform, thumbnail
and font size, Preview Area, Hover Scrub), the List view's columns, widths and sort, and ten view