diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -135,6 +135,7 @@ app's JSON control channel (`deckcraft --control PORT`).
 | Date | What landed |
 |---|---|
 | 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
+| 2026-10-18 | `file.export {format: "frames"}` writes the slide show as a PNG frame sequence for a video encoder: build steps play through their timelines with holds, slide transitions play between slides (the show's own frame composition, now shared through `deckcraft_anim::state_for`), frame and pixel caps, and an answer mapping frames back to slides |
 | 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
 | 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
 | 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs
--- a/apps/deckcraft-cli/src/help.rs
+++ b/apps/deckcraft-cli/src/help.rs
@@ -66,7 +66,9 @@ everything; .pptx, .pdf and .md (the outline) are written as convert writes them
 current slide as .png or .jpg, or the presentation as .pdf, .pptx or an animated .gif (one frame per
 slide). --export-params passes file.export options as one JSON object, such as
 '{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' for a GIF
-(`deckcraft-cli describe file.export` lists them).
+(`deckcraft-cli describe file.export` lists them). A video's frames (the show with its builds and
+transitions, one PNG per frame in a folder) are exported with a command:
+--cmd 'file.export={\"format\":\"frames\",\"path\":\"frames/\",\"fps\":30}'.
 
 Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
 JSON object. Quote it for the shell. A command that needs no parameters is just its id.
diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs
--- a/crates/anim/src/lib.rs
+++ b/crates/anim/src/lib.rs
@@ -40,7 +40,7 @@ pub use interact::{Click, HitShape, Target};
 pub use mesh::{MeshVertex, layer_mesh};
 pub use morph::{MorphFrame, MorphPath, MorphText, morph_crop, morph_frame, morph_pairs, morph_xfrm};
 pub use path::{MotionPath, default_path};
-pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order};
+pub use show::{LinkJump, ShapeFrame, ShowAction, ShowState, safe_url, show_order, state_for};
 pub use timeline::{EffectTiming, Sequence, StepStamp, Timeline, TriggerPlay};
 pub use transition::{DEFAULT_ASPECT, Layer, Source, transition_frame, transition_layers, uses_slide_parts};
 
diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs
--- a/crates/anim/src/show.rs
+++ b/crates/anim/src/show.rs
@@ -3,11 +3,30 @@
 
 use std::collections::BTreeMap;
 
-use deckcraft_model::Presentation;
 use deckcraft_model::text::{Action, Hyperlink};
+use deckcraft_model::{Presentation, ShapeId};
 
-use crate::Timeline;
 use crate::interact::SlideRun;
+use crate::{AnimState, StepStamp, Timeline, TriggerPlay};
+
+/// What one shape looks like at one moment of the show: its own state and, for by-paragraph text
+/// builds, the state of each animated paragraph relative to the shape's (paragraph index, state).
+#[derive(Clone, Debug, PartialEq)]
+pub struct ShapeFrame {
+    pub state: AnimState,
+    pub paras: Vec<(usize, AnimState)>,
+}
+
+/// Shape `id` of timeline `tl` when `step` main steps are done and step `step` has been playing
+/// for `t` seconds, with the triggered sequences at `plays` applied in the order of `stamps`
+/// ([`Timeline::state_ordered`]); its paragraphs follow [`Timeline::state`]. The slide show and
+/// the frame-sequence export draw every frame from this.
+pub fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64, plays: &[TriggerPlay], stamps: &[StepStamp]) -> ShapeFrame {
+    ShapeFrame {
+        state: tl.state_ordered(id, None, step, t, plays, stamps),
+        paras: tl.paragraph_targets(id).into_iter().map(|p| (p, tl.state(id, Some(p), step, t))).collect(),
+    }
+}
 
 /// What the UI should do after a navigation call.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -19,6 +19,7 @@ deckcraft-pdf = { workspace = true }
 deckcraft-format = { workspace = true }
 deckcraft-pptx = { workspace = true }
 deckcraft-media = { workspace = true }
+deckcraft-anim = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
 i_overlay = { workspace = true }
@@ -28,7 +29,6 @@ image = { workspace = true }
 chrono = { workspace = true }
 
 [dev-dependencies]
-deckcraft-anim = { workspace = true }
 zip = { workspace = true }
 
 # The browser clock for comment timestamps (wasm has no std::time::SystemTime).
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
         cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
         cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
         cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
-        cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|rtf|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
+        cmd!(noundo "file.export", "Export…", ["File"], None, "{path: file to write (frames: a folder that receives frame-000001.png…; its earlier frame-NNNNNN.png files are removed), format?: png|jpeg|pptx|deckcraft|outline|rtf|pdf|gif|frames (else by path; frames needs it), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif: 960; frames: a number, 1920), secondsPerSlide?: 0.1-600 (gif; 5) or 0-60 (frames; 3: hold after a slide's last build step, the whole time of a slide without animations), loop?: bool (gif; true: forever), fps?: 1-60 (frames; 30), secondsPerStep?: 0-30 (frames; 1: hold before the first click step and after each click step settles; automatic steps run on), transitions?: bool (frames; true: play each slide's transition from the previous slide, frame by frame; false cuts)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide; frames (the slide show as a PNG frame sequence for a video encoder: every build step plays at fps, then holds; at most 18000 frames) answers {folder, frames, fps, width, height, seconds, slides: [{index, from, to}]: the first and last frame number (from 1) of each slide, its transition is included}", has_doc, export),
         cmd!(
             query "file.info",
             "Info",
@@ -633,6 +633,29 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
                 "secondsPerSlide": f64::from(out.delay_cs) / 100.0,
             }))
         }
