mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 11:17:01 -04:00
#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:
1 parent
3ad5a59321
commit
110c1783c3
14 files changed
+197
-5
No files matched your search
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -355,3 +355,4 @@
|
||||
|
||||
# System appearance mode menu command.
|
||||
Next Appearance Mode Próximo modo de aparência
|
||||
Project Notes Notas do projeto
|
||||
|
@@ -355,3 +355,4 @@
|
||||
Zoom In Збільшити
|
||||
Zoom Out Зменшити
|
||||
Zoom to Sequence Умістити секвенцію
|
||||
Project Notes Нотатки проєкту
|
||||
|
@@ -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.
|
@@ -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()))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user