Video: file footage, Frames to Layers, Render Video (parity 624/625, 99.8%)

layer.videoLayers.{newVideoLayerFromFile,replaceFootage,interpretFootage,
reloadFrame} load frames from a single image or an image-sequence folder;
file.import.videoFramesToLayers adds each frame as its own layer;
file.export.renderVideo composites the whole document at every timeline
frame and writes an image sequence (clean-room, no proprietary codec).
6 tests. Only Windows-only WIA Support remains. Parity 618 -> 624 (99.8%);
FLOOR 619.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 02:50:44 -04:00
parent 9cfbe11bf1
commit 53b6f2bafb
4 changed files with 203 additions and 14 deletions

View File

@ -153,6 +153,154 @@ fn rasterize(s: &mut Session, _p: &Value) -> Result<Value> {
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<Vec<Surface>> {
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<std::path::PathBuf> = 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<Value> {
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<Value> {
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<Value> {
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<Value> {
// 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<Value> {
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<Value> {
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<CommandSpec> {
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)),
]
}

View File

@ -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());
}

View File

@ -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)]

View File

@ -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`)