+        "frames" => {
+            // `path` is a folder; `slides` may be a range, as for PDF ("1-3,5").
+            let mut fp = p.clone();
+            if let Some(text) = p.get("slides").and_then(Value::as_str) {
+                fp["slides"] = json!(parse_slide_range(text, st.doc.slides.len())?);
+            } else if p.get("slides").is_none_or(Value::is_null)
+                && let Some(i) = usize_param(p, "slide")
+            {
+                // `slide` narrows the export to that one slide, as it does for the images.
+                fp["slides"] = json!([i]);
+            }
+            let opts = crate::frames_export::FramesOptions::from_params(&fp)?;
+            let out = crate::frames_export::export(&st.doc, &opts, &path)?;
+            Ok(json!({
+                "folder": out.folder,
+                "frames": out.frames,
+                "fps": out.fps,
+                "width": out.width,
+                "height": out.height,
+                "seconds": out.seconds(),
+                "slides": out.slides.iter().map(|r| json!({"index": r.index, "from": r.from, "to": r.to})).collect::<Vec<_>>(),
+            }))
+        }
         "pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" | "rtf" => {
             let bytes = save_bytes(&st.doc, &format)?;
             write_file(&path, &bytes)?;
diff --git a/crates/engine/src/frames_export.rs b/crates/engine/src/frames_export.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/frames_export.rs
@@ -0,0 +1,581 @@
+//! Frame sequence of the slide show (`file.export` with format `frames`): one PNG per video frame,
+//! `frame-000001.png`… in a folder, at a constant frame rate, for a video encoder to join.
+//!
+//! Each exported slide plays its build through its [`Timeline`] as the show would: every main
+//! step from `t = 0` to its duration at `1/fps`, then the settled state held `secondsPerStep`
+//! (an automatic step runs straight into the next); after the last step the slide is held
+//! `secondsPerSlide`. A slide without animations is one still held `secondsPerSlide`. Between
+//! slides the incoming slide's own transition (kind, option and duration) plays frame by frame,
+//! composited like the show does ([`deckcraft_render::composite_transition`]; Morph is rendered
+//! shape by shape); `none` cuts. Sound and video don't play (a frame shows a video's first frame).
+//!
+//! All parameters are hostile: rate, holds, width and the slide list are checked against fixed
+//! bounds, and the total number of frames and of pixels is capped before anything is rendered or
+//! written, so a request can't fill a disk or run for hours. Identical consecutive frames (holds)
+//! are encoded once and written as copies.
+
+use std::sync::Arc;
+
+use deckcraft_anim::{ShowState, Timeline, state_for, transition_frame, uses_slide_parts};
+use deckcraft_model::{Presentation, ShapeId};
+use deckcraft_render::{Image, RenderOpts, ShapeState, SlideParts, composite_transition};
+use serde_json::Value;
+
+use crate::cmd::bad;
+use crate::gif_export::{MAX_WIDTH, MIN_WIDTH, frame_size};
+use crate::{EngineError, Result};
+
+const CMD: &str = "file.export";
+
+pub const DEFAULT_FPS: u32 = 30;
+pub const MIN_FPS: u32 = 1;
+pub const MAX_FPS: u32 = 60;
+pub const DEFAULT_SECONDS_PER_STEP: f64 = 1.0;
+pub const MAX_SECONDS_PER_STEP: f64 = 30.0;
+pub const DEFAULT_SECONDS_PER_SLIDE: f64 = 3.0;
+pub const MAX_SECONDS_PER_SLIDE: f64 = 60.0;
+/// Frame width when none is given (Full HD for 16:9 slides).
+pub const DEFAULT_WIDTH: u32 = 1920;
+/// Most frames in one export: 10 minutes at 30 fps.
+pub const MAX_FRAMES: usize = 18_000;
+/// Cap on frames × width × height: [`MAX_FRAMES`] Full HD frames fit, 4K frames for a third of
+/// that time.
+pub const MAX_TOTAL_PIXELS: u64 = 40_000_000_000;
+/// A step or transition longer than this (a hostile `duration_ms` in the file) is cut here; the
+/// frame cap then decides.
+const MAX_PLAY_SECONDS: f64 = 600.0;
+
+/// What to export.
+#[derive(Clone, Debug, PartialEq)]
+pub struct FramesOptions {
+    pub fps: u32,
+    /// Hold after a click step's animations have settled.
+    pub seconds_per_step: f64,
+    /// Hold after a slide's last step (the whole time of a slide without animations).
+    pub seconds_per_slide: f64,
+    /// Play the slides' transitions between them (`false`: cut).
+    pub transitions: bool,
+    /// Frame width in pixels; the height follows the slide's aspect ratio.
+    pub width: u32,
+    /// Slide indices in play order (default: all).
+    pub slides: Option<Vec<usize>>,
+    /// Hidden slides are left out unless this is set.
+    pub include_hidden: bool,
+}
+
+impl Default for FramesOptions {
+    fn default() -> Self {
+        FramesOptions {
+            fps: DEFAULT_FPS,
+            seconds_per_step: DEFAULT_SECONDS_PER_STEP,
+            seconds_per_slide: DEFAULT_SECONDS_PER_SLIDE,
+            transitions: true,
+            width: DEFAULT_WIDTH,
+            slides: None,
+            include_hidden: false,
+        }
+    }
+}
+
+/// Present and not `null`.
+fn given<'a>(p: &'a Value, key: &str) -> Option<&'a Value> {
+    p.get(key).filter(|v| !v.is_null())
+}
+
+/// A finite number of seconds in `0..=max`.
+fn seconds(v: &Value, key: &str, max: f64) -> Result<f64> {
+    v.as_f64()
+        .filter(|s| s.is_finite() && (0.0..=max).contains(s))
+        .ok_or_else(|| bad(CMD, format!("`{key}` must be a number of seconds from 0 to {max}")))
+}
+
+impl FramesOptions {
+    /// Read `{fps?, secondsPerStep?, secondsPerSlide?, transitions?, width?, slides?: [index],
+    /// includeHidden?}`. Absent or `null` values take their defaults; anything out of range or of
+    /// the wrong type is an error.
+    pub fn from_params(p: &Value) -> Result<Self> {
+        let mut o = FramesOptions::default();
+        if let Some(v) = given(p, "fps") {
+            o.fps = v
+                .as_u64()
+                .and_then(|f| u32::try_from(f).ok())
+                .filter(|f| (MIN_FPS..=MAX_FPS).contains(f))
+                .ok_or_else(|| bad(CMD, format!("`fps` must be a whole number of frames per second from {MIN_FPS} to {MAX_FPS}")))?;
+        }
+        if let Some(v) = given(p, "secondsPerStep") {
+            o.seconds_per_step = seconds(v, "secondsPerStep", MAX_SECONDS_PER_STEP)?;
+        }
+        if let Some(v) = given(p, "secondsPerSlide") {
+            o.seconds_per_slide = seconds(v, "secondsPerSlide", MAX_SECONDS_PER_SLIDE)?;
+        }
+        if let Some(v) = given(p, "transitions") {
+            o.transitions = v.as_bool().ok_or_else(|| bad(CMD, "`transitions` must be true or false"))?;
+        }
+        if let Some(v) = given(p, "width") {
+            o.width = v
+                .as_u64()
+                .and_then(|w| u32::try_from(w).ok())
+                .filter(|w| (MIN_WIDTH..=MAX_WIDTH).contains(w))
+                .ok_or_else(|| bad(CMD, format!("`width` must be a whole number of pixels from {MIN_WIDTH} to {MAX_WIDTH}")))?;
+        }
+        if let Some(v) = given(p, "slides") {
+            let a = v.as_array().ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices"))?;
+            if a.is_empty() {
+                return Err(bad(CMD, "`slides` is empty"));
+            }
+            if a.len() > MAX_FRAMES {
+                return Err(bad(CMD, format!("a frame sequence can show at most {MAX_FRAMES} slides")));
+            }
+            let list = a
+                .iter()
+                .map(|x| x.as_u64().and_then(|i| usize::try_from(i).ok()))
+                .collect::<Option<Vec<usize>>>()
+                .ok_or_else(|| bad(CMD, "each entry of `slides` must be a non-negative integer (a 0-based index)"))?;
+            o.slides = Some(list);
+        }
+        if let Some(v) = given(p, "includeHidden") {
+            o.include_hidden = v.as_bool().ok_or_else(|| bad(CMD, "`includeHidden` must be true or false"))?;
+        }
+        Ok(o)
+    }
+
+    /// The slides to play, in order: the chosen ones (or all), without hidden slides unless
+    /// `include_hidden`.
+    pub fn order(&self, doc: &Presentation) -> Result<Vec<usize>> {
+        let n = doc.slides.len();
+        let chosen: Vec<usize> = match &self.slides {
+            Some(list) => {
+                if let Some(i) = list.iter().find(|i| **i >= n) {
+                    return Err(bad(CMD, format!("no slide at index {i}; indices run 0 through {}", n.saturating_sub(1))));
+                }
+                list.clone()
+            }
+            None => (0..n).collect(),
+        };
+        let order: Vec<usize> = chosen.into_iter().filter(|i| self.include_hidden || doc.slides.get(*i).is_some_and(|s| !s.hidden)).collect();
+        if order.is_empty() {
+            return Err(bad(CMD, "no slides to export (they are all hidden, or the presentation is empty)"));
+        }
+        Ok(order)
+    }
+}
+
+/// One frame of the sequence: what to draw.
+#[derive(Clone, Debug, PartialEq)]
+enum Frame {
+    /// Slide `slide` with `step` main steps done and step `step` playing for `t` seconds
+    /// (`step == steps()`: everything settled).
+    Build { slide: usize, step: usize, t: f64 },
+    /// The transition into `to` from `from`, `p` (0..1) of the way through.
+    Transition { from: usize, to: usize, p: f64 },
+}
+
+/// First and last frame number (from 1) of one exported slide; its transition in counts as its own.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SlideRange {
+    pub index: usize,
+    pub from: usize,
+    pub to: usize,
+}
+
+/// Every frame of the export, decided before anything is drawn.
+#[derive(Clone, Debug)]
+pub struct Plan {
+    frames: Vec<Frame>,
+    pub slides: Vec<SlideRange>,
+    pub width: u32,
+    pub height: u32,
+}
+
+impl Plan {
+    pub fn frames(&self) -> usize {
+        self.frames.len()
+    }
+}
+
+/// A finite number of seconds from the document, cut to [`MAX_PLAY_SECONDS`].
+fn play_seconds(s: f64) -> f64 {
+    if s.is_finite() && s > 0.0 { s.min(MAX_PLAY_SECONDS) } else { 0.0 }
+}
+
+/// Decide the frames of `doc` under `opts`, refusing when there would be more than
+/// [`MAX_FRAMES`] or more than [`MAX_TOTAL_PIXELS`] pixels.
+pub fn plan(doc: &Presentation, opts: &FramesOptions) -> Result<Plan> {
+    let order = opts.order(doc)?;
+    let fps = f64::from(opts.fps.clamp(MIN_FPS, MAX_FPS));
+    // Frames a span of `s` seconds takes while playing (the frame at its end belongs to what follows).
+    let playing = |s: f64| (play_seconds(s) * fps).ceil().clamp(0.0, 1e9) as usize;
+    // Frames a hold of `s` seconds takes.
+    let holding = |s: f64| (play_seconds(s) * fps).round().clamp(0.0, 1e9) as usize;
+    let hold_step = holding(opts.seconds_per_step);
+    let hold_slide = holding(opts.seconds_per_slide).max(1);
+    let (width, height) = frame_size(doc, opts.width);
+    let too_many = |n: usize| {
+        bad(
+            CMD,
+            format!(
+                "{n} frames is more than the {MAX_FRAMES} a frame sequence can have ({} minutes at {} fps): lower fps, secondsPerSlide or secondsPerStep, or export fewer slides",
+                MAX_FRAMES / 60 / DEFAULT_FPS as usize,
+                DEFAULT_FPS
+            ),
+        )
+    };
+    let mut frames: Vec<Frame> = Vec::new();
+    let mut slides = Vec::with_capacity(order.len());
+    let mut prev: Option<usize> = None;
+    for &slide in &order {
+        let from = frames.len() + 1;
+        if let Some(from_slide) = prev.filter(|_| opts.transitions)
+            && let Some(tr) = doc.slides.get(slide).and_then(|s| s.transition.as_ref())
+            && tr.kind != "none"
+        {
+            let dur = play_seconds(f64::from(tr.duration_ms) / 1000.0);
+            let n = playing(dur);
+            // At most MAX_PLAY_SECONDS × MAX_FPS: counted against the cap below.
+            frames.extend((0..n).map(|j| Frame::Transition { from: from_slide, to: slide, p: ((j as f64 / fps) / dur).clamp(0.0, 1.0) }));
+        }
+        let tl: Timeline = ShowState::timeline(doc, slide);
+        let steps = tl.steps();
+        if steps == 0 {
+            frames.extend(std::iter::repeat_n(Frame::Build { slide, step: 0, t: 0.0 }, hold_slide));
+        }
+        // What the slide shows before its first click is held like any settled state; an automatic
+        // first step plays at once, so there is nothing to hold.
+        if steps > 0 && !tl.step_is_auto(0) {
+            frames.extend(std::iter::repeat_n(Frame::Build { slide, step: 0, t: 0.0 }, hold_step));
+        }
+        for step in 0..steps {
+            let n = playing(tl.step_duration(step));
+            frames.extend((0..n).map(|j| Frame::Build { slide, step, t: j as f64 / fps }));
+            let hold = if step + 1 == steps {
+                hold_slide
+            } else if tl.step_is_auto(step + 1) {
+                // The pause before a step depends on that step: an automatic one runs on.
+                0
+            } else {
+                hold_step
+            };
+            frames.extend(std::iter::repeat_n(Frame::Build { slide, step: step + 1, t: 0.0 }, hold));
+            if frames.len() > MAX_FRAMES {
+                return Err(too_many(frames.len()));
+            }
+        }
+        if frames.len() > MAX_FRAMES {
+            return Err(too_many(frames.len()));
+        }
+        slides.push(SlideRange { index: slide, from, to: frames.len() });
+        prev = Some(slide);
+    }
+    let total = (frames.len() as u64).saturating_mul(u64::from(width)).saturating_mul(u64::from(height));
+    if total > MAX_TOTAL_PIXELS {
+        return Err(bad(
+            CMD,
+            format!(
+                "{} frames at {width}×{height} is {total} pixels, more than the {MAX_TOTAL_PIXELS} a frame sequence can have: lower width, fps, secondsPerSlide or secondsPerStep, or export fewer slides",
+                frames.len()
+            ),
+        ));
+    }
+    Ok(Plan { frames, slides, width, height })
+}
+
+/// What was written.
+#[derive(Clone, Debug)]
+pub struct FramesOutput {
+    pub folder: String,
+    pub frames: usize,
+    pub fps: u32,
+    pub width: u32,
+    pub height: u32,
+    pub slides: Vec<SlideRange>,
+}
+
+impl FramesOutput {
+    /// Length of the sequence in seconds.
+    pub fn seconds(&self) -> f64 {
+        self.frames as f64 / f64::from(self.fps.max(1))
+    }
+}
+
+/// The file name of frame `n` (from 1).
+pub fn frame_name(n: usize) -> String {
+    format!("frame-{n:06}.png")
+}
+
+/// Is `name` a frame file this export writes (`frame-` + six digits + `.png`)?
+fn is_frame_name(name: &str) -> bool {
+    name.strip_prefix("frame-").and_then(|r| r.strip_suffix(".png")).is_some_and(|d| d.len() == 6 && d.bytes().all(|b| b.is_ascii_digit()))
+}
+
+fn render_threads() -> u16 {
+    if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }
+}
+
+/// Draws the frames of one export, keeping the last few slide images (a hold's settled frame, the
+/// two slides of a transition) so nothing is rendered twice.
+struct Painter<'a> {
+    doc: &'a Presentation,
+    size: (u32, u32),
+    scale: f64,
+    threads: u16,
+    /// Slide `.0`'s timeline.
+    timeline: Option<(usize, Timeline)>,
+    /// Recently drawn build frames, newest last.
+    builds: Vec<(Frame, Arc<Image>)>,
+    /// Backgrounds and contents of the transition `.0` → `.1` in progress.
+    parts: Option<(usize, usize, SlideParts)>,
+}
+
+impl Painter<'_> {
+    fn opts(&self) -> RenderOpts<'static> {
+        RenderOpts { scale: self.scale, size: Some(self.size), threads: self.threads, ..Default::default() }
+    }
+
+    fn timeline(&mut self, slide: usize) -> &Timeline {
+        if self.timeline.as_ref().is_none_or(|(k, _)| *k != slide) {
+            self.timeline = Some((slide, ShowState::timeline(self.doc, slide)));
+        }
+        match &self.timeline {
+            Some((_, t)) => t,
+            None => {
+                static EMPTY: std::sync::OnceLock<Timeline> = std::sync::OnceLock::new();
+                EMPTY.get_or_init(Timeline::default)
+            }
+        }
+    }
+
+    /// Slide `slide` at `step`, `t` seconds in.
+    fn build(&mut self, slide: usize, step: usize, t: f64) -> Arc<Image> {
+        let key = Frame::Build { slide, step, t };
+        if let Some((_, img)) = self.builds.iter().find(|(k, _)| *k == key) {
+            return Arc::clone(img);
+        }
+        let tl = self.timeline(slide).clone();
+        let f = move |id: ShapeId| ShapeState::from(state_for(&tl, id, step, t, &[], &[]));
+        let img = Arc::new(deckcraft_render::render_slide(self.doc, slide, &RenderOpts { state: Some(&f), ..self.opts() }));
+        if self.builds.len() >= 3 {
+            self.builds.remove(0);
+        }
+        self.builds.push((key, Arc::clone(&img)));
+        img
+    }
+
+    /// The transition into `to` from `from` at progress `p`: the settled outgoing slide going out,
+    /// the incoming slide as it is before its first step coming in.
+    fn transition(&mut self, from: usize, to: usize, p: f64) -> Arc<Image> {
+        let doc = self.doc;
+        let (kind, option) =
+            doc.slides.get(to).and_then(|s| s.transition.as_ref()).map(|tr| (tr.kind.clone(), tr.option.clone())).unwrap_or_default();
+        let steps_from = self.timeline(from).steps();
+        if kind == "morph"
+            && let (Some(a), Some(b)) = (doc.slides.get(from), doc.slides.get(to))
+            && p < 1.0
+        {
+            let tl = self.timeline(to).clone();
+            let hidden_b = |id: ShapeId| {
+                let st = state_for(&tl, id, 0, 0.0, &[], &[]).state;
+                !st.visible || st.opacity <= 0.0
+            };
+            let m = deckcraft_anim::morph_frame(doc, a, b, p, &hidden_b);
+            let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() };
+            let o = RenderOpts { state: Some(&f), ..self.opts() };
+            let text: Vec<deckcraft_render::TextMorph> = m
+                .text
+                .iter()
+                .map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix })
+                .collect();
+            let paths: Vec<deckcraft_render::PathMorph> = m
+                .paths
+                .iter()
+                .map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix })
+                .collect();
+            return Arc::new(deckcraft_render::render_blend(doc, from, to, m.mix, &m.shapes, &text, &paths, &o));
+        }
+        let old = self.build(from, steps_from, 0.0);
+        let new = self.build(to, 0, 0.0);
+        if uses_slide_parts(&kind, &option) && self.parts.as_ref().is_none_or(|(a, b, _)| (*a, *b) != (from, to)) {
+            self.parts = Some((from, to, SlideParts::render(doc, Some(from), to, &self.opts())));
+        }
+        let parts = self.parts.as_ref().filter(|(a, b, _)| (*a, *b) == (from, to)).map(|(_, _, p)| p);
+        let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0);
+        Arc::new(composite_transition(Some(&old), &new, parts, &transition_frame(&kind, &option, p, aspect)))
+    }
+
+    fn draw(&mut self, f: &Frame) -> Arc<Image> {
+        match *f {
+            Frame::Build { slide, step, t } => self.build(slide, step, t),
+            Frame::Transition { from, to, p } => self.transition(from, to, p),
+        }
+    }
+}
+
+/// One PNG to encode once and write under every number in `numbers` (a hold is one image).
+struct Job {
+    numbers: Vec<usize>,
+    image: Arc<Image>,
+}
+
+fn encode_and_write(folder: &std::path::Path, job: &Job) -> std::result::Result<(), String> {
+    let bytes = job.image.to_png();
+    if bytes.is_empty() {
+        return Err("a frame could not be encoded as PNG".into());
+    }
+    for &n in &job.numbers {
+        crate::io::write_file_direct(&folder.join(frame_name(n)).to_string_lossy(), &bytes)?;
+    }
+    Ok(())
+}
+
+/// Encode and write the jobs of `rx` on a few threads; the first error stops the rest.
+#[cfg(not(target_arch = "wasm32"))]
+fn run_jobs(folder: &std::path::Path, jobs: impl Iterator<Item = Job>) -> std::result::Result<(), String> {
+    let workers = std::thread::available_parallelism().map(|n| n.get().clamp(1, 8)).unwrap_or(1);
+    let (tx, rx) = std::sync::mpsc::sync_channel::<Job>(workers * 2);
+    let rx = std::sync::Mutex::new(rx);
+    let failed = std::sync::Mutex::new(None::<String>);
+    std::thread::scope(|s| {
+        for _ in 0..workers {
+            s.spawn(|| {
+                loop {
+                    let job = match rx.lock() {
+                        Ok(r) => r.recv(),
+                        Err(_) => return,
+                    };
+                    let Ok(job) = job else { return };
+                    if let Err(e) = encode_and_write(folder, &job)
+                        && let Ok(mut f) = failed.lock()
+                    {
+                        f.get_or_insert(e);
+                    }
+                }
+            });
+        }
+        for job in jobs {
+            if failed.lock().ok().is_some_and(|f| f.is_some()) {
+                break;
+            }
+            if tx.send(job).is_err() {
+                break;
+            }
+        }
+        drop(tx);
+    });
+    match failed.into_inner() {
+        Ok(Some(e)) => Err(e),
+        _ => Ok(()),
+    }
+}
+
+#[cfg(target_arch = "wasm32")]
+fn run_jobs(folder: &std::path::Path, jobs: impl Iterator<Item = Job>) -> std::result::Result<(), String> {
+    for job in jobs {
+        encode_and_write(folder, &job)?;
+    }
+    Ok(())
+}
+
+/// Render the frames of `doc` under `opts` into `folder` (created if missing; earlier
+/// `frame-NNNNNN.png` files in it are removed first). Nothing is written when the request is
+/// refused.
+pub fn export(doc: &Presentation, opts: &FramesOptions, folder: &str) -> Result<FramesOutput> {
+    let plan = plan(doc, opts)?;
+    let dir = std::path::Path::new(folder);
+    let io = |e: String| EngineError::Other(format!("frames export: {e}"));
+    crate::file_access::check_write(&dir.join(frame_name(1)).to_string_lossy()).map_err(io)?;
+    crate::io::create_folder(folder).map_err(io)?;
+    // The frames of an earlier export go, so a video encoder reading the numbered sequence stops
+    // where this one ends.
+    crate::io::remove_files(folder, &is_frame_name).map_err(io)?;
+    let scale = f64::from(plan.width) / doc.slide_size.width;
+    let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
+    let mut painter = Painter { doc, size: (plan.width, plan.height), scale, threads: render_threads(), timeline: None, builds: vec![], parts: None };
+    // Consecutive identical frames (holds) become one job.
+    let mut runs: Vec<(Frame, Vec<usize>)> = Vec::new();
+    for (k, f) in plan.frames.iter().enumerate() {
+        match runs.last_mut() {
+            Some((last, numbers)) if last == f => numbers.push(k + 1),
+            _ => runs.push((f.clone(), vec![k + 1])),
+        }
+    }
+    let jobs = runs.into_iter().map(|(f, numbers)| Job { numbers, image: painter.draw(&f) });
+    run_jobs(dir, jobs).map_err(io)?;
+    Ok(FramesOutput {
+        folder: folder.to_string(),
+        frames: plan.frames.len(),
+        fps: opts.fps,
+        width: plan.width,
+        height: plan.height,
+        slides: plan.slides,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+    use serde_json::json;
+
+    use super::*;
+
+    #[test]
+    fn hostile_params_are_errors_not_panics() {
+        for p in [
+            json!({"fps": 0}),
+            json!({"fps": 61}),
+            json!({"fps": -1}),
+            json!({"fps": 29.97}),
+            json!({"fps": "30"}),
+            json!({"fps": 1e308}),
+            json!({"secondsPerStep": -0.1}),
+            json!({"secondsPerStep": 30.1}),
+            json!({"secondsPerStep": "1"}),
+            json!({"secondsPerSlide": -1}),
+            json!({"secondsPerSlide": 61}),
+            json!({"secondsPerSlide": 1e308}),
+            json!({"transitions": "yes"}),
+            json!({"width": 15}),
+            json!({"width": 3841}),
+            json!({"width": "large"}),
+            json!({"width": 640.5}),
+            json!({"slides": []}),
+            json!({"slides": [-1]}),
+            json!({"slides": "1-3"}),
+            json!({"slides": vec![0; MAX_FRAMES + 1]}),
+            json!({"includeHidden": 1}),
+        ] {
+            assert!(FramesOptions::from_params(&p).is_err(), "{p}");
+        }
+        assert_eq!(FramesOptions::from_params(&json!({"fps": null, "width": null, "transitions": null})).unwrap(), FramesOptions::default());
+        let o =
+            FramesOptions::from_params(&json!({"fps": 60, "secondsPerStep": 0, "secondsPerSlide": 60, "transitions": false, "width": 16})).unwrap();
+        assert_eq!((o.fps, o.seconds_per_step, o.seconds_per_slide, o.transitions, o.width), (60, 0.0, 60.0, false, 16));
+    }
+
+    #[test]
+    fn frame_names_are_six_digits_and_recognised() {
+        assert_eq!(frame_name(1), "frame-000001.png");
+        assert_eq!(frame_name(MAX_FRAMES), "frame-018000.png");
+        assert!(is_frame_name("frame-000001.png"));
+        assert!(!is_frame_name("frame-1.png"));
+        assert!(!is_frame_name("frame-000001.PNG"));
+        assert!(!is_frame_name("slide-000001.png"));
+        assert!(!is_frame_name("frame-00000a.png"));
+    }
+
+    /// The pixel budget refuses a long 4K export before a frame is drawn; every slide is at least
+    /// one frame even with a zero hold.
+    #[test]
+    fn plan_is_capped_and_never_empty() {
+        let mut s = crate::Session::with_new();
+        s.execute("slide.new", &json!({})).unwrap();
+        let doc = &s.doc().unwrap().doc;
+        let o = FramesOptions { fps: 60, seconds_per_slide: 60.0, width: 3840, ..Default::default() };
+        let e = plan(doc, &o).unwrap_err().to_string();
+        assert!(e.contains(&MAX_TOTAL_PIXELS.to_string()) && e.contains("width"), "{e}");
+        let o = FramesOptions { seconds_per_slide: 0.0, width: 64, ..Default::default() };
+        let p = plan(doc, &o).unwrap();
+        assert_eq!(p.frames(), 2);
+        assert_eq!(p.slides, vec![SlideRange { index: 0, from: 1, to: 1 }, SlideRange { index: 1, from: 2, to: 2 }]);
+        assert_eq!((p.width, p.height), (64, 36));
+    }
+}
diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs
--- a/crates/engine/src/io.rs
+++ b/crates/engine/src/io.rs
@@ -71,6 +71,62 @@ pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> {
     Err(format!("{path}: saving to a path is not available in the browser"))
 }
 
+/// Create the folder `path` (and its parents) inside the automation write root; one that exists is
+/// left as it is. A file of that name is an error.
+#[cfg(not(target_arch = "wasm32"))]
+pub fn create_folder(path: &str) -> Result<(), String> {
+    crate::file_access::check_write(path)?;
+    let dir = std::path::Path::new(path);
+    if dir.exists() && !dir.is_dir() {
+        return Err(format!("{path} is not a folder"));
+    }
+    std::fs::create_dir_all(dir).map_err(|e| format!("{path}: cannot create the folder: {e}"))
+}
+
+#[cfg(target_arch = "wasm32")]
+pub fn create_folder(path: &str) -> Result<(), String> {
+    Err(format!("{path}: saving to a path is not available in the browser"))
+}
+
+/// Remove the plain files in folder `path` whose names `matches` says are ours, inside the
+/// automation write root (each one is checked). A folder that isn't there has nothing to remove.
+/// Answers how many were removed.
+#[cfg(not(target_arch = "wasm32"))]
+pub fn remove_files(path: &str, matches: &dyn Fn(&str) -> bool) -> Result<usize, String> {
+    let Ok(entries) = std::fs::read_dir(path) else { return Ok(0) };
+    let mut removed = 0;
+    for e in entries.flatten() {
+        let name = e.file_name();
+        if matches(&name.to_string_lossy()) && e.file_type().is_ok_and(|t| t.is_file()) {
+            let file = e.path();
+            let file = file.to_string_lossy();
+            crate::file_access::check_write(&file)?;
+            std::fs::remove_file(&*file).map_err(|err| format!("{file}: {err}"))?;
+            removed += 1;
+        }
+    }
+    Ok(removed)
+}
+
+#[cfg(target_arch = "wasm32")]
+pub fn remove_files(_path: &str, _matches: &dyn Fn(&str) -> bool) -> Result<usize, String> {
+    Ok(0)
+}
+
+/// Write `bytes` straight to `path` inside the automation write root: no temporary sibling and no
+/// sync, for the many generated files of a frame sequence, which are rebuilt rather than precious.
+/// The folder must exist ([`create_folder`]).
+#[cfg(not(target_arch = "wasm32"))]
+pub fn write_file_direct(path: &str, bytes: &[u8]) -> Result<(), String> {
+    crate::file_access::check_write(path)?;
+    std::fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))
+}
+
+#[cfg(target_arch = "wasm32")]
+pub fn write_file_direct(path: &str, _bytes: &[u8]) -> Result<(), String> {
+    Err(format!("{path}: saving to a path is not available in the browser"))
+}
+
 /// The size in bytes of the file at `path`; `None` when it can't be read (or is outside the
 /// automation read root, so File › Info doesn't reveal files the agent may not read).
 #[cfg(not(target_arch = "wasm32"))]
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -13,6 +13,7 @@ pub mod connect;
 pub mod drawtable;
 pub mod file_access;
 pub mod fonts;
