Browse / github.com/storytold/filmcraft / patch
M11: add Replace Footage to Project media menus
M11: add Replace Footage to Project media menus FilmCraft has a Replace Footage engine command but no interface for editors to use it. This adds Clip > Replace Footage... for one selected file-backed Project item and the same action in its context menu. The picker accepts video, audio and still images; cancellation leaves the project unchanged. Replacing footage with a different frame size now keeps a default, static Motion anchor at the new source center. The old numeric anchor previously shifted smaller replacements toward the upper left. The correction applies across all sequences and subclip chains. Position, Scale, Rotation, custo
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- cshapiro (imported from pull request #3401, open)
- Base
10d626ccc4888e0f020f9ca314c2bc9148ded4f4- Size
- +595 −22 in 21 files
- Review
- pending
- Shared
- 2026-10-28
- Id
6d5a2d3071df6b8c8219cd54
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/proxies.rs crates/engine/src/relink.rs crates/engine/src/relink_tests.rs crates/ui-egui/README.md crates/ui-egui/src/hooks.rs crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/id.tsv crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/ko.tsv crates/ui-egui/src/i18n/pt-br.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/i18n/zh-cn.tsv crates/ui-egui/src/panels/media_dialogs.rs crates/ui-egui/src/panels/project_views.rs crates/ui-egui/tests/it/media_ui.rs docs/project-files.md
@@ -1,5 +1,7 @@ # FilmCraft Roadmap +- M11: Replace Footage is available from Clip and the Project-item context menu for one file-backed item. The replacement preserves timeline edits, detaches the old proxy and supports undo/redo; no parity percentage is changed by this UI wiring.+ - UI.6: Compact Source and Program monitors separate timecodes from view settings and retain Source drag choices. Transport controls use the shared toolbar sizing helper, with overflow actions in an operable More menu and a scrolling fallback at tiny widths. - UI.3: Panel tabs, edge splits, tab reordering and floating groups use the shared docking model and drop resolver. Floating geometry is saved with named workspaces while legacy layout JSON remains readable. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tools and dock tabs expose actual widget focus, paint focus outlines, and support keyboard traversal/activation. Control input reaches focused widgets in background windows; optional input isolation supports unattended validation. Existing workspace trees and saved layout formats are retained. @@ -84,6 +84,8 @@ pub struct PendingJob { pub on_done: OnDone, /// (item, output path) for each finished output. pub outputs: Arc<Mutex<Vec<(ItemId, String)>>>,+ /// Source identity when work began; stale outputs must not attach to replacement footage.+ pub sources: Vec<(ItemId, MediaRef, Option<filmcraft_project::MediaIdentity>)>, } /// Transcode `range` (whole media when None) of one media clip to `out` with `preset`, blocking.@@ -259,6 +261,7 @@ fn items_param(s: &Session, p: &Value) -> Vec<ItemId> { /// Start a background transcode job over `work` (item, output path, preset). Returns the job id. fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, ReservedOutput, &'static Preset)>, on_done: OnDone, wait: bool) -> Result<u64> { let mut tasks = Vec::new();+ let mut sources = Vec::new(); let mut total = 0; for (item, output, pr) in work { let (clip, _, name) = media_path(&s.project, item).ok_or_else(|| bad("media.createProxies", format!("item {} is not file media", item.0)))?;@@ -267,6 +270,7 @@ fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, ReservedOutput, return Err(EngineError::Other(format!("{name} is offline; link it first"))); } total += frame_count(&clip, None);+ sources.push((item, clip.media.clone(), clip.identity)); tasks.push((item, clip, name, src, output, pr)); } let id = s.jobs.iter().map(|j| j.id).max().unwrap_or(0) + 1;@@ -316,7 +320,7 @@ fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, ReservedOutput, }; let run = crate::export_tools::guard_job(job.progress.clone(), job.result.clone(), run); s.jobs.push(job);- s.media_jobs.push(PendingJob { job: id, on_done, outputs });+ s.media_jobs.push(PendingJob { job: id, on_done, outputs, sources }); if wait || cfg!(target_arch = "wasm32") { run(); poll(s);@@ -338,7 +342,18 @@ pub fn poll(s: &mut Session) { .collect(); for i in finished.into_iter().rev() { let pj = s.media_jobs.remove(i);- let outs = std::mem::take(&mut *pj.outputs.lock().unwrap_or_else(|e| e.into_inner()));+ let mut outs = std::mem::take(&mut *pj.outputs.lock().unwrap_or_else(|e| e.into_inner()));+ if pj.on_done != OnDone::Nothing {+ outs.retain(|(item, path)| {+ let current = s.project.item(*item).and_then(|i| i.as_media());+ let keep =+ pj.sources.iter().any(|(id, source, identity)| *id == *item && current.is_some_and(|m| m.media == *source && m.identity == *identity));+ if !keep {+ discard_unused_output(s, path);+ }+ keep+ });+ } if outs.is_empty() { continue; }@@ -353,6 +368,35 @@ pub fn poll(s: &mut Session) { } } +/// Delete an obsolete job-owned output only when current and undoable states do not use it.+fn discard_unused_output(s: &mut Session, path: &str) {+ let canonical = std::fs::canonicalize(path).ok();+ let mut seen = std::collections::HashSet::new();+ let mut references = |media: &MediaRef| {+ let MediaRef::File { path: used } = media else { return false };+ if used == path {+ return true;+ }+ if !seen.insert(used.clone()) {+ return false;+ }+ let followed = s.media.follow(used).ok();+ followed.as_deref() == Some(path)+ || canonical.as_ref().is_some_and(|output| followed.and_then(|p| std::fs::canonicalize(p).ok()).as_ref() == Some(output))+ };+ let mut projects = std::iter::once(s.project.as_ref()).chain(s.history.undo.iter().chain(&s.history.redo).map(|(_, p)| p.as_ref()));+ if projects.any(|project| {+ project.items.values().any(|item| item.as_media().is_some_and(|m| references(&m.media) || m.proxy.as_ref().is_some_and(&mut references)))+ }) {+ return;+ }+ if let Err(error) = std::fs::remove_file(path)+ && error.kind() != std::io::ErrorKind::NotFound+ {+ s.error_toast("job", format!("could not remove obsolete job output {path}: {error}"));+ }+}+ /// Check a proxy file against its clip: duration within one frame, same frame rate. fn check_proxy(s: &Session, item: ItemId, path: &str) -> Result<()> { let (clip, _, name) = media_path(&s.project, item).ok_or_else(|| bad("media.attachProxies", format!("item {} is not file media", item.0)))?;@@ -321,7 +321,7 @@ struct Plan { } /// Apply planned relinks as one undo step.-fn apply(s: &mut Session, plans: Vec<Plan>, align_timecode: bool, label: &str) -> Result<Vec<Value>> {+fn apply(s: &mut Session, plans: Vec<Plan>, align_timecode: bool, label: &str, replace: bool) -> Result<Vec<Value>> { let rows: Vec<Value> = plans.iter().map(|p| json!({"item": p.item.0, "path": p.cand.path})).collect(); let sources: Vec<(ItemId, String, filmcraft_media::SharedSource)> = plans.iter().filter_map(|p| p.cand.source.clone().map(|src| (p.item, p.cand.path.clone(), src))).collect();@@ -330,9 +330,15 @@ fn apply(s: &mut Session, plans: Vec<Plan>, align_timecode: bool, label: &str) - let Some(m) = proj.item_mut(p.item).and_then(|i| i.as_media_mut()) else { continue }; let old_tc = m.info.start_timecode; let rate = m.info.frame_rate();+ let old_size = m.info.video.as_ref().map(|v| (v.width, v.height)); m.media = MediaRef::File { path: p.cand.path.clone() }; m.offline = false;- if p.cand.identity.is_some() {+ if replace {+ // A proxy belongs to the old footage, not to its replacement.+ m.proxy = None;+ }+ // An unreadable replacement identity is unknown, never the old footage's identity.+ if replace || p.cand.identity.is_some() { m.identity = p.cand.identity; } let mut shift = Tick::ZERO;@@ -345,15 +351,26 @@ fn apply(s: &mut Session, plans: Vec<Plan>, align_timecode: bool, label: &str) - m.info = info.clone(); } proj.shift_media_time(p.item, shift);+ if replace {+ recenter_default_anchors(proj, p.item, old_size);+ } if p.cand.info.is_some() { // The item's size is now the file's own. Clips that came from an interchange import // while the file was missing still have an "auto" (NaN) anchor waiting for it:- // centre it in the real picture. Points that are numbers are not touched.+ // centre it in the real picture. Other numeric points stay intentional;+ // replacement's default Motion anchor was already adjusted above. proj.resolve_placed_auto_points(|_| true, |source| source.id == p.item); } } Ok(()) })?;+ if replace {+ // Replacement is a new user decision, even if the path and fingerprint are unchanged.+ // Do this only after the edit succeeds, and leave other items in each batch valid.+ for job in &mut s.media_jobs {+ job.sources.retain(|(item, _, _)| !plans.iter().any(|p| p.item == *item));+ }+ } for p in &plans { s.media.allow(&p.cand.path); }@@ -368,6 +385,36 @@ fn apply(s: &mut Session, plans: Vec<Plan>, align_timecode: bool, label: &str) - Ok(rows) } +/// Keep an unanimated, source-centred Motion pivot centred when replacement changes its size.+/// Numeric custom pivots and all keyframed pivots remain intentional source-pixel coordinates.+fn recenter_default_anchors(project: &mut Project, item: ItemId, old_size: Option<(u32, u32)>) {+ let (Some(old), Some(new)) = (old_size, project.source_size(item)) else { return };+ if old == new || old.0 == 0 || old.1 == 0 || new.0 == 0 || new.1 == 0 {+ return;+ }+ // Include every sequence and subclip chain, using the same bounded lookup as the renderer.+ let users: std::collections::BTreeSet<ItemId> =+ project.items.values().filter(|i| project.resolve_media(i.id).is_some_and(|(source, _, _)| source == item)).map(|i| i.id).collect();+ let old_center = filmcraft_geom::Vec2::new(f64::from(old.0) / 2.0, f64::from(old.1) / 2.0);+ let new_center = filmcraft_geom::Vec2::new(f64::from(new.0) / 2.0, f64::from(new.1) / 2.0);+ for entry in project.items.values_mut() {+ let ItemKind::Sequence(sequence) = &mut entry.kind else { continue };+ if !sequence.all_tracks().flat_map(|t| t.items.iter()).any(|clip| users.contains(&clip.item)) {+ continue;+ }+ let sequence = std::sync::Arc::make_mut(sequence);+ for clip in sequence.all_tracks_mut().flat_map(|t| t.items.iter_mut()) {+ if !users.contains(&clip.item) {+ continue;+ }+ let Some(anchor) = clip.effect_mut("motion").and_then(|e| e.params.get_mut("anchor")) else { continue };+ if anchor.keyframes.is_empty() && anchor.value == filmcraft_project::ParamValue::Vec2(old_center) {+ anchor.value = filmcraft_project::ParamValue::Vec2(new_center);+ }+ }+ }+}+ /// Other missing items that the remap (or the new file's folder) finds, checked. fn others(s: &Session, skip: ItemId, old: &str, new: &str, o: &MatchOptions) -> Vec<Plan> { let remap = derive_remap(old, new);@@ -404,6 +451,11 @@ fn others(s: &Session, skip: ItemId, old: &str, new: &str, o: &MatchOptions) -> /// `media.relink`: link `item` to `path` (checked unless `force`), then the others. pub fn relink(s: &mut Session, p: &Value) -> Result<Value> {+ relink_inner(s, p, false)+}++/// Plan and apply a checked relink or an intentional single-item replacement.+fn relink_inner(s: &mut Session, p: &Value, replace: bool) -> Result<Value> { let item = u64_p(p, "item").map(ItemId).ok_or_else(|| bad("media.relink", "need `item`"))?; let path = str_p(p, "path").ok_or_else(|| bad("media.relink", "need `path`"))?.to_string(); let force = bool_p(p, "force").unwrap_or(false);@@ -429,7 +481,7 @@ pub fn relink(s: &mut Session, p: &Value) -> Result<Value> { if o.relink_others { plans.extend(others(s, item, &old, &path, &o)); }- let rows = apply(s, plans, o.align_timecode, "Link Media")?;+ let rows = apply(s, plans, o.align_timecode, if replace { "Replace Footage" } else { "Link Media" }, replace)?; Ok(json!({"relinked": rows, "remaining": s.offline.missing.iter().map(|i| i.0).collect::<Vec<_>>()})) } @@ -469,7 +521,7 @@ pub fn auto_relink(s: &mut Session, p: &Value) -> Result<Value> { } else { return Err(bad("media.autoRelink", "need `from` and `to`, or `folder`")); }- let rows = if plans.is_empty() { Vec::new() } else { apply(s, plans, o.align_timecode, "Link Media")? };+ let rows = if plans.is_empty() { Vec::new() } else { apply(s, plans, o.align_timecode, "Link Media", false)? }; refresh(s); Ok(json!({"relinked": rows, "failed": failed, "remaining": s.offline.missing.iter().map(|i| i.0).collect::<Vec<_>>()})) }@@ -612,6 +664,24 @@ fn status_json(s: &Session, id: ItemId) -> Option<Value> { })) } +/// Enable replacement only for one selected file-backed Project item.+fn can_replace_footage(s: &Session) -> std::result::Result<(), String> {+ let [item] = s.state.project_selection.as_slice() else { return Err("select one file in the Project panel".into()) };+ match s.project.item(*item).and_then(|i| i.as_media()).map(|m| &m.media) {+ Some(MediaRef::File { .. }) => Ok(()),+ _ => Err("select one file in the Project panel".into()),+ }+}++/// Replace the explicitly named item without remapping neighbours or shifting source times.+fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {+ let item = u64_p(p, "item").ok_or_else(|| bad("media.replaceFootage", "need `item`"))?;+ let path = str_p(p, "path").ok_or_else(|| bad("media.replaceFootage", "need `path`"))?;+ // Build our own parameters: callers cannot accidentally relink neighbouring missing media+ // or shift source times when deliberately replacing a single Project item.+ relink_inner(s, &json!({"item": item, "path": path, "force": true, "relinkOthers": false}), true)+}+ pub fn commands() -> Vec<CommandSpec> { vec![ CommandSpec {@@ -680,16 +750,11 @@ pub fn commands() -> Vec<CommandSpec> { CommandSpec { id: "media.replaceFootage", label: "Replace Footage…",- menu: &[],+ menu: &["Clip"], shortcut: None, params: r#"{"item":id,"path":str}"#,- enabled: always,- run: |s, p| {- let mut q = p.clone();- q["force"] = json!(true);- q["relinkOthers"] = json!(false);- relink(s, &q)- },+ enabled: can_replace_footage,+ run: replace_footage, journal: true, }, CommandSpec {@@ -14,6 +14,356 @@ use crate::relink::{apply_remap, derive_remap}; const W: u32 = 160; const H: u32 = 90; +/// A replacement whose bytes can be decoded but not fingerprinted must not inherit the old identity.+#[test]+fn replace_footage_clears_unreadable_identity_and_undo_restores_it() {+ struct NoFingerprint;+ impl crate::Services for NoFingerprint {+ fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {+ std::fs::read(path)+ }+ fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {+ crate::Services::write_file(&crate::FsServices, path, data)+ }+ fn file_size(&self, _path: &str) -> std::io::Result<u64> {+ Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "fingerprint metadata unavailable"))+ }+ }+ let root = tmp_dir("replace-no-fingerprint");+ let original = root.join("original.mov");+ let replacement = root.join("replacement.mov");+ make_movie(&original, DemoScene::Aurora, W, H, 12);+ make_movie(&replacement, DemoScene::Dunes, W, H, 12);+ let (mut s, items, _) = session_with(&[&original]);+ let item = items[0];+ let before = s.project.clone();+ assert!(before.item(item).unwrap().as_media().unwrap().identity.is_some());+ s.services = std::sync::Arc::new(NoFingerprint);+ s.execute("media.replaceFootage", json!({"item":item.0,"path":replacement.to_string_lossy()})).unwrap();+ let media = s.project.item(item).unwrap().as_media().unwrap();+ assert_eq!(media.media, filmcraft_project::MediaRef::File { path: replacement.to_string_lossy().into_owned() });+ assert!(media.identity.is_none(), "the old footage's fingerprint cannot describe the replacement");+ let after = s.project.clone();+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(s.project, before);+ s.execute("edit.redo", json!({})).unwrap();+ assert_eq!(s.project, after);+ let path = root.join("replaced.fcproj");+ s.execute("file.save", json!({"path":path.to_string_lossy()})).unwrap();+ assert_eq!(open(path.to_str().unwrap()).project.item(item), after.item(item));+ let _ = std::fs::remove_dir_all(root);+}++/// A differently sized replacement keeps its visible centre in sequence coordinates.+#[test]+fn replace_footage_keeps_smaller_and_larger_sources_centered() {+ let root = tmp_dir("replace-centre-pixels");+ let original = root.join("original.mov");+ make_movie(&original, DemoScene::Aurora, W, H, 12);+ let (mut s, items, _) = session_with(&[&original]);+ for (width, height) in [(80, 40), (240, 120), (80, 40)] {+ let replacement = root.join(format!("{width}-{height}.png"));+ // A small opaque marker at the source centre makes both up- and down-sizing visible.+ image::RgbaImage::from_fn(width, height, |x, y| {+ if (width / 2 - 10..width / 2 + 10).contains(&x) && (height / 2 - 5..height / 2 + 5).contains(&y) {+ image::Rgba([255, 255, 255, 255])+ } else {+ image::Rgba([0, 0, 0, 0])+ }+ })+ .save(&replacement)+ .unwrap();+ let before = s.project.clone();+ s.execute("media.replaceFootage", json!({"item":items[0].0,"path":replacement.to_string_lossy()})).unwrap();+ let after = s.project.clone();+ let (w, h, pixels) = frame_rgba(&mut s, 2, 1.0);+ assert_eq!((w, h), (W as usize, H as usize));+ let lit: Vec<(usize, usize)> =+ pixels.as_chunks::<4>().0.iter().enumerate().filter(|(_, p)| p[0] > 100 || p[1] > 100 || p[2] > 100).map(|(i, _)| (i % w, i / w)).collect();+ assert!(lit.len() > 10);+ assert_eq!(+ lit.iter().map(|p| p.0).min().unwrap() + lit.iter().map(|p| p.0).max().unwrap(),+ w - 1,+ "horizontal centre after {width}x{height} replacement"+ );+ assert_eq!(+ lit.iter().map(|p| p.1).min().unwrap() + lit.iter().map(|p| p.1).max().unwrap(),+ h - 1,+ "vertical centre after {width}x{height} replacement"+ );+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(s.project, before);+ s.execute("edit.redo", json!({})).unwrap();+ assert_eq!(s.project, after);+ }+ let path = root.join("centred.fcproj");+ s.execute("file.save", json!({"path":path.to_string_lossy()})).unwrap();+ assert_eq!(open(path.to_str().unwrap()).project, s.project);+ let _ = std::fs::remove_dir_all(root);+}++/// Default pivots follow the source through subclips; custom and animated pivots stay intact.+#[test]+fn replace_footage_preserves_custom_motion_and_centers_subclips_in_every_sequence() {+ use filmcraft_geom::Vec2;+ use filmcraft_project::{ItemKind, Keyframe, Label, ParamValue, TrackKind};+ use filmcraft_time::{Tick, TimeRange};+ let root = tmp_dir("replace-centre-motion");+ let original = root.join("original.mov");+ let replacement = root.join("replacement.mov");+ make_movie(&original, DemoScene::Aurora, W, H, 12);+ make_movie(&replacement, DemoScene::Dunes, W * 2, H * 2, 12);+ let (mut s, items, first) = session_with(&[&original, &original]);+ let rate = s.sequence_rate();+ let p = std::sync::Arc::make_mut(&mut s.project);+ let range = TimeRange::new(Tick::ZERO, rate.tick_of(12));+ let sub = p.add_item("sub", Label::Iris, ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);+ let nested = p.add_item("nested sub", Label::Iris, ItemKind::Subclip { parent: sub, range, restrict_trims: false }, None);+ let settings = p.sequence(first).unwrap().settings.clone();+ let second = p.new_sequence("Other sequence", settings, 1, 0, None);+ let mut clips = Vec::new();+ for (source, kind) in [(nested, 0), (items[0], 1), (items[0], 2)] {+ let mut clip = p.make_track_item(source, TrackKind::Video, Tick::ZERO, range, rate).unwrap();+ for e in &mut clip.effects {+ filmcraft_project::resolve_auto_points(e, (W, H), (W, H));+ }+ let motion = clip.effect_mut("motion").unwrap();+ motion.params.get_mut("position").unwrap().value = ParamValue::Vec2(Vec2::new(31.0, 42.0));+ motion.params.get_mut("scale").unwrap().value = ParamValue::Float(75.0);+ motion.params.get_mut("rotation").unwrap().value = ParamValue::Float(12.0);+ let anchor = motion.params.get_mut("anchor").unwrap();+ if kind == 1 {+ anchor.value = ParamValue::Vec2(Vec2::new(12.0, 17.0));+ }+ if kind == 2 {+ anchor.keyframes = vec![Keyframe::new(Tick::ZERO, anchor.value.clone()), Keyframe::new(rate.tick_of(5), ParamValue::Vec2(Vec2::new(20.0, 30.0)))];+ }+ clips.push(clip);+ }+ p.sequence_mut(second).unwrap().video_tracks[0].items = clips;+ let mut expected = p.sequence(second).unwrap().clone();+ expected.video_tracks[0].items[0].effect_mut("motion").unwrap().params.get_mut("anchor").unwrap().value =+ ParamValue::Vec2(Vec2::new(f64::from(W), f64::from(H)));+ let other = p.item(items[1]).unwrap().clone();+ let before = s.project.clone();+ s.execute("media.replaceFootage", json!({"item":items[0].0,"path":replacement.to_string_lossy()})).unwrap();+ assert_eq!(s.project.sequence(second), Some(&expected));+ assert_eq!(s.project.item(items[1]), Some(&other));+ let first_seq = s.project.sequence(first).unwrap();+ assert_eq!(first_seq.video_tracks[0].items[0].effect("motion").unwrap().vec2_at("anchor", Tick::ZERO), Vec2::new(f64::from(W), f64::from(H)));+ assert_eq!(first_seq.video_tracks[0].items[1], before.sequence(first).unwrap().video_tracks[0].items[1]);+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(s.project, before);+ let _ = std::fs::remove_dir_all(root);+}++#[test]+/// Replacement preserves edits, refreshes source pixels, and survives undo/redo and reopen.+fn replace_footage_preserves_edits_updates_pixels_and_round_trips() {+ let root = tmp_dir("replace-footage");+ let original = root.join("original.mov");+ let replacement = root.join("replacement.mov");+ make_movie(&original, DemoScene::Aurora, W, H, 12);+ // A shorter replacement with a different size must not move or trim existing edits.+ make_movie(&replacement, DemoScene::Dunes, W * 2, H * 2, 6);+ let (mut s, items, seq) = session_with(&[&original, &original]);+ let item = items[0];+ s.edit("attach test proxy", |project, _| {+ project.item_mut(item).unwrap().as_media_mut().unwrap().proxy =+ Some(filmcraft_project::MediaRef::File { path: original.to_string_lossy().into_owned() });+ Ok(())+ })+ .unwrap();+ let before = s.project.clone();+ let pixels = frame_rgba(&mut s, 2, 1.0).2;+ // Explicit targeting works without a Project selection, as required by CLI/MCP callers.+ s.state.project_selection.clear();+ s.execute("media.replaceFootage", json!({"item":item.0,"path":replacement.to_string_lossy(),"relinkOthers":true,"alignTimecode":true})).unwrap();+ let after = s.project.clone();+ let mut expected = before.sequence(seq).unwrap().clone();+ for clip in expected.all_tracks_mut().flat_map(|t| t.items.iter_mut()).filter(|c| c.item == item) {+ if let Some(motion) = clip.effect_mut("motion") {+ motion.params.get_mut("anchor").unwrap().value = filmcraft_project::ParamValue::Vec2(filmcraft_geom::Vec2::new(f64::from(W), f64::from(H)));+ }+ }+ assert_eq!(after.sequence(seq), Some(&expected), "only the default source anchor changes; all edits survive");+ assert_eq!(after.items.len(), before.items.len());+ let media = after.item(item).unwrap().as_media().unwrap();+ assert_eq!(media.info.video.as_ref().unwrap().width, W * 2);+ assert!(media.proxy.is_none(), "old proxy must not hide replacement footage");+ assert_eq!(after.item(items[1]), before.item(items[1]), "another bin item using the original file is untouched");+ assert_ne!(frame_rgba(&mut s, 2, 1.0).2, pixels, "cached source is replaced immediately");+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(s.project, before);+ assert_eq!(frame_rgba(&mut s, 2, 1.0).2, pixels, "undo invalidates the replacement source too");+ s.execute("edit.redo", json!({})).unwrap();+ assert_eq!(s.project, after);+ let path = root.join("replaced.fcproj");+ s.execute("file.save", json!({"path":path.to_string_lossy()})).unwrap();+ let reopened = open(path.to_str().unwrap());+ assert_eq!(reopened.project.item(item), after.item(item));+ assert_eq!(reopened.project.sequence(seq), after.sequence(seq));+ let _ = std::fs::remove_dir_all(root);+}++#[test]+/// Invalid targets and files must leave the project and its undo history unchanged.+fn replace_footage_rejects_bad_targets_and_files_without_mutation() {+ let root = tmp_dir("replace-invalid");+ let original = root.join("original.mov");+ make_movie(&original, DemoScene::Aurora, W, H, 12);+ let (mut s, items, seq) = session_with(&[&original]);+ s.state.project_selection.clear();+ assert!(!s.is_enabled("media.replaceFootage"));+ s.state.project_selection = vec![seq];+ assert!(!s.is_enabled("media.replaceFootage"));+ s.state.project_selection = vec![items[0], seq];+ assert!(!s.is_enabled("media.replaceFootage"));+ s.state.project_selection = vec![items[0]];+ assert!(s.is_enabled("media.replaceFootage"));+ let corrupt = root.join("bad.mov");+ std::fs::write(&corrupt, b"not a movie").unwrap();+ let before = s.project.clone();+ let undo = s.undo_mark();+ for p in [+ json!(null),+ json!([]),+ json!({}),+ json!({"item":u64::MAX,"path":"bad"}),+ json!({"item":seq.0,"path":original.to_string_lossy()}),+ json!({"item":items[0].0,"path":root.join("missing.mov").to_string_lossy()}),+ json!({"item":items[0].0,"path":corrupt.to_string_lossy()}),+ ] {+ assert!(s.execute("media.replaceFootage", p).is_err());+ assert_eq!(s.project, before);+ assert_eq!(s.undo_mark(), undo);+ }+ let _ = std::fs::remove_dir_all(root);+}++#[test]+/// A completed job for another source cannot overwrite replacement footage.+fn replace_footage_ignores_old_background_proxy_and_ingest_outputs() {+ use crate::proxies::{OnDone, PendingJob};+ let root = tmp_dir("replace-pending");+ let original = root.join("original.mov");+ let replacement = root.join("replacement.mov");+ make_movie(&original, DemoScene::Aurora, W, H, 12);+ // Matching properties: proxy validation alone cannot tell this is old footage.+ make_movie(&replacement, DemoScene::Dunes, W, H, 12);+ for on_done in [OnDone::AttachProxies, OnDone::ReplaceMedia] {+ let (mut s, items, _) = session_with(&[&original, &original]);+ let sources = items+ .iter()+ .map(|id| {+ let media = s.project.item(*id).unwrap().as_media().unwrap();+ (*id, media.media.clone(), media.identity)+ })+ .collect();+ s.execute("media.replaceFootage", json!({"item":items[0].0,"path":replacement.to_string_lossy()})).unwrap();+ let expected = s.project.item(items[0]).unwrap().clone();+ s.media_jobs.push(PendingJob {+ job: u64::MAX,+ on_done,+ sources,+ outputs: std::sync::Arc::new(std::sync::Mutex::new(items.iter().map(|id| (*id, original.to_string_lossy().into_owned())).collect())),+ });+ crate::proxies::poll(&mut s);+ assert_eq!(s.project.item(items[0]), Some(&expected), "stale output cannot undo the replacement");+ if on_done == OnDone::AttachProxies {+ assert!(s.project.item(items[1]).unwrap().as_media().unwrap().proxy.is_some(), "valid outputs in the same batch still attach");+ }+ assert!(s.media_jobs.is_empty());+ }+ let _ = std::fs::remove_dir_all(root);+}++/// Replacing with the same file invalidates older jobs, without discarding other batch items.+#[test]+fn replace_footage_invalidates_pending_jobs_for_the_same_source() {+ use crate::proxies::{OnDone, PendingJob};+ let root = tmp_dir("replace-same-source");+ let original = root.join("original.mov");+ make_movie(&original, DemoScene::Aurora, W, H, 12);+ for on_done in [OnDone::AttachProxies, OnDone::ReplaceMedia] {+ let (mut s, items, _) = session_with(&[&original, &original]);+ let sources = items+ .iter()+ .map(|id| {+ let media = s.project.item(*id).unwrap().as_media().unwrap();+ (*id, media.media.clone(), media.identity)+ })+ .collect();+ let outputs = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));+ s.media_jobs.push(PendingJob { job: u64::MAX, on_done, sources, outputs: outputs.clone() });+ // Rejected replacements must not invalidate work that still belongs to this source.+ assert!(s.execute("media.replaceFootage", json!({"item":items[0].0,"path":root.join("missing.mov").to_string_lossy()})).is_err());+ assert_eq!(s.media_jobs[0].sources.len(), 2);+ s.execute("media.replaceFootage", json!({"item":items[0].0,"path":original.to_string_lossy()})).unwrap();+ assert_eq!(s.media_jobs[0].sources.iter().map(|(id, _, _)| *id).collect::<Vec<_>>(), vec![items[1]]);+ // Simulate the worker finishing only after replacement, with the unchanged source tuple.+ outputs.lock().unwrap().extend(items.iter().map(|id| (*id, original.to_string_lossy().into_owned())));+ crate::proxies::poll(&mut s);+ assert!(s.project.item(items[0]).unwrap().as_media().unwrap().proxy.is_none());+ if on_done == OnDone::AttachProxies {+ assert!(s.project.item(items[1]).unwrap().as_media().unwrap().proxy.is_some());+ }+ assert!(s.media_jobs.is_empty());+ }+ let _ = std::fs::remove_dir_all(root);+}++/// Rejected job files are cleaned up, but files adopted as current media remain available.+#[test]+fn replace_footage_cleans_up_unreferenced_job_outputs() {+ use crate::proxies::{OnDone, PendingJob};+ let root = tmp_dir("replace-cleanup");+ let original = root.join("original.mov");+ make_movie(&original, DemoScene::Aurora, W, H, 12);+ for (on_done, adopt_output) in+ [(OnDone::AttachProxies, 0), (OnDone::AttachProxies, 1), (OnDone::AttachProxies, 2), (OnDone::AttachProxies, 3), (OnDone::Nothing, 0)]+ {+ let stale = root.join("stale-proxy.mov");+ let valid = root.join("valid-proxy.mov");+ std::fs::copy(&original, &stale).unwrap();+ std::fs::copy(&original, &valid).unwrap();+ let (mut s, items, _) = session_with(&[&original, &original]);+ let sources = items+ .iter()+ .map(|id| {+ let m = s.project.item(*id).unwrap().as_media().unwrap();+ (*id, m.media.clone(), m.identity)+ })+ .collect();+ s.media_jobs.push(PendingJob {+ job: u64::MAX,+ on_done,+ sources,+ outputs: std::sync::Arc::new(std::sync::Mutex::new(vec![+ (items[0], stale.to_string_lossy().into_owned()),+ (items[1], valid.to_string_lossy().into_owned()),+ ])),+ });+ let alias = root.join(".").join("stale-proxy.mov");+ let replacement = match adopt_output {+ 1 | 3 => &stale,+ 2 => &alias,+ _ => &original,+ };+ s.execute("media.replaceFootage", json!({"item":items[0].0,"path":replacement.to_string_lossy()})).unwrap();+ if adopt_output == 3 {+ // The output is no longer current, but undo must still be able to restore it.+ s.execute("media.replaceFootage", json!({"item":items[0].0,"path":original.to_string_lossy()})).unwrap();+ }+ crate::proxies::poll(&mut s);+ assert_eq!(stale.exists(), adopt_output != 0 || on_done == OnDone::Nothing, "only an unreferenced obsolete output is deleted");+ assert!(valid.exists(), "accepted output is untouched");+ assert!(original.exists(), "input source is untouched");+ assert_eq!(s.project.item(items[1]).unwrap().as_media().unwrap().proxy.is_some(), on_done == OnDone::AttachProxies);+ }+ let _ = std::fs::remove_dir_all(root);+}+ /// A saved project at `<root>/Project/p.fcproj` using `<root>/Media/{a,b}.mov`. fn saved_project(root: &std::path::Path) -> (String, Vec<ItemId>) { let media = root.join("Media");@@ -113,7 +463,7 @@ fn fingerprint_mismatch_is_refused_unless_forced() { let root = tmp_dir("relink-mismatch"); let (path, items) = saved_project(&root); std::fs::remove_dir_all(root.join("Media")).unwrap();- // same name, duration, size and rate — different pictures+ // same name, duration, size and rate -- different pictures let other = root.join("Other"); std::fs::create_dir_all(&other).unwrap(); make_movie(&other.join("a.mov"), DemoScene::Dunes, W, H, 12);@@ -198,7 +548,7 @@ fn reimport_clears_offline_list_after_path_resolves() { let a = root.join("Media/a.mov"); // Delete a.mov so the saved project now points at a missing file, then reopen:- // `on_open` → `refresh` → `scan` registers items[0] as missing.+ // `on_open` -> `refresh` -> `scan` registers items[0] as missing. let online = frame_rgba(&mut open(&path), 3, 1.0); std::fs::remove_file(&a).unwrap(); let mut s = open(&path);@@ -210,7 +560,7 @@ fn reimport_clears_offline_list_after_path_resolves() { // and the user has to reopen or `media.relink` each item. make_movie(&a, DemoScene::Aurora, W, H, 12); s.execute("file.import", json!({"paths": [a.to_string_lossy()]})).unwrap();- assert!(!s.offline.missing.contains(&items[0]), "issue #110 — file.import did not refresh s.offline.missing (still contains {:?})", s.offline.missing);+ assert!(!s.offline.missing.contains(&items[0]), "issue #110 -- file.import did not refresh s.offline.missing (still contains {:?})", s.offline.missing); assert_eq!(s.execute("media.findMissing", json!({})).unwrap()["missing"].as_array().unwrap().len(), 0); // and the monitors show the file again, not the cached slate assert_eq!(frame_rgba(&mut s, 3, 1.0), online);@@ -3,6 +3,19 @@ The egui frontend draws panels and dialogs, and dispatches project changes through the engine. Interactive controls keep stable automation ids regardless of the interface language. +## Replace Footage++Select a single file-backed item in Project, then use **Clip ▸ Replace Footage…**, or right-click+the item and choose **Replace Footage…** (`project.itemMenu.replaceFootage`). The existing relink+picker supplies a replacement path without importing another item. Its async hint names the+original item, so a later selection change cannot redirect the replacement. The engine command+`media.replaceFootage {item, path}` remains available to CLI/MCP clients. Cancellation is a no-op.+See [project files](../../docs/project-files.md#link-media) for preservation and proxy semantics.++Replacing with another frame size keeps a default, static Motion anchor centred in the new+source. Position, Scale, Rotation, custom anchors and anchor keyframes are preserved. This+applies in every sequence, including clips that reference the source through subclips.+ ## Control input limits The control channel bounds `ui.click` to 10 clicks and `ui.drag` to 1024 movement steps (#2784).@@ -111,7 +111,7 @@ pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>; /// that can only run it later. #[derive(Clone, Debug)] pub struct RelinkHint {- /// `media.relink`, `media.attachProxies` or `media.reconnectFullRes`.+ /// `media.relink`, `media.replaceFootage`, `media.attachProxies` or `media.reconnectFullRes`. pub command: String, /// Its parameters, without `"path"`. pub params: serde_json::Value,@@ -3614,3 +3614,4 @@ Shows the version, community links and credits in a FilmCraft window. Zeigt Version, Community-Links und Credits in einem FilmCraft-Fenster. Couldn't open your web browser Dein Webbrowser konnte nicht geöffnet werden Open this address in your browser yourself: Öffne diese Adresse selbst in deinem Browser:+ Replace Footage… Filmmaterial ersetzen…@@ -3628,3 +3628,4 @@ No matching text. Ningún texto coincide. H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead. H.265 (HEVC) necesita un codificador por hardware (NVENC en GPU NVIDIA, VideoToolbox en macOS); no se encontró ninguno en este equipo y FilmCraft aún no tiene un codificador H.265 por software. Elija H.264 o ProRes. AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet. AV1 necesita un codificador por hardware; no se encontró ninguno en este equipo y FilmCraft aún no tiene un codificador AV1 por software.+ Replace Footage… Reemplazar material de archivo…@@ -3627,3 +3627,4 @@ H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead. H.265 (HEVC) nécessite un encodeur matériel (NVENC sur les GPU NVIDIA, VideoToolbox sur macOS) ; aucun n'a été trouvé sur cet ordinateur, et FilmCraft n'a pas encore d'encodeur H.265 logiciel. Choisissez plutôt H.264 ou ProRes. AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet. AV1 nécessite un encodeur matériel ; aucun n'a été trouvé sur cet ordinateur, et FilmCraft n'a pas encore d'encodeur AV1 logiciel. Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Décodage matériel : Auto utilise le décodeur vidéo du système (VideoToolbox sur macOS, Media Foundation sur Windows, VA-API pour H.264 et HEVC sous Linux) pour les flux qu'il prend en charge, et le décodeur de FilmCraft pour tout le reste ou en cas d'échec du matériel. Un média déjà ouvert garde son décodeur jusqu'à sa réouverture.+ Replace Footage… Remplacer le métrage…@@ -3583,3 +3583,4 @@ Open a sequence to search its graphics Buka sekuens untuk mencari di grafisnya No text graphics in this sequence. Add text with the Type tool. Tidak ada grafis teks di sekuens ini. Tambahkan teks dengan alat Teks. No matching text. Tidak ada teks yang cocok.+ Replace Footage… Ganti Rekaman…@@ -3609,3 +3609,4 @@ Open a sequence to search its graphics Apri una sequenza per cercare nelle sue grafiche No text graphics in this sequence. Add text with the Type tool. Nessuna grafica con testo in questa sequenza. Aggiungi testo con lo strumento Testo. No matching text. Nessun testo corrispondente.+ Replace Footage… Sostituisci metraggio…@@ -3627,3 +3627,4 @@ No matching text. 一致するテキストはありません。 H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead. H.265 (HEVC) にはハードウェアエンコーダー(NVIDIA GPU の NVENC、macOS の VideoToolbox)が必要です。このマシンには見つからず、FilmCraft にはまだソフトウェアの H.265 エンコーダーがありません。代わりに H.264 または ProRes を選んでください。 AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet. AV1 にはハードウェアエンコーダーが必要です。このマシンには見つからず、FilmCraft にはまだソフトウェアの AV1 エンコーダーがありません。+ Replace Footage… フッテージを置き換え…@@ -3604,3 +3604,4 @@ Open a sequence to search its graphics 그래픽을 검색하려면 시퀀스를 여세요 No text graphics in this sequence. Add text with the Type tool. 이 시퀀스에는 텍스트 그래픽이 없습니다. 문자 도구로 텍스트를 추가하세요. No matching text. 일치하는 텍스트가 없습니다.+ Replace Footage… 푸티지 바꾸기…@@ -3586,3 +3586,4 @@ Open a sequence to search its graphics Abra uma sequência para pesquisar nos gráficos dela No text graphics in this sequence. Add text with the Type tool. Esta sequência não tem gráficos com texto. Adicione texto com a ferramenta Texto. No matching text. Nenhum texto corresponde.+ Replace Footage… Substituir gravação…@@ -360,4 +360,4 @@ Zoom to Sequence Масштабировать по размеру эпизода Next Appearance Mode Следующий режим оформления Project Notes Заметки проекта-+ Replace Footage… Заменить материал…@@ -360,4 +360,4 @@ Zoom Out Зменшити Zoom to Sequence Умістити секвенцію Project Notes Нотатки проєкту-+ Replace Footage… Замінити матеріал…@@ -3603,3 +3603,4 @@ No matching text. 没有匹配的文本。 H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead. H.265 (HEVC) 需要硬件编码器(NVIDIA GPU 上的 NVENC、macOS 上的 VideoToolbox);本机未找到,且 FilmCraft 尚无软件 H.265 编码器。请改选 H.264 或 ProRes。 AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet. AV1 需要硬件编码器;本机未找到,且 FilmCraft 尚无软件 AV1 编码器。+ Replace Footage… 替换素材…@@ -58,6 +58,24 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option<Result< } let enabled = |app: &FilmcraftApp, id: &str| filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session)); match id {+ "media.replaceFootage" => {+ if let Err(e) = enabled(app, id) {+ return Some(Err(e));+ }+ let item = app.session.state.project_selection.first().copied()?;+ let exts = filmcraft_media::VIDEO_EXTENSIONS+ .iter()+ .chain(filmcraft_media::AUDIO_EXTENSIONS)+ .chain(filmcraft_media::STILL_EXTENSIONS)+ .map(|s| s.to_string())+ .collect();+ let hint = RelinkHint { command: id.to_string(), params: json!({"item": item.0}) };+ Some(app.pick(FileDialog::Relink { exts, hint }, move |app, paths| {+ let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) };+ app.stop();+ app.session.execute("media.replaceFootage", json!({"item": item.0, "path": path})).map_err(|e| e.to_string())+ }))+ } "media.linkMedia" => { let r = app.session.execute("media.linkMedia", json!({})).map_err(|e| e.to_string()); if let Ok(v) = &r@@ -300,6 +300,13 @@ fn item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: ItemId, kind: &ItemK } if matches!(kind, ItemKind::Media(m) if matches!(m.media, filmcraft_project::MediaRef::File { .. })) { ui.separator();+ let replace = ui.button(tl!("Replace Footage…"));+ app.auto.add("project.itemMenu.replaceFootage", replace.rect, "Replace Footage…");+ if replace.clicked() {+ actions.push(("project.select".into(), json!({"items": [id.0]})));+ actions.push(("media.replaceFootage".into(), json!({})));+ ui.close();+ } for (label, cmd) in [(tl!("Link Media…"), "media.linkMedia"), (tl!("Make Offline…"), "media.makeOffline")] { if ui.button(label).clicked() { actions.push(("project.select".into(), sel()));@@ -516,3 +516,54 @@ fn locate_relinks_instead_of_importing() { assert!(st.as_array().unwrap().iter().all(|m| m["status"] == "online"), "item {item}: {st}"); let _ = std::fs::remove_dir_all(&root); }++#[test]+/// The actual menu preserves its target across async selection changes and supports cancellation.+fn replace_footage_picker_cancels_and_replaces_the_original_selection() {+ let root = tmp_dir("replace-footage");+ let original = root.join("original.mov");+ let replacement = root.join("replacement.mov");+ make_movie(&original, DemoScene::Aurora, 160, 90, 12);+ make_movie(&replacement, DemoScene::Dunes, 320, 180, 12);+ let mut s = Session::default();+ let imported = s.execute("file.import", json!({"paths":[original.to_string_lossy()]})).unwrap();+ let item = imported["items"][0].as_u64().unwrap();+ let mut d = Driver::new(s);+ d.exec("project.select", json!({"items":[item]}));+ let menu = filmcraft_ui_egui::menus::menu_items_for(&d.app().session);+ let entry = menu.iter().find(|m| m.id == "media.replaceFootage").unwrap();+ assert_eq!(entry.path, vec!["Clip"]);+ assert!(entry.enabled);+ let before = d.app().session.project.clone();+ let hints = Arc::new(std::sync::Mutex::new(Vec::new()));+ let h = hints.clone();+ d.app().hooks.pick_file_for_relink = Some(Box::new(move |exts, hint| {+ assert!(exts.contains(&"mov") && exts.contains(&"wav") && exts.contains(&"png"));+ h.lock().unwrap().push(hint.unwrap());+ None+ }));+ d.click("menu.Clip");+ assert!(d.ids("menu.media.replaceFootage").contains(&"menu.media.replaceFootage".to_string()));+ d.snapshot("media-replace-footage-menu");+ d.click("menu.media.replaceFootage");+ assert_eq!(d.app().session.project, before, "cancelling never edits the project");+ let hint = hints.lock().unwrap().pop().unwrap();+ assert_eq!(hint.command, "media.replaceFootage");+ assert_eq!(hint.params["item"], item);+ // An async host retains the requested item even if selection changes before its answer.+ d.exec("project.select", json!({"items":[]}));+ let mut params = hint.params;+ params["path"] = json!(replacement.to_string_lossy());+ d.exec(&hint.command, params);+ assert_eq!(d.app().session.project.items.len(), before.items.len());+ d.exec("edit.undo", json!({}));+ d.exec("project.select", json!({"items":[item]}));+ let file = replacement.to_string_lossy().into_owned();+ d.app().hooks.pick_file_for_relink = Some(Box::new(move |_, _| Some(file.clone())));+ d.ok("ui.menu.invoke", json!({"id":"media.replaceFootage"}));+ let media = d.app().session.project.item(filmcraft_engine::project::ItemId(item)).unwrap().as_media().unwrap();+ assert_eq!(media.info.video.as_ref().unwrap().width, 320);+ assert!(matches!(&media.media, MediaRef::File {path} if path == replacement.to_str().unwrap()));+ d.snapshot("media-replace-footage");+ let _ = std::fs::remove_dir_all(root);+}@@ -289,9 +289,23 @@ have no identity, so relinking them skips the fingerprint check. | `media.relink {item, path, force?, relinkOthers?=true, alignTimecode?, match?}` | check, link, then the others | | `media.autoRelink {from, to}` / `{folder}` | batch relink: a folder-prefix remap, or a search of a folder tree | | `media.search {folder, item?, exactName?=true}` | ranked candidates with `ok`, `identityMatch`, `problems` |-| `media.replaceFootage {item, path}` | relink without checks (Replace Footage) |+| `media.replaceFootage {item, path}` | replace one file-backed Project item; Clip ▸ Replace Footage… or the item's context menu opens a picker | | `media.offlineAll` | close the dialog and leave the rest offline | +Replace Footage deliberately accepts a different file name, duration, dimensions or frame rate.+It preserves the Project item's ID and its uses in every sequence; `MediaClip::identity` is updated+to the replacement file's fingerprint, or cleared if the host can decode the file but cannot+read its fingerprint metadata. It never retains the old file's identity after replacement. Clip placements, source trims,+effects and durations are retained, even when the new source is shorter. A static Motion anchor+still at the old source's centre moves to the replacement's centre when dimensions change,+including uses through subclips and in other sequences. Position, Scale and Rotation remain+unchanged; custom or animated anchors retain their source-pixel values. Source timecode is+not realigned and other bin items are not relinked. Attached proxies are detached because they+belong to the old source. Replacement and proxy detachment are one undoable edit; undo/redo and+save/reopen retain the association. Missing or unreadable replacement files leave the project intact.+The picker accepts video, audio and still images; cancellation does nothing. Generated items,+sequences and multi-selection do not enable the Clip-menu action.+ `match` selects which properties must agree. Each defaults as shown: - `fileName` (true): the file name stem;