diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index d6a4a862..0e223e72 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -153,6 +153,154 @@ fn rasterize(s: &mut Session, _p: &Value) -> Result { Ok(json!({"rasterized": true})) } +// ------------------------------------------------------------------ file-based footage + +/// Load frames from `path`: a single image (1 frame), or a directory of images (sorted = an image +/// sequence). Each becomes a full raster frame in the document's format. +fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result> { + let p = std::path::Path::new(path); + let is_img = |f: &std::path::Path| { + matches!( + f.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), + Some("png" | "jpg" | "jpeg" | "tif" | "tiff" | "webp" | "bmp" | "gif" | "tga" | "exr" | "pcraft" | "psd") + ) + }; + let files: Vec = if p.is_dir() { + let mut v: Vec<_> = std::fs::read_dir(p) + .map_err(|e| EngineError::Other(format!("read dir `{path}`: {e}")))? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|f| is_img(f)) + .collect(); + v.sort(); + v + } else { + vec![p.to_path_buf()] + }; + if files.is_empty() { + return Err(EngineError::Other(format!("no images in `{path}`"))); + } + let mut frames = Vec::with_capacity(files.len()); + for f in &files { + let bytes = std::fs::read(f).map_err(|e| EngineError::Other(format!("read `{}`: {e}", f.display())))?; + let name = f.to_string_lossy(); + let doc = photocraft_io::import(&name, &bytes).map_err(|e| EngineError::Other(format!("`{}`: {e}", f.display())))?.document; + frames.push(crate::file_cmds::flattened(&doc, fmt)); + } + Ok(frames) +} + +fn new_from_file(s: &mut Session, p: &Value) -> Result { + let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.newVideoLayerFromFile".into(), msg: "need `path`".into() })?.to_string(); + let fps = p.get("fps").and_then(Value::as_f64).unwrap_or(30.0) as f32; + let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); + let frames = load_frames(&path, fmt)?; + let n = frames.len(); + let id = s.edit("New Video Layer from File", |doc, active| { + if doc.timeline.is_none() { + doc.timeline = Some(Timeline::new(n, fps)); + } else if let Some(t) = &mut doc.timeline { + t.duration = t.duration.max(n); + t.clamp(); + } + let name = doc.next_layer_name(std::path::Path::new(&path).file_stem().and_then(|s| s.to_str()).unwrap_or("Video")); + let mut l = Layer::raster(name, fmt); + let mut v = VideoData::new(frames, fps); + v.source = photocraft_doc::VideoSource::File { path: path.clone() }; + l.video = Some(v); + let id = doc.insert_above(*active, l); + *active = Some(id); + sync(doc); + Ok(id) + })?; + Ok(json!({"layer": id.0, "frames": n})) +} + +fn replace_footage(s: &mut Session, p: &Value) -> Result { + let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.replaceFootage".into(), msg: "need `path`".into() })?.to_string(); + let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); + let frames = load_frames(&path, fmt)?; + edit_video(s, "Replace Footage", move |v, _| { + v.frames = frames; + v.source = photocraft_doc::VideoSource::File { path: path.clone() }; + Ok(()) + }) +} + +fn interpret_footage(s: &mut Session, p: &Value) -> Result { + let fps = p.get("fps").and_then(Value::as_f64); + edit_video(s, "Interpret Footage", move |v, _| { + if let Some(f) = fps { + v.fps = f as f32; + } + Ok(()) + }) +} + +fn reload_frame(s: &mut Session, _p: &Value) -> Result { + // Reload the whole stack from its file source (what a frame reload needs for a sequence). + let (src, fmt) = { + let st = s.active().ok_or(EngineError::NoDocument)?; + let id = st.active_layer.ok_or(EngineError::NoDocument)?; + let v = st.doc.layer(id).and_then(|l| l.video.as_ref()).ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.reloadFrame".into(), msg: "not a video layer".into() })?; + (v.source.clone(), st.doc.pixel_format()) + }; + let photocraft_doc::VideoSource::File { path } = src else { + return Err(EngineError::BadParams { cmd: "layer.videoLayers.reloadFrame".into(), msg: "this layer has no file source to reload".into() }); + }; + let frames = load_frames(&path, fmt)?; + edit_video(s, "Reload Frame", move |v, _| { + v.frames = frames; + Ok(()) + }) +} + +/// Import › Video Frames to Layers: load frames from a file/sequence and add each as its own layer. +fn frames_to_layers(s: &mut Session, p: &Value) -> Result { + let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { cmd: "file.import.videoFramesToLayers".into(), msg: "need `path`".into() })?.to_string(); + let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); + let frames = load_frames(&path, fmt)?; + let n = frames.len(); + s.edit("Video Frames to Layers", |doc, active| { + let mut above = *active; + for (i, f) in frames.into_iter().enumerate() { + let mut l = Layer::raster(format!("Frame {}", i + 1), fmt); + if let LayerContent::Raster(surf) = &mut l.content { + *surf = f; + } + above = Some(doc.insert_above(above, l)); + } + *active = above; + Ok(()) + })?; + Ok(json!({"layers": n})) +} + +/// Export › Render Video: composite the whole document at each timeline frame and write an image +/// sequence (`{dir}/{stem}_NNNN.{format}`). Clean-room; no proprietary codec. +fn render_video(s: &mut Session, p: &Value) -> Result { + let dir = p.get("dir").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { cmd: "file.export.renderVideo".into(), msg: "need `dir`".into() })?; + let format = p.get("format").and_then(Value::as_str).unwrap_or("png"); + std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("mkdir `{dir}`: {e}")))?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let base = (*st.doc).clone(); + let frames = base.timeline.as_ref().map_or(1, |t| t.duration).max(1); + let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string(); + let opts = photocraft_io::ExportOptions::default(); + let mut files = Vec::new(); + for f in 0..frames { + let mut doc = base.clone(); + if let Some(t) = &mut doc.timeline { + t.current = f; + } + sync(&mut doc); + let path = format!("{dir}/{stem}_{f:04}.{format}"); + let bytes = photocraft_io::export(&doc, format, &opts).map(|r| r.bytes).map_err(|e| EngineError::Other(format!("render frame {f}: {e}")))?; + std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("write `{path}`: {e}")))?; + files.push(path); + } + Ok(json!({"frames": files.len(), "dir": dir})) +} + macro_rules! spec { ($id:expr, $label:expr, $menu:expr, $en:expr, $run:expr) => { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: None, params: "{} → {}", enabled: $en, journal: true, run: $run } @@ -171,6 +319,12 @@ pub fn specs() -> Vec { spec!("layer.videoLayers.showAlteredVideo", "Show Altered Video", vl, active_video, |s, p| show_altered(s, p)), spec!("layer.videoLayers.rasterize", "Rasterize", vl, active_video, |s, p| rasterize(s, p)), spec!("layer.rasterize.video", "Video", &["Layer", "Rasterize"], active_video, |s, p| rasterize(s, p)), + spec!("layer.videoLayers.newVideoLayerFromFile", "New Video Layer from File…", vl, has_doc, |s, p| new_from_file(s, p)), + spec!("layer.videoLayers.replaceFootage", "Replace Footage…", vl, active_video, |s, p| replace_footage(s, p)), + spec!("layer.videoLayers.interpretFootage", "Interpret Footage…", vl, active_video, |s, p| interpret_footage(s, p)), + spec!("layer.videoLayers.reloadFrame", "Reload Frame", vl, active_video, |s, p| reload_frame(s, p)), + spec!("file.import.videoFramesToLayers", "Video Frames to Layers…", &["File", "Import"], has_doc, |s, p| frames_to_layers(s, p)), + spec!("file.export.renderVideo", "Render Video…", &["File", "Export"], has_doc, |s, p| render_video(s, p)), ] } diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index 77577db7..44746d51 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -55,3 +55,47 @@ fn scrub_keeps_content_a_raster() { s.execute("timeline.setFrame", json!({"frame": 1})).unwrap(); assert!(matches!(s.active().unwrap().doc.layer(id).unwrap().content, LayerContent::Raster(_))); } + +fn write_png(path: &str, w: u32, h: u32, color: &str) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": color})).unwrap(); + let bytes = photocraft_io::export(&s.active().unwrap().doc, "png", &photocraft_io::ExportOptions::default()).unwrap().bytes; + std::fs::write(path, bytes).unwrap(); +} + +fn tmpdir(name: &str) -> String { + let d = std::env::temp_dir().join(format!("pc-video-{}-{name}", std::process::id())); + let _ = std::fs::create_dir_all(&d); + d.to_string_lossy().into_owned() +} + +#[test] +fn new_video_layer_from_image_sequence() { + let dir = tmpdir("seq"); + write_png(&format!("{dir}/f01.png"), 16, 12, "#ff0000"); + write_png(&format!("{dir}/f02.png"), 16, 12, "#00ff00"); + write_png(&format!("{dir}/f03.png"), 16, 12, "#0000ff"); + let mut s = session(); + let r = s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": dir})).unwrap(); + assert_eq!(r["frames"], 3); + let id = LayerId(r["layer"].as_u64().unwrap()); + assert_eq!(frames(&s, id), 3); + assert!(s.active().unwrap().doc.timeline.as_ref().unwrap().duration >= 3); +} + +#[test] +fn frames_to_layers_and_render_video() { + let dir = tmpdir("ftl"); + write_png(&format!("{dir}/a.png"), 10, 8, "#112233"); + write_png(&format!("{dir}/b.png"), 10, 8, "#445566"); + let mut s = session(); + let r = s.execute("file.import.videoFramesToLayers", json!({"path": dir})).unwrap(); + assert_eq!(r["layers"], 2); + // Build a 2-frame video layer and render. + let v = s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": dir})).unwrap(); + assert_eq!(v["frames"], 2); + let out = tmpdir("render"); + let rr = s.execute("file.export.renderVideo", json!({"dir": out, "format": "png"})).unwrap(); + assert!(rr["frames"].as_u64().unwrap() >= 2); + assert!(std::path::Path::new(&out).exists()); +} diff --git a/crates/ui-egui/src/parity.rs b/crates/ui-egui/src/parity.rs index 9acacfb5..2b2ef94f 100644 --- a/crates/ui-egui/src/parity.rs +++ b/crates/ui-egui/src/parity.rs @@ -7,7 +7,7 @@ use crate::menu_catalog::CATALOG; use crate::menus::is_live; /// Minimum number of live catalog items. Raise it when parity grows; never lower it. -pub const FLOOR: usize = 613; +pub const FLOOR: usize = 619; /// One top-level menu's coverage. #[derive(Clone, Debug, serde::Serialize)] diff --git a/docs/parity.md b/docs/parity.md index 40e42ddd..9da01277 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,14 +2,14 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand. -**618 of 625 menu items live (98.9%).** +**624 of 625 menu items live (99.8%).** | Menu | Live | Total | % | |---|---:|---:|---:| -| File | 47 | 50 | 94% | +| File | 49 | 50 | 98% | | Edit | 75 | 75 | 100% | | Image | 61 | 61 | 100% | -| Layer | 156 | 160 | 98% | +| Layer | 160 | 160 | 100% | | Type | 44 | 44 | 100% | | Select | 25 | 25 | 100% | | Filter | 75 | 75 | 100% | @@ -18,15 +18,6 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Pho ## Not yet implemented -### File (3) +### File (1) -- Export › Render Video… (`file.export.renderVideo`) -- Import › Video Frames to Layers… (`file.import.videoFramesToLayers`) - Import › WIA Support… (`file.import.wiaSupport`) - -### Layer (4) - -- Video Layers › New Video Layer from File… (`layer.videoLayers.newVideoLayerFromFile`) -- Video Layers › Replace Footage… (`layer.videoLayers.replaceFootage`) -- Video Layers › Interpret Footage… (`layer.videoLayers.interpretFootage`) -- Video Layers › Reload Frame (`layer.videoLayers.reloadFrame`)