collective

Browse / github.com/storytold/artcraft / patch

artcraft-native: Frame Extract, pull PNG stills from a local video

artcraft-native: Frame Extract, pull PNG stills from a local video ## Demo ![demo](https://raw.githubusercontent.com/mvanhorn/pr-demo-assets/main/artcraft/native-frame-extract/demo.gif) <video src="https://raw.githubusercontent.com/mvanhorn/pr-demo-assets/main/artcraft/native-frame-extract/demo.mp4" controls></video> | Before | After | | --- | --- | | <img src="https://raw.githubusercontent.com/mvanhorn/pr-demo-assets/main/artcraft/native-frame-extract/before.png" width="420" alt="before"> | <img src="https://raw.githubusercontent.com/mvanhorn/pr-demo-assets/main/artcraft/native-frame-extract/after.png" width="420" alt="after"> | ## Summary Studio › Fram

Status
open
Score
0.00
Adopters
0 other operators
Author
mvanhorn (imported from pull request #2105, open)
Base
28a39a0de4a2fc2eccc237df2046c24245bd12ab
Size
+690 −10 in 5 files
Review
pending
Shared
2026-10-28
Id
ec239dcaa272f801673b099d

How it went

No other operator has reported using it yet.

Diff

raw · artcraft-native/README.md artcraft-native/src/app.rs artcraft-native/src/pages/frame_extract.rs artcraft-native/src/pages/mod.rs artcraft-native/src/shell.rs

diff --git a/artcraft-native/README.md b/artcraft-native/README.md--- a/artcraft-native/README.md+++ b/artcraft-native/README.md@@ -7,8 +7,9 @@ The look follows the brutalist webapp (`artcraft-services/frontend/apps/artcraft means near-black surfaces with white/15 hairlines and 3 px corners on controls. Type is Archivo for display, Inter for body text and Geist Mono for labels. The brand blue marks the primary actions. -Create Image, Create Video and Create Audio are fully built. The other tools show a "coming soon"-page that links to the same tool in the web app.+Create Image, Create Video, Create Audio and Frame Extract are fully built. The other tools show+a "coming soon" page that links to the same tool in the web app. Frame Extract reads a video on+this computer and writes stills locally; it does not need an account.  ## Running @@ -35,7 +36,7 @@ The workspace's default member is the Tauri app, so pass `-p artcraft_native`. | `backend::audio`   | One clip at a time: reference previews, feed tracks (pause, seek, volume), waveforms | | `models`           | Model catalog from OmniGen listings, plus names, families and limits           | | `prompt_box`       | The shared prompt box: editor, reference deck, keyframes, pickers, model selector |-| `pages`            | Create Image, Video and Audio, Home, Library, and the shared page shell        |+| `pages`            | Create Image, Video, Audio and Frame Extract; Home, Library, shared page shell | | `feed`             | Generation feed: running, failed and finished jobs, library paging, grid and list | | `overlays`         | Lightbox, library picker, sign-in and settings dialogs                         | | `shell`            | Custom title bar, sidebar flush to the window edge, content panel header       |@@ -93,8 +94,10 @@ Feed and lightbox: ## Diagnostics  Set `ARTCRAFT_SCREENSHOT=<file.png>` to save a screenshot once the window has settled.-`ARTCRAFT_PAGE` (`home`, `create-image`, `create-video`, `create-audio` or `library`) picks the page, and-`ARTCRAFT_SCREENSHOT_EXIT=1` closes the app afterwards.+`ARTCRAFT_PAGE` (`home`, `create-image`, `create-video`, `create-audio`, `library` or+`frame-extractor`) picks the page, and `ARTCRAFT_SCREENSHOT_EXIT=1` closes the app afterwards.+`ARTCRAFT_OPEN_VIDEO=<path>` opens a local file on Frame Extract;+`ARTCRAFT_EXTRACT=1` then pulls stills once the clip has been read.  ## Tests @@ -102,9 +105,10 @@ Set `ARTCRAFT_SCREENSHOT=<file.png>` to save a screenshot once the window has se cargo test -p artcraft_native ``` -There are unit tests for the catalog, feed store, wire decoding and backend helpers. Headless UI-tests (`egui_kittest`) drive the prompt box (generate gating, Enter to generate, the pickers, `@`-mentions, clear-all), the Characters dialog and the audio toolbar (toggles, Beat & Key, Tuning).+There are unit tests for the catalog, feed store, wire decoding, backend helpers and Frame Extract+(timestamp planning, file names, still export). Headless UI tests (`egui_kittest`) drive the prompt+box (generate gating, Enter to generate, the pickers, `@` mentions, clear-all), the Characters+dialog, the audio toolbar (toggles, Beat & Key, Tuning) and Frame Extract's empty and loaded pages.  ## Fonts and brand assets diff --git a/artcraft-native/src/app.rs b/artcraft-native/src/app.rs--- a/artcraft-native/src/app.rs+++ b/artcraft-native/src/app.rs@@ -24,6 +24,7 @@ use crate::pages::common::{self, AppRequest, Env, FeedKey}; use crate::pages::create_audio::{AudioSettings, CreateAudioPage}; use crate::pages::create_image::{CreateImagePage, ImageSettings}; use crate::pages::create_video::{CreateVideoPage, VideoSettings};+use crate::pages::frame_extract::FrameExtractPage; use crate::pages::other; use crate::prompt_box::refs::{RefKind, RefMedia, References}; use crate::shell::{self, Account, FeedToggles, Page, ShellAction};@@ -84,6 +85,7 @@ pub struct ArtcraftApp {   image: CreateImagePage,   video: CreateVideoPage,   audio: CreateAudioPage,+  frame_extract: FrameExtractPage,   library: FeedStore,   ratios: RatioCache,   /// Prompt texts by prompt token (list view, lightbox).@@ -125,7 +127,7 @@ impl ArtcraftApp {       backend.load_models(modality);     }     backend.refresh_session();-    Self { cache, player, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, autoplay: saved.autoplay, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), audio: CreateAudioPage::new(saved.audio), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, characters_modal: None, characters: Vec::new(), pending_characters: Vec::new(), settings_open: false, preview: None, requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend }+    Self { cache, player, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, autoplay: saved.autoplay, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), audio: CreateAudioPage::new(saved.audio), frame_extract: FrameExtractPage::new(), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, characters_modal: None, characters: Vec::new(), pending_characters: Vec::new(), settings_open: false, preview: None, requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend }   }    // --- Events from the backend -------------------------------------------------------------@@ -597,6 +599,7 @@ impl ArtcraftApp {             Page::CreateVideo => self.video.ui(ui, &mut env),             Page::CreateAudio => self.audio.ui(ui, &mut env),             Page::Library => other::library(ui, &mut env, &mut self.library),+            Page::FrameExtractor => self.frame_extract.ui(ui),             page => other::coming_soon(ui, page),           }         });diff --git a/artcraft-native/src/pages/frame_extract.rs b/artcraft-native/src/pages/frame_extract.rsnew file mode 100644--- /dev/null+++ b/artcraft-native/src/pages/frame_extract.rs@@ -0,0 +1,663 @@+//! Frame Extract: pull stills from a local video. Works offline; FFmpeg on the PATH does the+//! decoding (the same tools in-app playback already uses).++use std::path::{Path, PathBuf};+use std::process::Command;+use std::sync::mpsc::{Receiver, TryRecvError, channel};+use std::thread;++use egui::{Align2, Color32, ColorImage, FontId, Rect, Sense, TextureHandle, TextureOptions, Ui, pos2, vec2};+use log::warn;++use crate::backend::video::{self, VideoPlayer};+use crate::theme;+use crate::ui::icons::{self, Icon};+use crate::ui::widgets;++pub const MIN_COUNT: u32 = 1;+pub const MAX_COUNT: u32 = 50;+pub const DEFAULT_COUNT: u32 = 10;+pub const MIN_DISTANCE_MS: u32 = 1;+pub const MAX_DISTANCE_MS: u32 = 10_000;+pub const DEFAULT_DISTANCE_MS: u32 = 100;++const VIDEO_EXTS: &[&str] = &["mp4", "mov", "webm", "mkv", "m4v", "avi"];++/// Size, rate and length of a local clip, from `ffprobe`.+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct ClipInfo {+  pub width: u32,+  pub height: u32,+  pub fps: f64,+  pub duration: f64,+}++/// One still written to disk (and shown in the grid once its texture is ready).+struct ExtractedFrame {+  timestamp: f64,+  path: PathBuf,+  texture: Option<TextureHandle>,+}++enum ExtractEvent {+  Frame { timestamp: f64, path: PathBuf },+  Failed(String),+  Done,+}++struct OpenClip {+  path: PathBuf,+  info: Option<ClipInfo>,+  probe: Option<Receiver<Result<ClipInfo, String>>>,+  player: Option<VideoPlayer>,+  probe_error: Option<String>,+}++/// The Studio › Frame Extract page.+pub struct FrameExtractPage {+  count: u32,+  distance_ms: u32,+  start: f64,+  clip: Option<OpenClip>,+  frames: Vec<ExtractedFrame>,+  extracting: bool,+  extract_rx: Option<Receiver<ExtractEvent>>,+  notice: Option<String>,+}++impl Default for FrameExtractPage {+  fn default() -> Self {+    Self { count: DEFAULT_COUNT, distance_ms: DEFAULT_DISTANCE_MS, start: 0.0, clip: None, frames: Vec::new(), extracting: false, extract_rx: None, notice: None }+  }+}++impl FrameExtractPage {+  pub fn new() -> Self {+    let mut page = Self::default();+    if let Some(path) = std::env::var_os("ARTCRAFT_OPEN_VIDEO").map(PathBuf::from) {+      if path.is_file() {+        page.open(path);+      }+    }+    page+  }++  pub fn ui(&mut self, ui: &mut Ui) {+    self.poll();+    self.load_textures(ui.ctx());+    if self.extracting || self.clip.as_ref().is_some_and(|c| c.probe.is_some()) {+      ui.ctx().request_repaint();+    }+    if let Some(path) = dropped_video(ui) {+      self.open(path);+    }+    let area = ui.max_rect();+    match self.clip.as_ref() {+      None => self.empty(ui, area),+      Some(_) => {+        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area));+        egui::ScrollArea::vertical().id_salt("frame-extract-scroll").auto_shrink([false, false]).show(&mut child, |ui| {+          ui.set_width(ui.available_width());+          self.loaded(ui);+        });+      },+    }+  }++  fn empty(&mut self, ui: &mut Ui, area: Rect) {+    let width = (area.width() - 64.0).min(768.0);+    let section = Rect::from_center_size(area.center(), vec2(width, 260.0));+    ui.painter().rect(section, 0.0, Color32::from_rgba_unmultiplied(242, 241, 238, 4), egui::Stroke::new(1.0, Color32::from_rgba_unmultiplied(242, 241, 238, 51)), egui::StrokeKind::Inside);+    widgets::corner_marks(ui.painter(), section);+    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(section.shrink2(vec2(40.0, 36.0))));+    child.label(widgets::heading("Frame Extract", 44.0));+    child.add_space(10.0);+    child.label(egui::RichText::new("Open a video on this computer and pull stills at a start time, count and spacing you choose. Nothing is uploaded.").size(15.0).color(theme::MUTED));+    child.add_space(18.0);+    if widgets::button(&mut child, Some(Icon::Upload), "Select Video", widgets::Kind::Primary, 0.0, 40.0).clicked() {+      self.pick();+    }+    if !video::ffmpeg_available() {+      child.add_space(12.0);+      child.label(egui::RichText::new("FFmpeg is not on the PATH, so this page cannot read videos yet. Install ffmpeg and ffprobe, then reopen the app.").size(13.0).color(theme::BAD));+    }+  }++  fn loaded(&mut self, ui: &mut Ui) {+    let width = (ui.available_width() - 48.0).min(960.0);+    ui.add_space(16.0);+    ui.horizontal(|ui| {+      ui.add_space(((ui.available_width() - width).max(0.0)) / 2.0);+      ui.vertical(|ui| {+        ui.set_width(width);+        self.preview(ui, width);+        if self.clip.is_none() {+          return;+        }+        ui.add_space(16.0);+        ui.vertical_centered(|ui| {+          let ready = self.clip.as_ref().is_some_and(|c| c.info.is_some()) && video::ffmpeg_available() && !self.extracting;+          let label = if self.extracting { "Extracting\u{2026}" } else { "Extract Frames" };+          ui.add_enabled_ui(ready, |ui| {+            if widgets::button(ui, Some(Icon::Sparkles), label, widgets::Kind::Primary, 0.0, 44.0).clicked() {+              self.start_extract();+            }+          });+        });+        if let Some(notice) = &self.notice {+          ui.add_space(10.0);+          ui.label(egui::RichText::new(notice).size(13.0).color(theme::BAD));+        }+        if !self.frames.is_empty() {+          ui.add_space(16.0);+          self.grid(ui, width);+        }+        ui.add_space(16.0);+        self.settings(ui);+        ui.add_space(32.0);+      });+    });+  }++  fn preview(&mut self, ui: &mut Ui, width: f32) {+    let height = (width * 9.0 / 16.0).clamp(180.0, 300.0);+    let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());+    ui.painter().rect_filled(rect, 0.0, Color32::BLACK);+    if let Some(clip) = self.clip.as_mut() {+      if let Some(player) = clip.player.as_mut() {+        player.ui(ui, rect, None);+      } else if let Some(err) = &clip.probe_error {+        ui.painter().text(rect.center(), Align2::CENTER_CENTER, err, FontId::new(13.0, egui::FontFamily::Proportional), theme::BAD);+      } else if clip.info.is_none() {+        icons::paint_spinner(ui, Rect::from_center_size(rect.center(), vec2(32.0, 32.0)), Color32::WHITE);+      }+    }+    let switch = Rect::from_min_size(pos2(rect.right() - 132.0, rect.top() + 12.0), vec2(120.0, 32.0));+    let mut overlay = ui.new_child(egui::UiBuilder::new().max_rect(switch));+    if widgets::button(&mut overlay, Some(Icon::RotateCw), "Switch Video", widgets::Kind::Danger, 120.0, 32.0).clicked() {+      self.clear_clip();+    }+  }++  fn settings(&mut self, ui: &mut Ui) {+    let duration = self.clip.as_ref().and_then(|c| c.info).map(|i| i.duration).unwrap_or(0.0);+    let file = self.clip.as_ref().map(|c| file_stem(&c.path)).unwrap_or_default();+    let info = self.clip.as_ref().and_then(|c| c.info);+    let probe_error = self.clip.as_ref().and_then(|c| c.probe_error.clone());+    ui.columns(2, |cols| {+      panel(&mut cols[0], |ui| {+        widgets::hud(ui, "Start Time", theme::FAINT);+        ui.add_space(6.0);+        ui.label(egui::RichText::new(format_time_precise(self.start)).size(28.0).family(theme::mono()).color(theme::ACCENT_INK));+        ui.add_space(10.0);+        if duration > 0.0 {+          ui.add(egui::Slider::new(&mut self.start, 0.0..=duration).show_value(false).trailing_fill(true));+          ui.add_space(6.0);+          ui.label(egui::RichText::new("Drag to choose where the first still is taken.").size(12.5).color(theme::MUTED));+        }+        ui.add_space(16.0);+        widgets::hud(ui, "Video Information", theme::FAINT);+        ui.add_space(8.0);+        kv(ui, "File", &file);+        match info {+          Some(info) => {+            kv(ui, "Duration", &format_time_precise(info.duration));+            kv(ui, "Resolution", &format!("{} \u{00d7} {}", info.width, info.height));+          },+          None => kv(ui, "Status", probe_error.as_deref().unwrap_or("Reading\u{2026}")),+        }+      });+      panel(&mut cols[1], |ui| {+        widgets::hud(ui, "Extraction Settings", theme::FAINT);+        ui.add_space(12.0);+        number_field(ui, "Number of Frames", &mut self.count, MIN_COUNT..=MAX_COUNT);+        ui.add_space(12.0);+        number_field(ui, "Distance (ms)", &mut self.distance_ms, MIN_DISTANCE_MS..=MAX_DISTANCE_MS);+        ui.add_space(10.0);+        let planned = timestamps(self.start, self.count, self.distance_ms, duration);+        ui.label(egui::RichText::new(format!("{} stills from {} to {}", planned.len(), format_time_precise(planned.first().copied().unwrap_or(self.start)), format_time_precise(planned.last().copied().unwrap_or(self.start)))).size(12.5).color(theme::MUTED));+      });+    });+  }++  fn grid(&mut self, ui: &mut Ui, width: f32) {+    ui.horizontal(|ui| {+      ui.label(egui::RichText::new("Extracted Frames").size(16.0).family(theme::medium()).color(theme::INK));+      ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+        ui.label(egui::RichText::new(format!("{} {}", self.frames.len(), if self.frames.len() == 1 { "Frame" } else { "Frames" })).size(12.0).family(theme::mono()).color(theme::MUTED));+        if widgets::button(ui, Some(Icon::Download), "Save All", widgets::Kind::Secondary, 0.0, 32.0).clicked() {+          self.save_all();+        }+      });+    });+    ui.add_space(10.0);+    let cols = if width >= 900.0 { 4 } else { 2 };+    let gap = 12.0;+    let cell_w = (width - gap * (cols as f32 - 1.0)) / cols as f32;+    let mut i = 0;+    while i < self.frames.len() {+      ui.horizontal(|ui| {+        ui.spacing_mut().item_spacing.x = gap;+        for _ in 0..cols {+          if i >= self.frames.len() {+            break;+          }+          self.frame_card(ui, i, cell_w);+          i += 1;+        }+      });+      ui.add_space(gap);+    }+  }++  fn frame_card(&mut self, ui: &mut Ui, index: usize, width: f32) {+    let thumb_h = width * 9.0 / 16.0;+    let height = thumb_h + 64.0;+    let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());+    ui.painter().rect(rect, 0.0, theme::WASH, theme::hairline(), egui::StrokeKind::Inside);+    let image = Rect::from_min_size(rect.min, vec2(width, thumb_h));+    ui.painter().rect_filled(image, 0.0, Color32::BLACK);+    if let Some(tex) = self.frames.get(index).and_then(|f| f.texture.as_ref()) {+      let fit = crate::overlays::lightbox::fit_inside(tex.size_vec2(), image.shrink(4.0).size());+      egui::Image::new(tex).paint_at(ui, Rect::from_center_size(image.center(), fit));+    }+    let ts = self.frames.get(index).map(|f| format_time_precise(f.timestamp)).unwrap_or_default();+    ui.painter().text(pos2(rect.left() + 10.0, image.bottom() + 8.0), Align2::LEFT_TOP, format!("#{}", index + 1), FontId::new(11.0, theme::mono()), theme::MUTED);+    ui.painter().text(pos2(rect.right() - 10.0, image.bottom() + 8.0), Align2::RIGHT_TOP, ts, FontId::new(12.0, theme::mono()), theme::INK);+    let btn = Rect::from_min_max(pos2(rect.left() + 10.0, rect.bottom() - 38.0), pos2(rect.right() - 10.0, rect.bottom() - 8.0));+    let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(btn));+    if widgets::button(&mut inner, Some(Icon::Download), "Save", widgets::Kind::Secondary, btn.width(), 28.0).clicked() {+      self.save_one(index);+    }+  }++  fn pick(&mut self) {+    let dialog = rfd::FileDialog::new().add_filter("Videos", VIDEO_EXTS);+    if let Some(path) = dialog.pick_file() {+      self.open(path);+    }+  }++  fn open(&mut self, path: PathBuf) {+    self.clear_clip();+    let (tx, rx) = channel();+    let probe_path = path.clone();+    thread::spawn(move || {+      let _ = tx.send(probe(&probe_path));+    });+    let player = video::ffmpeg_available().then(|| VideoPlayer::new(&path.to_string_lossy()));+    self.clip = Some(OpenClip { path, info: None, probe: Some(rx), player, probe_error: None });+  }++  fn clear_clip(&mut self) {+    self.clip = None;+    self.frames.clear();+    self.extracting = false;+    self.extract_rx = None;+    self.notice = None;+    self.start = 0.0;+  }++  fn poll(&mut self) {+    let mut info_ready = None;+    if let Some(clip) = self.clip.as_mut() {+      if let Some(rx) = clip.probe.as_mut() {+        match rx.try_recv() {+          Ok(Ok(info)) => {+            clip.info = Some(info);+            clip.probe = None;+            info_ready = Some(info);+          },+          Ok(Err(err)) => {+            clip.probe_error = Some(err);+            clip.probe = None;+          },+          Err(TryRecvError::Empty) => {},+          Err(TryRecvError::Disconnected) => clip.probe = None,+        }+      }+    }+    if let Some(info) = info_ready {+      self.start = self.start.clamp(0.0, info.duration);+      if std::env::var_os("ARTCRAFT_EXTRACT").is_some() && !self.extracting && self.frames.is_empty() {+        self.start_extract();+      }+    }+    let mut incoming = Vec::new();+    if let Some(rx) = self.extract_rx.as_mut() {+      loop {+        match rx.try_recv() {+          Ok(event) => incoming.push(event),+          Err(TryRecvError::Empty) => break,+          Err(TryRecvError::Disconnected) => {+            self.extracting = false;+            self.extract_rx = None;+            break;+          },+        }+      }+    }+    for event in incoming {+      match event {+        ExtractEvent::Frame { timestamp, path } => self.frames.push(ExtractedFrame { timestamp, path, texture: None }),+        ExtractEvent::Failed(message) => {+          self.notice = Some(message);+          self.extracting = false;+          self.extract_rx = None;+        },+        ExtractEvent::Done => {+          self.extracting = false;+          self.extract_rx = None;+        },+      }+    }+  }++  fn start_extract(&mut self) {+    let Some(clip) = self.clip.as_ref() else {+      return;+    };+    let Some(info) = clip.info else {+      return;+    };+    if self.extracting {+      return;+    }+    let times = timestamps(self.start, self.count, self.distance_ms, info.duration);+    if times.is_empty() {+      self.notice = Some("Nothing to extract at this start time.".into());+      return;+    }+    let dest = std::env::temp_dir().join("artcraft-native-frames").join(uuid::Uuid::new_v4().to_string());+    if let Err(err) = std::fs::create_dir_all(&dest) {+      self.notice = Some(format!("Couldn't create a temp folder: {err}"));+      return;+    }+    self.frames.clear();+    self.notice = None;+    self.extracting = true;+    let (tx, rx) = channel();+    self.extract_rx = Some(rx);+    let input = clip.path.clone();+    thread::spawn(move || {+      for (i, t) in times.iter().enumerate() {+        let out = dest.join(frame_file_name(i as u32, *t));+        match extract_frame(&input, *t, &out) {+          Ok(()) => {+            if tx.send(ExtractEvent::Frame { timestamp: *t, path: out }).is_err() {+              return;+            }+          },+          Err(err) => {+            let _ = tx.send(ExtractEvent::Failed(err));+            return;+          },+        }+      }+      let _ = tx.send(ExtractEvent::Done);+    });+  }++  fn save_one(&self, index: usize) {+    let Some(frame) = self.frames.get(index) else {+      return;+    };+    let name = frame.path.file_name().and_then(|n| n.to_str()).unwrap_or("frame.png");+    if let Some(dest) = rfd::FileDialog::new().set_file_name(name).save_file() {+      if let Err(err) = std::fs::copy(&frame.path, &dest) {+        warn!("Couldn't save frame: {err}");+      }+    }+  }++  fn save_all(&self) {+    let Some(dir) = rfd::FileDialog::new().pick_folder() else {+      return;+    };+    for frame in &self.frames {+      if let Some(name) = frame.path.file_name() {+        if let Err(err) = std::fs::copy(&frame.path, dir.join(name)) {+          warn!("Couldn't save {}: {err}", name.to_string_lossy());+        }+      }+    }+  }++  /// Loads PNG textures for stills that have arrived from the worker.+  pub fn load_textures(&mut self, ctx: &egui::Context) {+    for (i, frame) in self.frames.iter_mut().enumerate() {+      if frame.texture.is_none() {+        frame.texture = load_png(ctx, format!("extract-frame-{i}"), &frame.path);+      }+    }+  }+}++/// Timestamps for `count` stills, `distance_ms` apart, starting at `start`, clipped to `duration`.+pub fn timestamps(start: f64, count: u32, distance_ms: u32, duration: f64) -> Vec<f64> {+  if duration < 0.0 {+    return Vec::new();+  }+  let start = start.clamp(0.0, duration);+  let count = count.clamp(MIN_COUNT, MAX_COUNT);+  let step = f64::from(distance_ms.clamp(MIN_DISTANCE_MS, MAX_DISTANCE_MS)) / 1000.0;+  let mut out = Vec::new();+  for i in 0..count {+    let t = start + f64::from(i) * step;+    if t > duration {+      if out.is_empty() {+        out.push(duration);+      }+      break;+    }+    out.push(t);+    if (duration - t).abs() < 1e-9 {+      break;+    }+  }+  out+}++/// `mm:ss.mmm`, matching the web Frame Extract labels.+pub fn format_time_precise(secs: f64) -> String {+  let ms_total = (secs.max(0.0) * 1000.0).round() as u64;+  let mins = ms_total / 60_000;+  let secs = (ms_total % 60_000) / 1000;+  let ms = ms_total % 1000;+  format!("{mins:02}:{secs:02}.{ms:03}")+}++/// `frame-01-00-00-000.png`.+pub fn frame_file_name(index: u32, timestamp: f64) -> String {+  let stamp = format_time_precise(timestamp).replace([':', '.'], "-");+  format!("frame-{:02}-{stamp}.png", index + 1)+}++/// Writes one PNG at `timestamp` seconds into `output`.+pub fn extract_frame(input: &Path, timestamp: f64, output: &Path) -> Result<(), String> {+  let status = ffmpeg_cmd("ffmpeg").args(["-hide_banner", "-nostdin", "-loglevel", "error", "-y", "-ss", &format!("{timestamp:.3}"), "-i"]).arg(input).args(["-frames:v", "1", "-update", "1"]).arg(output).status().map_err(|e| format!("ffmpeg: {e}"))?;+  if !status.success() {+    return Err(format!("ffmpeg exited with {status}"));+  }+  if !output.is_file() {+    return Err("ffmpeg did not write a still".into());+  }+  Ok(())+}++fn probe(path: &Path) -> Result<ClipInfo, String> {+  #[derive(serde::Deserialize, Default)]+  #[serde(default)]+  struct Probe {+    streams: Vec<ProbeStream>,+    format: ProbeFormat,+  }+  #[derive(serde::Deserialize, Default)]+  #[serde(default)]+  struct ProbeStream {+    width: u32,+    height: u32,+    avg_frame_rate: String,+  }+  #[derive(serde::Deserialize, Default)]+  #[serde(default)]+  struct ProbeFormat {+    duration: String,+  }+  let out = ffmpeg_cmd("ffprobe").args(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,avg_frame_rate:format=duration", "-of", "json"]).arg(path).output().map_err(|e| format!("ffprobe: {e}"))?;+  if !out.status.success() {+    return Err(format!("ffprobe: {}", String::from_utf8_lossy(&out.stderr)));+  }+  let probe: Probe = serde_json::from_slice(&out.stdout).map_err(|e| format!("ffprobe: {e}"))?;+  let stream = probe.streams.first().ok_or_else(|| "no video stream".to_owned())?;+  let fps = match stream.avg_frame_rate.split_once('/') {+    Some((n, d)) => n.parse::<f64>().unwrap_or(24.0) / d.parse::<f64>().unwrap_or(1.0).max(1.0),+    None => stream.avg_frame_rate.parse().unwrap_or(24.0),+  };+  Ok(ClipInfo { width: stream.width.max(2), height: stream.height.max(2), fps: if fps.is_finite() && fps > 0.0 { fps } else { 24.0 }, duration: probe.format.duration.parse().unwrap_or(0.0) })+}++fn ffmpeg_cmd(program: &str) -> Command {+  #[allow(unused_mut)]+  let mut cmd = Command::new(program);+  #[cfg(windows)]+  {+    use std::os::windows::process::CommandExt;+    const CREATE_NO_WINDOW: u32 = 0x0800_0000;+    cmd.creation_flags(CREATE_NO_WINDOW);+  }+  cmd+}++fn dropped_video(ui: &Ui) -> Option<PathBuf> {+  ui.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_path_buf()).find(|p| !p.as_os_str().is_empty() && is_video_path(p)))+}++fn is_video_path(path: &Path) -> bool {+  path.extension().and_then(|e| e.to_str()).is_some_and(|ext| VIDEO_EXTS.iter().any(|ok| ext.eq_ignore_ascii_case(ok)))+}++fn file_stem(path: &Path) -> String {+  path.file_name().and_then(|n| n.to_str()).unwrap_or("video").to_owned()+}++fn kv(ui: &mut Ui, key: &str, value: &str) {+  ui.horizontal(|ui| {+    ui.label(egui::RichText::new(key).size(13.0).color(theme::MUTED));+    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+      ui.label(egui::RichText::new(value).size(13.0).family(theme::medium()).color(theme::INK));+    });+  });+}++fn panel(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {+  egui::Frame::new().fill(theme::WASH).stroke(theme::hairline()).inner_margin(egui::Margin::same(18)).show(ui, |ui| {+    ui.set_min_height(200.0);+    ui.set_width(ui.available_width());+    content(ui);+  });+}++fn number_field(ui: &mut Ui, label: &str, value: &mut u32, range: std::ops::RangeInclusive<u32>) {+  widgets::hud(ui, label, theme::FAINT);+  ui.add_space(4.0);+  ui.add(egui::DragValue::new(value).range(range).speed(1.0).update_while_editing(false));+}++fn load_png(ctx: &egui::Context, id: String, path: &Path) -> Option<TextureHandle> {+  let img = image::open(path).ok()?.into_rgba8();+  let size = [img.width() as usize, img.height() as usize];+  let color = ColorImage::from_rgba_unmultiplied(size, img.as_raw());+  Some(ctx.load_texture(id, color, TextureOptions::LINEAR))+}++#[cfg(test)]+mod tests {+  use super::*;+  use egui_kittest::Harness;+  use egui_kittest::kittest::Queryable;++  #[test]+  fn timestamps_stop_at_duration_and_clamp() {+    assert_eq!(timestamps(0.0, 4, 250, 2.0), vec![0.0, 0.25, 0.5, 0.75]);+    assert_eq!(timestamps(1.5, 10, 250, 2.0), vec![1.5, 1.75, 2.0]);+    assert_eq!(timestamps(2.0, 5, 100, 2.0), vec![2.0]);+    assert_eq!(timestamps(5.0, 3, 250, 1.0), vec![1.0], "start past the end clamps to duration");+    assert_eq!(timestamps(0.0, 0, 100, 1.0).len(), 1, "count 0 clamps up to 1");+    assert_eq!(timestamps(0.0, 999, 250, 1.0), vec![0.0, 0.25, 0.5, 0.75, 1.0], "count 999 clamps to 50, then duration cuts it");+  }++  #[test]+  fn precise_clock_and_file_names() {+    assert_eq!(format_time_precise(0.0), "00:00.000");+    assert_eq!(format_time_precise(65.042), "01:05.042");+    assert_eq!(format_time_precise(-1.0), "00:00.000");+    assert_eq!(frame_file_name(0, 1.5), "frame-01-00-01-500.png");+    assert_eq!(frame_file_name(9, 0.0), "frame-10-00-00-000.png");+  }++  #[test]+  fn video_paths_accept_common_containers() {+    assert!(is_video_path(Path::new("scene.MP4")));+    assert!(is_video_path(Path::new("/tmp/a.webm")));+    assert!(!is_video_path(Path::new("still.png")));+  }++  #[test]+  fn empty_page_offers_select_video() {+    let harness = build(FrameExtractPage::default());+    harness.get_by_label("Select Video");+    assert!(harness.query_by_label("Extract Frames").is_none());+  }++  #[test]+  fn loaded_page_shows_extract_and_switch() {+    let page = FrameExtractPage { clip: Some(OpenClip { path: PathBuf::from("/tmp/demo.mp4"), info: Some(ClipInfo { width: 640, height: 360, fps: 24.0, duration: 2.0 }), probe: None, player: None, probe_error: None }), ..Default::default() };+    let harness = build(page);+    assert!(harness.query_by_label("Extract Frames").is_some());+    assert!(harness.query_by_label("Switch Video").is_some());+    assert!(harness.query_by_label("Select Video").is_none());+  }++  #[test]+  fn extracts_stills_from_a_generated_clip() {+    if !video::ffmpeg_available() {+      eprintln!("skipping: ffmpeg/ffprobe not on PATH");+      return;+    }+    let dir = std::env::temp_dir().join("artcraft-native-frame-extract-test");+    std::fs::create_dir_all(&dir).unwrap();+    let clip = dir.join("clip.mp4");+    let status = ffmpeg_cmd("ffmpeg").args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=1:size=320x180:rate=24", "-pix_fmt", "yuv420p"]).arg(&clip).status().unwrap();+    assert!(status.success());+    let info = probe(&clip).unwrap();+    assert_eq!((info.width, info.height), (320, 180));+    assert!((info.duration - 1.0).abs() < 0.15);+    let out = dir.join("still.png");+    extract_frame(&clip, 0.2, &out).unwrap();+    let img = image::open(&out).unwrap();+    assert_eq!((img.width(), img.height()), (320, 180));+  }++  fn build(page: FrameExtractPage) -> Harness<'static, State> {+    let mut harness = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(+      |ui, s: &mut State| {+        if !s.fonts_ready {+          crate::theme::apply(ui.ctx());+          s.fonts_ready = true;+          return;+        }+        s.page.load_textures(ui.ctx());+        s.page.ui(ui);+      },+      State { page, fonts_ready: false },+    );+    harness.run();+    harness+  }++  struct State {+    page: FrameExtractPage,+    fonts_ready: bool,+  }+}diff --git a/artcraft-native/src/pages/mod.rs b/artcraft-native/src/pages/mod.rs--- a/artcraft-native/src/pages/mod.rs+++ b/artcraft-native/src/pages/mod.rs@@ -2,5 +2,6 @@ pub mod common; pub mod create_audio; pub mod create_image; pub mod create_video;+pub mod frame_extract; pub mod other; pub mod toolbar;diff --git a/artcraft-native/src/shell.rs b/artcraft-native/src/shell.rs--- a/artcraft-native/src/shell.rs+++ b/artcraft-native/src/shell.rs@@ -89,13 +89,14 @@ impl Page {       "create-video" | "video" => Page::CreateVideo,       "create-audio" | "audio" => Page::CreateAudio,       "library" => Page::Library,+      "frame-extractor" | "frame-extract" => Page::FrameExtractor,       _ => return None,     })   }    /// Pages the native app has; the rest open a "coming soon" page.   pub fn is_ready(self) -> bool {-    matches!(self, Page::Home | Page::CreateImage | Page::CreateVideo | Page::CreateAudio | Page::Library)+    matches!(self, Page::Home | Page::CreateImage | Page::CreateVideo | Page::CreateAudio | Page::Library | Page::FrameExtractor)   }    /// The same tool in the web app.@@ -418,4 +419,12 @@ mod tests {     assert_eq!(group_thousands(1_234_567), "1,234,567");     assert_eq!(group_thousands(15), "15");   }++  #[test]+  fn frame_extract_slug_is_ready() {+    assert_eq!(Page::from_slug("frame-extractor"), Some(Page::FrameExtractor));+    assert_eq!(Page::from_slug("frame-extract"), Some(Page::FrameExtractor));+    assert!(Page::FrameExtractor.is_ready());+    assert!(!Page::RemoveBackground.is_ready());+  } }