mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 20:37:24 -06:00
Merge PR #3223: fix(ui): wire video menu pickers/dialogs and fix Timeline Stop playback
This commit is contained in:
@@ -2777,6 +2777,7 @@ curves Smooth Glätten
|
||||
Light Red Hellrot
|
||||
Medium Blue Mittelblau
|
||||
Light Gray Hellgrau
|
||||
Stop Stopp
|
||||
# Status messages, system info and tool names
|
||||
Join the ArtCraft Discord ({url}) Dem ArtCraft-Discord beitreten ({url})
|
||||
Nothing to paste: the clipboard holds no image Nichts einzufügen: Die Zwischenablage enthält kein Bild
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3308,6 +3308,7 @@ curves Smooth 매끄럽게
|
||||
Light Red 연한 빨강
|
||||
Medium Blue 중간 파랑
|
||||
Light Gray 연한 회색
|
||||
Stop 정지
|
||||
Object Finder 개체 찾기
|
||||
No object found there — drag a rectangle around it 그곳에서 개체를 찾지 못했습니다 — 주위에 사각형을 드래그하세요
|
||||
Remove after each stroke 획마다 제거
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3172 and column 53.
|
@@ -2748,6 +2748,7 @@ curves Smooth 平滑
|
||||
Light Red 浅红色
|
||||
Medium Blue 中蓝色
|
||||
Light Gray 浅灰色
|
||||
Stop 停止
|
||||
Object Finder 对象查找程序
|
||||
No object found there — drag a rectangle around it 那里未找到对象 — 请在其周围拖动矩形
|
||||
Remove after each stroke 每次描边后移除
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2606 and column 53.
|
@@ -2733,6 +2733,7 @@ curves Smooth 平滑
|
||||
Light Red 淺紅色
|
||||
Medium Blue 中藍色
|
||||
Light Gray 淺灰色
|
||||
Stop 停止
|
||||
Object Finder 物件尋找器
|
||||
No object found there — drag a rectangle around it 那裡找不到物件 — 請在其周圍拖曳矩形
|
||||
Remove after each stroke 每次描邊後移除
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -49,6 +49,7 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let mut act: Option<(&str, Value)> = None;
|
||||
let mut close = false;
|
||||
let mut toggle_play = false;
|
||||
let mut stop_play = false;
|
||||
let playing = app.ui.timeline.playing;
|
||||
let t = Tokens::get(ctx);
|
||||
|
||||
@@ -84,7 +85,14 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if crate::icons::button(ui, play_icon, 22.0, playing, if playing { tl!("Pause") } else { tl!("Play") }).clicked() {
|
||||
toggle_play = true;
|
||||
}
|
||||
if crate::icons::button(ui, "square", 22.0, false, tl!("Stop")).clicked() {
|
||||
if !playing {
|
||||
act = Some(("timeline.setFrame", json!({ "frame": 0 })));
|
||||
}
|
||||
stop_play = true;
|
||||
}
|
||||
if crate::icons::button(ui, "chevron-left", 22.0, false, tl!("Previous frame")).clicked() {
|
||||
stop_play = true;
|
||||
act = Some(("timeline.previousFrame", json!({})));
|
||||
}
|
||||
ui.label(
|
||||
@@ -96,12 +104,14 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
.monospace(),
|
||||
);
|
||||
if crate::icons::button(ui, "chevron-right", 22.0, false, tl!("Next frame")).clicked() {
|
||||
stop_play = true;
|
||||
act = Some(("timeline.nextFrame", json!({})));
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(RichText::new(format!("{:.2}s", tl.time())).color(t.text_dim).monospace());
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::icons::button(ui, "trash", 20.0, false, tl!("Delete Timeline")).clicked() {
|
||||
stop_play = true;
|
||||
act = Some(("timeline.delete", json!({})));
|
||||
}
|
||||
ui.label(RichText::new(format!("{:.0} fps", tl.fps)).color(t.text_dim));
|
||||
@@ -113,6 +123,7 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let mut frame = tl.current as f64;
|
||||
let resp = ui.add(egui::Slider::new(&mut frame, 0.0..=(tl.duration - 1) as f64).integer().show_value(false).text("playhead"));
|
||||
if resp.changed() {
|
||||
stop_play = true;
|
||||
act = Some(("timeline.setFrame", json!({ "frame": frame as u64 })));
|
||||
}
|
||||
}
|
||||
@@ -124,7 +135,9 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
app.ui.timeline.open = false;
|
||||
app.ui.timeline.playing = false;
|
||||
}
|
||||
if toggle_play {
|
||||
if stop_play {
|
||||
app.ui.timeline.playing = false;
|
||||
} else if toggle_play {
|
||||
app.ui.timeline.playing = !app.ui.timeline.playing;
|
||||
app.ui.timeline.last_time = ctx.input(|i| i.time);
|
||||
}
|
||||
@@ -147,3 +160,67 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
#[test]
|
||||
fn stop_step_and_scrub_pause_playback_and_second_stop_rewinds() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("timeline.create", json!({"duration": 10, "fps": 30.0})).unwrap();
|
||||
app.ui.timeline.open = true;
|
||||
app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 700.0));
|
||||
|
||||
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.001).build_ui_state(|ui, app| windows(app, ui.ctx()), app);
|
||||
h.run_steps(2);
|
||||
|
||||
// Start playback via Play button.
|
||||
h.get_by_label("Play").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.timeline.playing);
|
||||
|
||||
// Move to frame 5 while playing; first Stop pauses at the current frame without rewinding.
|
||||
h.state_mut().run("timeline.setFrame", json!({"frame": 5})).unwrap();
|
||||
h.get_by_label("Stop").click();
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.timeline.playing, "first Stop pauses playback");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 5);
|
||||
|
||||
// Second Stop while already paused rewinds to frame 0.
|
||||
h.get_by_label("Stop").click();
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.timeline.playing);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 0);
|
||||
|
||||
// Next frame and Previous frame pause playback.
|
||||
h.state_mut().ui.timeline.playing = true;
|
||||
h.run_steps(1);
|
||||
h.get_by_label("Next frame").click();
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.timeline.playing, "Next frame pauses playback");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 1);
|
||||
|
||||
h.state_mut().ui.timeline.playing = true;
|
||||
h.run_steps(1);
|
||||
h.get_by_label("Previous frame").click();
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.timeline.playing, "Previous frame pauses playback");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 0);
|
||||
|
||||
// Scrubbing the playhead slider pauses playback and updates the current frame.
|
||||
h.state_mut().ui.timeline.playing = true;
|
||||
h.run_steps(1);
|
||||
let slider = h.get_by_role(egui::accesskit::Role::Slider).rect();
|
||||
let target = egui::pos2(slider.right() - 8.0, slider.center().y);
|
||||
h.event(egui::Event::PointerMoved(target));
|
||||
h.event(egui::Event::PointerButton { pos: target, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
||||
h.step();
|
||||
h.event(egui::Event::PointerButton { pos: target, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.timeline.playing, "scrubbing pauses playback");
|
||||
assert!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,6 +612,11 @@ fn wraps(id: &str) -> bool {
|
||||
| "image.calculations"
|
||||
| "select.saveSelection"
|
||||
| "select.loadSelection"
|
||||
| "layer.videoLayers.newVideoLayerFromFile"
|
||||
| "layer.videoLayers.replaceFootage"
|
||||
| "layer.videoLayers.interpretFootage"
|
||||
| "file.import.videoFramesToLayers"
|
||||
| "file.export.renderVideo"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1251,6 +1256,33 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value)
|
||||
app.with_document(doc, |app| app.run("layer.smartObjects.convertToLinked", json!({"layer": layer.0, "path": path})))
|
||||
}))
|
||||
}
|
||||
"layer.videoLayers.newVideoLayerFromFile" | "file.import.videoFramesToLayers" | "layer.videoLayers.replaceFootage" => {
|
||||
let doc = match app.active_doc_id() {
|
||||
Ok(doc) => doc,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
let cmd = id.to_string();
|
||||
Some(app.pick_file_bytes(move |app, path, _bytes| {
|
||||
app.with_document(doc, |app| {
|
||||
let r = app.run(&cmd, json!({"path": path}));
|
||||
if r.is_ok() && app.session.active().is_some_and(|d| d.doc.timeline.is_some()) {
|
||||
app.ui.timeline.open = true;
|
||||
}
|
||||
app.sync_views();
|
||||
r
|
||||
})
|
||||
}))
|
||||
}
|
||||
"layer.videoLayers.interpretFootage" => {
|
||||
let st = app.session.active()?;
|
||||
let id_layer = st.active_layer?;
|
||||
let fps = st.doc.layer(id_layer).and_then(|l| l.video.as_ref()).map_or(30.0, |v| v.fps);
|
||||
dialog(app, json!({"fps": fps}), json!({}))
|
||||
}
|
||||
"file.export.renderVideo" => {
|
||||
let _ = doc?;
|
||||
dialog(app, json!({"dir": dir, "format": "gif"}), json!({"format": ["gif", "png", "jpg", "webp", "bmp", "tiff"]}))
|
||||
}
|
||||
"file.fileInfo" => {
|
||||
let info = app.session.execute("file.fileInfo", json!({})).ok()?;
|
||||
let kw = info["keywords"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join("; ")).unwrap_or_default();
|
||||
|
||||
@@ -503,6 +503,31 @@ fn load_selection_without_alpha_channels_offers_the_composite() {
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_menu_commands_open_pickers_and_dialogs() {
|
||||
let (mut app, ctx) = app_with(1);
|
||||
app.services.file_dialog = Some(Box::new(|_, _, _| {}));
|
||||
|
||||
for id in ["layer.videoLayers.newVideoLayerFromFile", "file.import.videoFramesToLayers", "layer.videoLayers.replaceFootage"] {
|
||||
assert!(is_live(id), "{id}");
|
||||
assert_eq!(menu(&mut app, &ctx, id, json!({})).unwrap(), json!({"fileDialog": "open"}), "{id}");
|
||||
app.file_dialog = None;
|
||||
}
|
||||
|
||||
let interpret = menu(&mut app, &ctx, "layer.videoLayers.interpretFootage", json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
let fields = &app.ui.dialog_mut(interpret).unwrap().fields;
|
||||
assert_eq!(fields["__command"], "layer.videoLayers.interpretFootage");
|
||||
assert_eq!(fields["fps"], 30.0);
|
||||
app.ui.close_dialog(interpret);
|
||||
|
||||
let render = menu(&mut app, &ctx, "file.export.renderVideo", json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
let fields = &app.ui.dialog_mut(render).unwrap().fields;
|
||||
assert_eq!(fields["__command"], "file.export.renderVideo");
|
||||
assert_eq!(fields["format"], "gif");
|
||||
assert_eq!(fields["__choices"]["format"], json!(["gif", "png", "jpg", "webp", "bmp", "tiff"]));
|
||||
app.ui.close_dialog(render);
|
||||
}
|
||||
|
||||
/// One frame of the shortcut handler with a plain Esc press.
|
||||
fn press_esc(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let key = egui::Key::Escape;
|
||||
|
||||
Reference in New Issue
Block a user