+pub mod frames_export;
 pub mod gif_export;
 pub mod guard;
 pub mod icons;
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -4724,3 +4724,186 @@ fn slide_hide_and_layout_reject_invalid_index() {
     }
     assert!(s.doc().unwrap().doc.slides.iter().all(|x| !x.hidden));
 }
+
+/// A scratch folder for the frame-sequence export tests, empty.
+fn frames_dir(name: &str) -> std::path::PathBuf {
+    let dir = std::env::temp_dir().join(format!("deckcraft-frames-{}-{name}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&dir);
+    dir
+}
+
+/// Two slides: slide 0 holds a rectangle that fades in on click (500 ms), slide 1 is blank and
+/// comes in with a 700 ms fade. Returns the session and the rectangle's id.
+fn frames_deck() -> (Session, u64) {
+    let mut s = session();
+    let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 200]})).unwrap()["id"].as_u64().unwrap();
+    s.execute("shape.fill", &json!({"ids": [id], "color": "#2040E0"})).unwrap();
+    s.execute("animation.add", &json!({"ids": [id], "effect": "fade", "class": "entrance", "start": "onClick", "duration": 500})).unwrap();
+    s.execute("slide.new", &json!({})).unwrap();
+    s.execute("transition.set", &json!({"kind": "fade", "duration": 700, "index": 1})).unwrap();
+    (s, id)
+}
+
+fn png_files(dir: &std::path::Path) -> Vec<String> {
+    let mut v: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
+    v.sort();
+    v
+}
+
+/// Hidden slides are skipped unless `includeHidden`, and a folder that isn't there yet is created.
+/// `slide` narrows a frame export to that slide, as it narrows a PNG export.
+#[test]
+fn frames_export_of_one_slide_takes_the_slide_parameter() {
+    let (mut s, _) = frames_deck();
+    let dir = frames_dir("one-slide");
+    let r = s
+        .execute(
+            "file.export",
+            &json!({"format": "frames", "path": dir.to_string_lossy(), "fps": 10, "secondsPerSlide": 1, "width": 320, "slide": 1}),
+        )
+        .unwrap();
+    assert_eq!(r["slides"].as_array().map(Vec::len), Some(1), "{r}");
+    assert_eq!(r["slides"][0]["index"], 1, "{r}");
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn frames_export_skips_hidden_slides_and_creates_the_folder() {
+    let (mut s, _) = frames_deck();
+    s.execute("slide.hide", &json!({"index": 0, "hidden": true})).unwrap();
+    let dir = frames_dir("hidden").join("deeper").join("still");
+    assert!(!dir.exists());
+    let p = json!({"format": "frames", "path": dir.to_string_lossy(), "fps": 10, "secondsPerSlide": 2, "width": 320});
+    let r = s.execute("file.export", &p).unwrap();
+    assert_eq!(r["frames"], 20, "{r}");
+    assert_eq!(r["slides"], json!([{"index": 1, "from": 1, "to": 20}]), "{r}");
+    assert!(dir.is_dir());
+    assert_eq!(png_files(&dir).len(), 20);
+    let mut p2 = p.clone();
+    p2["includeHidden"] = json!(true);
+    let r = s.execute("file.export", &p2).unwrap();
+    assert_eq!(r["frames"], 62, "{r}");
+    let _ = std::fs::remove_dir_all(dir.parent().unwrap().parent().unwrap());
+}
+
+/// HH4: the outline export (PowerPoint's outline view) holds titles and body text only: footer,
+/// date and slide-number placeholders are not body lines.
+#[test]
+fn outline_export_skips_footer_date_and_slide_number() {
+    let mut s = session();
+    s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap();
+    s.execute(
+        "design.headerFooter",
+        &json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}),
+    )
+    .unwrap();
+    let dir = std::env::temp_dir().join(format!("deckcraft-outline-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&dir);
+    std::fs::create_dir_all(&dir).unwrap();
+    let path = dir.join("deck.md");
+    s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "outline"})).unwrap();
+    let text = std::fs::read_to_string(&path).unwrap();
+    let _ = std::fs::remove_dir_all(&dir);
+    assert!(text.contains("Agenda") && text.contains("First point") && text.contains("Second point"), "{text:?}");
+    assert!(!text.contains("Company Confidential"), "footer text is not a body line: {text:?}");
+    assert!(!text.contains("‹#›"), "the slide-number field is not a body line: {text:?}");
+    assert!(!text.contains("1 May 2026"), "the date is not a body line: {text:?}");
+}
+
+/// The state before the first click is held for `secondsPerStep`; after an automatic first step
+/// the click step that follows is preceded by that pause, and an automatic step is not.
+#[test]
+fn frames_export_holds_the_pre_click_state_and_pauses_before_click_steps() {
+    let frames = |first: &str| {
+        let mut s = session();
+        let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 200]})).unwrap()["id"].as_u64().unwrap();
+        let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 100, 600, 200]})).unwrap()["id"].as_u64().unwrap();
+        s.execute("animation.add", &json!({"ids": [a], "effect": "fade", "class": "entrance", "start": first, "duration": 500})).unwrap();
+        s.execute("animation.add", &json!({"ids": [b], "effect": "fade", "class": "entrance", "start": "onClick", "duration": 500})).unwrap();
+        let dir = frames_dir(first);
+        let r = s
+            .execute(
+                "file.export",
+                &json!({"format": "frames", "path": dir.to_string_lossy(), "fps": 10, "secondsPerStep": 1, "secondsPerSlide": 2, "width": 160, "slides": [0]}),
+            )
+            .unwrap();
+        let _ = std::fs::remove_dir_all(&dir);
+        r["frames"].as_u64().unwrap()
+    };
+    // Click, click: 10 pre-click + 5 + 10 + 5 + 20 hold.
+    assert_eq!(frames("onClick"), 50);
+    // Automatic, click: no pre-click hold, 5 + 10 (pause before the click step) + 5 + 20.
+    assert_eq!(frames("afterPrevious"), 40);
+}
+
+/// `file.export {format: "frames"}` writes one PNG per video frame: slide 0's fade step (5 frames
+/// at 10 fps for 500 ms) after the 1 s hold of its state before the click (10), then its 2 s hold
+/// (20), the 700 ms fade into slide 1 (7), and slide 1's 2 s hold (20): 62 frames, numbered from 1, mapped back to the slides by the answer.
+#[test]
+fn frames_export_counts_build_steps_holds_and_transitions() {
+    let (mut s, _) = frames_deck();
+    let dir = frames_dir("count");
+    let r = s
+        .execute(
+            "file.export",
+            &json!({"format": "frames", "path": dir.to_string_lossy(), "fps": 10, "secondsPerStep": 1, "secondsPerSlide": 2, "width": 320}),
+        )
+        .unwrap();
+    assert_eq!(r["frames"], 62, "{r}");
+    assert_eq!(r["fps"], 10, "{r}");
+    assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(320), Some(180)), "{r}");
+    assert!((r["seconds"].as_f64().unwrap() - 6.2).abs() < 1e-9, "{r}");
+    assert_eq!(r["folder"], json!(dir.to_string_lossy()), "{r}");
+    assert_eq!(r["slides"], json!([{"index": 0, "from": 1, "to": 35}, {"index": 1, "from": 36, "to": 62}]), "{r}");
+    let files = png_files(&dir);
+    assert_eq!(files.len(), 62);
+    assert_eq!(files.first().map(String::as_str), Some("frame-000001.png"));
+    assert_eq!(files.last().map(String::as_str), Some("frame-000062.png"));
+    // Without transitions the fade's 7 frames go away; a slide without animations is one still.
+    let dir2 = frames_dir("cut");
+    let r = s
+        .execute(
+            "file.export",
+            &json!({"format": "frames", "path": dir2.to_string_lossy(), "fps": 10, "secondsPerStep": 1, "secondsPerSlide": 2, "width": 320, "transitions": false}),
+        )
+        .unwrap();
+    assert_eq!(r["frames"], 55, "{r}");
+    assert_eq!(r["slides"], json!([{"index": 0, "from": 1, "to": 35}, {"index": 1, "from": 36, "to": 55}]), "{r}");
+    let _ = std::fs::remove_dir_all(&dir);
+    let _ = std::fs::remove_dir_all(&dir2);
+}
+
+/// Frames are the show's frames: half-way through the fade the rectangle is translucent (the frame
+/// differs from the settled one), and the settled frame is exactly what `render_slide` draws.
+#[test]
+fn frames_export_draws_the_build_and_settles_on_the_plain_slide() {
+    let (mut s, _) = frames_deck();
+    let dir = frames_dir("pixels");
+    s.execute(
+        "file.export",
+        &json!({"format": "frames", "path": dir.to_string_lossy(), "fps": 10, "secondsPerStep": 1, "secondsPerSlide": 2, "width": 320}),
+    )
+    .unwrap();
+    let frame = |n: usize| std::fs::read(dir.join(format!("frame-{n:06}.png"))).unwrap();
+    let doc = &s.doc().unwrap().doc;
+    let settled = deckcraft_render::render_slide(
+        doc,
+        0,
+        &deckcraft_render::RenderOpts { scale: 320.0 / doc.slide_size.width, size: Some((320, 180)), ..Default::default() },
+    );
+    assert_eq!(frame(35), settled.to_png(), "the hold frame is the slide with its builds done");
+    assert_ne!(frame(13), frame(35), "200 ms into the fade the rectangle is still translucent");
+    assert_ne!(frame(1), frame(35), "before the click the rectangle is hidden");
+    // The transition's first frame is still slide 0, its last one nearly slide 1.
+    assert_eq!(frame(10), frame(1), "the state before the click is held");
+    assert_eq!(frame(36), frame(35));
+    let blank = deckcraft_render::render_slide(
+        doc,
+        1,
+        &deckcraft_render::RenderOpts { scale: 320.0 / doc.slide_size.width, size: Some((320, 180)), ..Default::default() },
+    );
+    assert_eq!(frame(62), blank.to_png());
+    assert_ne!(frame(39), frame(35));
+    assert_ne!(frame(39), frame(62));
+    let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -644,7 +644,9 @@ pub fn slides_to_outline(p: &Presentation) -> String {
         out.push_str(&s.title().replace('\n', " "));
         out.push('\n');
         for sh in &s.shapes {
-            if sh.ph_type().is_some_and(|k| k.is_title()) {
+            // The title is the slide's line; footer, date and slide-number placeholders are not
+            // body text (PowerPoint's outline view shows title and body placeholders only).
+            if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) {
                 continue;
             }
             if let Some(t) = &sh.text {
diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs
--- a/crates/mcp/src/tests.rs
+++ b/crates/mcp/src/tests.rs
@@ -242,3 +242,29 @@ fn export_tool_lists_gif_and_its_options() {
     assert_eq!(r["result"]["isError"], true, "{r}");
     let _ = std::fs::remove_dir_all(&dir);
 }
+
+/// The export tool offers the frame sequence for videos: the format, its options, and that `path`
+/// is a folder; through the tool it writes numbered PNGs and answers the slide map.
+#[test]
+fn export_tool_lists_frames_and_writes_a_folder() {
+    let mut s = Server::new(Box::new(Headless::new()));
+    let tools = call(&mut s, 1, "tools/list", json!({}));
+    let export = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "export").unwrap().clone();
+    let desc = export["description"].as_str().unwrap();
+    assert!(desc.contains("frames") && desc.contains("folder"), "{export}");
+    let props = &export["inputSchema"]["properties"];
+    assert!(props["format"]["enum"].as_array().unwrap().iter().any(|x| x == "frames"), "{export}");
+    for p in ["fps", "secondsPerStep", "secondsPerSlide", "transitions", "width", "slides", "includeHidden"] {
+        assert!(props.get(p).is_some(), "{p}: {export}");
+    }
+    let dir = std::env::temp_dir().join(format!("deckcraft-mcp-frames-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&dir);
+    tool(&mut s, "new_presentation", json!({}));
+    let r = tool(&mut s, "export", json!({"format": "frames", "path": dir.to_string_lossy(), "fps": 5, "secondsPerSlide": 1, "width": 64}));
+    assert_eq!(r["frames"], 5, "{r}");
+    assert_eq!(r["slides"], json!([{"index": 0, "from": 1, "to": 5}]), "{r}");
+    assert!(dir.join("frame-000005.png").is_file());
+    let r = call(&mut s, 9, "tools/call", json!({"name": "export", "arguments": {"format": "frames", "path": dir.to_string_lossy(), "fps": 0}}));
+    assert_eq!(r["result"]["isError"], true, "{r}");
+    let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs
--- a/crates/mcp/src/tools.rs
+++ b/crates/mcp/src/tools.rs
@@ -115,19 +115,19 @@ pub fn tool_definitions() -> Vec<Value> {
         tool(
             "export",
             "Export",
-            "Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, pdf (slides, notes pages or handouts), or an animated gif of the deck (one frame per slide).",
+            "Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, pdf (slides, notes pages or handouts), an animated gif of the deck (one frame per slide), or frames: the slide show as a PNG frame sequence for a video encoder (`path` is a folder that receives frame-000001.png… at `fps`; builds play step by step with holds, transitions play between slides; the answer maps frame numbers back to slides).",
             obj(
                 json!({
                     "path": string("Output path"),
-                    "format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "rtf", "pdf", "gif"], "description": "Output format (default: from the path's extension)"},
+                    "format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "rtf", "pdf", "gif", "frames"], "description": "Output format (default: from the path's extension; frames must be named)"},
                     "slide": int("Slide index for images"),
                     "all": boolean("All slides (images)"),
                     "scale": num("Pixels per point for images (default 2)"),
                     "layout": {"type": "string", "enum": ["slides", "notes", "handouts"], "description": "PDF page layout (default slides)"},
                     "perPage": {"type": "integer", "enum": [1, 2, 3, 4, 6, 9], "description": "PDF handouts: slides per page (default 6)"},
                     "dpi": num("PDF: raster resolution of slide artwork, 36-600 (default 200)"),
-                    "slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF and GIF: slide indices to export, in order (default all)"},
-                    "includeHidden": boolean("PDF and GIF: include hidden slides (default false)"),
+                    "slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF, GIF and frames: slide indices to export, in order (default all)"},
+                    "includeHidden": boolean("PDF, GIF and frames: include hidden slides (default false)"),
                     "textLayer": boolean("PDF: invisible text layer for search, select and copy (default true)"),
                     "frame": boolean("PDF notes/handouts: thin frame around each slide (default true)"),
                     "paper": {"type": "string", "enum": ["letter", "a4", "legal", "a3"], "description": "PDF notes/handouts: paper size (default letter)"},
@@ -137,10 +137,13 @@ pub fn tool_definitions() -> Vec<Value> {
                             {"type": "integer", "minimum": 16, "maximum": 3840},
                             {"type": "string", "enum": ["small", "medium", "large", "extraLarge"]}
                         ],
-                        "description": "GIF: frame width in pixels, or a size: small 640, medium 960 (default), large 1280, extraLarge 1920; the height follows the slide's shape"
+                        "description": "GIF: frame width in pixels, or a size: small 640, medium 960 (default), large 1280, extraLarge 1920; frames: a width in pixels (default 1920); the height follows the slide's shape"
                     },
-                    "secondsPerSlide": {"type": "number", "minimum": 0.1, "maximum": 600, "description": "GIF: seconds each slide shows (default 5)"},
-                    "loop": boolean("GIF: loop forever (true, default) or play once (false)")
+                    "secondsPerSlide": {"type": "number", "minimum": 0, "maximum": 600, "description": "GIF: seconds each slide shows, 0.1-600 (default 5); frames: hold after a slide's last build step and the whole time of a slide without animations, 0-60 (default 3)"},
+                    "loop": boolean("GIF: loop forever (true, default) or play once (false)"),
+                    "fps": {"type": "integer", "minimum": 1, "maximum": 60, "description": "frames: frames per second (default 30)"},
+                    "secondsPerStep": {"type": "number", "minimum": 0, "maximum": 30, "description": "frames: hold before the first click step and after each click step settles (default 1); automatic steps run on"},
+                    "transitions": boolean("frames: play each slide's transition from the previous slide (default true; false cuts)")
                 }),
                 &["path"],
             ),
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -194,6 +194,27 @@ impl Default for ShapeState {
     }
 }
 
+impl From<deckcraft_anim::ShapeFrame> for ShapeState {
+    /// The show's per-shape state ([`deckcraft_anim::state_for`]) as the renderer takes it.
+    fn from(f: deckcraft_anim::ShapeFrame) -> Self {
+        let a = f.state;
+        ShapeState {
+            visible: a.visible,
+            opacity: a.opacity,
+            offset: Vec2::new(a.offset_x, a.offset_y),
+            scale: (a.scale_x, a.scale_y),
+            rotate: a.rotate,
+            clip: a.clip,
+            tint: a.tint,
+            paras: f
+                .paras
+                .into_iter()
+                .map(|(p, a)| (p, ParaState { visible: a.visible, opacity: a.opacity, offset: Vec2::new(a.offset_x, a.offset_y) }))
+                .collect(),
+        }
+    }
+}
+
 /// Animation state of one paragraph, relative to its shape.
 #[derive(Clone, Copy, Debug)]
 pub struct ParaState {
diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs
--- a/crates/ui-egui/src/show.rs
+++ b/crates/ui-egui/src/show.rs
@@ -5,7 +5,7 @@
 use deckcraft_anim::{Click, HitShape, Layer, LinkJump, ShowAction, ShowState, Source, StepStamp, Timeline, TriggerPlay};
 use deckcraft_model::text::Hyperlink;
 use deckcraft_model::{Presentation, ShapeId};
-use deckcraft_render::{ParaState, PlacedLink, PlacedShape, RenderOpts, ShapeState};
+use deckcraft_render::{PlacedLink, PlacedShape, RenderOpts, ShapeState};
 use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2};
 use serde_json::json;
 
@@ -163,7 +163,7 @@ fn render_frame(
     backdrop: Option<&deckcraft_render::Image>,
 ) -> egui::ColorImage {
     let f = move |id: ShapeId| {
-        let mut st = state_for(&tl, id, step, t, plays, stamps);
+        let mut st = ShapeState::from(deckcraft_anim::state_for(&tl, id, step, t, plays, stamps));
         if hidden.contains(&id) {
             st.visible = false;
         }
@@ -201,7 +201,7 @@ fn render_overlay(
     stamps: &[StepStamp],
 ) -> egui::ColorImage {
     let f = move |id: ShapeId| {
-        let mut st = state_for(&tl, id, step, t, plays, stamps);
+        let mut st = ShapeState::from(deckcraft_anim::state_for(&tl, id, step, t, plays, stamps));
         if hidden.contains(&id) {
             st.visible = false;
         }
@@ -487,27 +487,6 @@ impl Show {
     }
 }
 
-fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64, plays: &[TriggerPlay], stamps: &[StepStamp]) -> ShapeState {
-    let a = tl.state_ordered(id, None, step, t, plays, stamps);
-    ShapeState {
-        visible: a.visible,
-        opacity: a.opacity,
-        offset: deckcraft_geom::Vec2::new(a.offset_x, a.offset_y),
-        scale: (a.scale_x, a.scale_y),
-        rotate: a.rotate,
-        clip: a.clip,
-        tint: a.tint,
-        paras: tl
-            .paragraph_targets(id)
-            .into_iter()
-            .map(|p| {
-                let a = tl.state(id, Some(p), step, t);
-                (p, ParaState { visible: a.visible, opacity: a.opacity, offset: deckcraft_geom::Vec2::new(a.offset_x, a.offset_y) })
-            })
-            .collect(),
-    }
-}
-
 /// Whether starting the show takes the whole screen. Reading view and "Browsed by an individual (window)" stay in the window.
 fn show_fills_screen(reading: bool, show_type: &str) -> bool {
     !reading && show_type != "browsed"
@@ -922,7 +901,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
             (Some(tr), Some(tl)) => {
                 let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
                 let hidden_b = |id: ShapeId| {
-                    let st = state_for(&tl, id, 0, 0.0, &[], &[]);
+                    let st = deckcraft_anim::state_for(&tl, id, 0, 0.0, &[], &[]).state;
                     !st.visible || st.opacity <= 0.0 || hidden.contains(&id)
                 };
                 tr.from
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -139,13 +139,14 @@ Detail in [file-format-parity.md](file-format-parity.md).
 - **Impact:** handouts and speaker notes are printed in many workplaces.
 - **Estimate:** 5–8 h (print through the PDF path and each OS's print dialog). **Beta blocker.**
 
-### No video export; the animated GIF has no transitions or builds
-- **Evidence:** checklist row "Export video (MP4) / animated GIF" is partial: `file.export` writes
-  an animated GIF with one frame per slide (#171), but there is no MP4/MOV export, and the GIF
-  doesn't animate transitions or build animations.
-- **Impact:** sharing a talk as a video is a common request.
-- **Estimate:** 10–16 h (render the timeline frame by frame, which the GIF can then reuse for
-  transitions and builds; an H.264 encoder is the hard part, FilmCraft has one to copy).
+### No video file export; the animated GIF has no transitions or builds
+- **Evidence:** checklist row "Export video (MP4) / animated GIF" is partial: `file.export
+  {format: "frames"}` writes the show frame by frame (builds, holds and transitions) as a PNG
+  sequence an external encoder joins, and the animated GIF has one frame per slide (#171), but
+  there is no MP4/MOV writer, and the GIF doesn't animate transitions or build animations.
+- **Impact:** sharing a talk as a video is a common request; a frame folder needs a second tool.
+- **Estimate:** 6–10 h (an H.264 encoder is the hard part, FilmCraft has one to copy; the GIF can
+  reuse the frame plan for transitions and builds).
 
 ### Embedded MicroType Express font subsets aren't drawn
 - **Evidence:** `p:embeddedFontLst` is read and written. A raw TrueType/OpenType face, or one inside an Embedded OpenType wrapper, is loaded and used to draw. A MicroType Express subset (`application/x-fontdata` with neither form) is kept and written back, and the text still uses a stand-in.
diff --git a/docs/mcp.md b/docs/mcp.md
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -99,6 +99,8 @@ A tool refuses an argument that is not in its schema (`insert_chart: unknown arg
 Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset,
 rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`,
 `shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`,
-`chart.*` (`chart.options {textColor}` colours a chart's title, axes, legend and labels), `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}`
-(GIF). `deckcraft-cli describe COMMAND` prints any
+`chart.*` (`chart.options {textColor}` colours a chart's title, axes, legend and labels), `table.*`, `file.export {path, layout}` (PDF), `{path, width, secondsPerSlide, loop}`
+(GIF) or `{format: "frames", path: folder, fps, secondsPerStep, secondsPerSlide, transitions, width}` (the
+slide show as a PNG frame sequence, builds and transitions played, for a video encoder; the answer's
+`slides` maps frame numbers back to slides). `deckcraft-cli describe COMMAND` prints any
 command's parameters.
diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md
--- a/docs/parity-checklist.md
+++ b/docs/parity-checklist.md
@@ -65,7 +65,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
 | Files | Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending) | P0 | P |
 | Files | Export slides as PNG/JPEG | P0 | D |
 | Files | Export outline / RTF | P2 | D |
-| Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P |
+| Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; the show exports as a PNG frame sequence with builds and transitions, no MP4 writer) | P2 | P |
 | Files | Print (slides, handouts, notes, outline) | P1 | M |
 | Files | Password protection | P2 | M |
 | Files | Reuse slides from another file | P1 | P |
diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md
--- a/docs/slideshow-parity.md
+++ b/docs/slideshow-parity.md
@@ -60,7 +60,7 @@ ready** (estimated), **10–16 h** to parity.
 | Rehearse Timings, keep timings | yes | yes |
 | Record Slide Show (narration, ink, laser, camera; Cameo) | yes | no |
 | Clear timing / narration | yes | timings only |
-| Export the recorded show to video | yes | no (see [file-format-parity.md](file-format-parity.md)) |
+| Export the recorded show to video | yes | frame sequence only: `file.export {format: "frames"}` plays builds and transitions into numbered PNGs for a video encoder; no MP4 writer (see [file-format-parity.md](file-format-parity.md)) |
 
 ## Reading View
 
@@ -80,6 +80,7 @@ Reading View in a window with navigation: present (checklist row done).
 | Date | Change | Summary |
 |---|---|---|
 | 2026-10-22 | minor | See All Slides from presenter view opens in presenter view, not on the audience screen (#1130) |
+| 2026-10-18 | minor | The show exports as a PNG frame sequence (builds with holds, transitions, frame caps) for a video encoder |
 | 2026-10-14 | trivial | Community PR wave folded: one media playback row, the presenter pen/laser row matches the laser that exists, estimates keep only the open work (~9–16 h); checklist breadth 68% |
 | 2026-10-13 | minor | Click handling for interactive decks: triggers, per-slide state kept across visits, visible-shape hit testing with hand cursor, advance on click |
 | 2026-10-13 | minor | Video seek handoff no longer discards freshly decoded frames or end-of-stream (upstream #416); deterministic queue and four-codec seek regression tests |
