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:
parent
9cfbe11bf1
commit
53b6f2bafb
|
|
@ -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)),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
Loading…
Reference in New Issue