#620: Project Notes panel, saved in the project

Problem: a project had nowhere to keep free text (to-dos, instructions
for whoever opens it next, frame numbers).

Fix: plain-text `Project::notes` (schema v15, no-op migration so older
builds refuse instead of dropping it), `project.notes` /
`project.setNotes {text, merge?}` engine commands (undoable, capped at
1 MiB; calls with one `merge` key share an undo step), and a
Window > Project Notes panel whose editor (`notes.text`) commits every
change, one typing session per undo step. Translations for es, ja,
zh-cn (and the panel title for pt-br, uk).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-10-10 21:09:13 +02:00
1 parent 3ad5a59321
commit 110c1783c3
14 files changed
+197 -5

No files matched your search

+44
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,
},
]
}
+37
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, "");
}
+9 -2
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]
+5
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(),
}
}
+4 -1
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 {
+4
View File
@@ -3512,3 +3512,7 @@
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.
Can't render this file because it contains an unexpected character in line 3 and column 95.
+4
View File
@@ -3505,3 +3505,7 @@
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. このプロジェクトのメモ: やること、指示、フレーム番号など… プロジェクトと一緒に保存されます。
Can't render this file because it contains an unexpected character in line 11 and column 59.
+1
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
1 # Brazilian Portuguese (pt-br) catalog. Same format as `es.tsv`: context <TAB> source <TAB> translation.
355
356
357
358
+1
View File
@@ -355,3 +355,4 @@
Zoom In Збільшити
Zoom Out Зменшити
Zoom to Sequence Умістити секвенцію
Project Notes Нотатки проєкту
1 # Ukrainian (uk) catalog. Columns separated by TAB: context <TAB> source <TAB> translation
355
356
357
358
+4
View File
@@ -3494,3 +3494,7 @@
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. 此项目的备注:待办事项、说明、帧号等… 随项目一起保存。
Can't render this file because it contains an unexpected character in line 3 and column 95.
+2
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()))),
}
}
+53
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);
}
+27 -1
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();
+2 -1
View File
@@ -729,7 +729,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