"""
export_video.py  –  Export a PPTX as full_presentation.mp4  (Linux)

═══════════════════════════════════════════════════════════════════════════
PRIMARY METHOD  ── preserves ALL animations, transitions, and effects
  Xvfb virtual display  +  LibreOffice Impress slideshow
                        +  FFmpeg x11grab screen capture
                        +  xdotool slide advancement

  How it works:
    1. python-pptx reads per-slide durations from the PPTX.
       A click-only copy is saved (advTm removed) so LibreOffice waits
       for explicit keypresses to advance.  The internal advTm timer is
       NOT used because it does not fire on headless Xvfb displays
       without a window manager.
    2. Xvfb creates an invisible 1280×720 X11 display.
    3. LibreOffice opens the PPTX in full-screen slideshow mode.
    4. xdotool polls for the LibreOffice window using multiple strategies
       (--classname, --class, --name) because the exact WM_CLASS varies
       by LibreOffice version.  Window info is written to xdotool_debug.log
       in the same directory as the output video for troubleshooting.
    5. FFmpeg starts recording.  xdotool sends Right Arrow keypresses
       after each slide's duration elapses, advancing the presentation.
    6. After the final slide finishes FFmpeg stops automatically (-t flag).

  Required packages:
    sudo apt install xvfb libreoffice ffmpeg xdotool openbox
    pip  install python-pptx

  WHY openbox is required:
    Without a window manager on the Xvfb display LibreOffice cannot go
    fullscreen, so --show falls back to EDIT mode (first slide thumbnail
    shown for the whole video).  openbox is a minimal WM that fixes this.
    It also enables xdotool windowactivate (_NET_ACTIVE_WINDOW support).

  Debug log (written on every run):
    <output_video_dir>/xdotool_debug.log

═══════════════════════════════════════════════════════════════════════════
FALLBACK METHOD ── static slides only, no animations
  LibreOffice headless → PDF → one PNG per slide → FFmpeg concat

  Required packages:
    sudo apt install libreoffice ffmpeg
    pip  install python-pptx PyMuPDF      ← recommended PDF renderer
    OR:  sudo apt install poppler-utils   ← alternative (pdftoppm)
         sudo apt install ghostscript     ← alternative (gs)
         sudo apt install imagemagick     ← alternative (convert)
═══════════════════════════════════════════════════════════════════════════

Usage:
  python3 export_video.py <ppt_path> <output_mp4_path>
"""

import os
import sys
import glob
import json
import time
import copy
import shutil
import posixpath
import subprocess
import tempfile
import zipfile
from pptx import Presentation
from lxml import etree


# ── Tunables ─────────────────────────────────────────────────────────────────
FFMPEG                 = "ffmpeg"
LIBREOFFICE            = "libreoffice"
PDFTOPPM               = "pdftoppm"

DEFAULT_SLIDE_DURATION = 5      # seconds/slide when the PPTX has no timings
SLIDE_WIDTH            = 1280
SLIDE_HEIGHT           = 720

# Xvfb: seconds to wait for LibreOffice to finish loading and show slide 1.
# LibreOffice creates a fresh user profile in a tmp dir on first run — this
# can take 15-25 s.  Increase on slow/low-RAM servers (e.g. 25 or 30).
XVFB_STARTUP_WAIT = 25
# Extra seconds to record after the last slide finishes.
XVFB_END_BUFFER   = 3

# Click-animation timing (for slides with advClick="1" and no auto-advance timer)
ANIM_WAIT_BEFORE_FIRST  = 4.0   # s to wait after a slide appears before sending first key
                                 # Increased from 2.0 → 4.0 to ensure 0-click slides have
                                 # at least 4 s of duration, which is > MIN_SLIDE_SWITCH_DELAY
                                 # (3.0 s).  Without this, the clip_start offset for short
                                 # 0-click slides would fall after the next boundary.
ANIM_WAIT_BETWEEN_STEPS = 3.0   # s to wait between consecutive animation-step keypresses
FFMPEG_PRE_ROLL         = 0.5   # s sleep between FFmpeg start and first xdotool keypress

# Each xdotool subprocess call (key --window ...) has real execution overhead
# (~0.05–0.3 s per call depending on the server).  This overhead is NOT in
# the ANIM_WAIT_* sleeps, so it accumulates across slides.  For a 10-slide
# deck with 36 total arrows at 0.25 s each the actual advance timestamps can
# be ~9 s later than the formula predicts.  We account for it in two places:
#   1. compute_record_duration() adds total_arrows × OVERHEAD so FFmpeg keeps
#      recording past the last slide action.
#   2. _advance_slides_xdotool() records actual wall-clock timestamps and
#      returns them; export_with_xvfb() writes those real times to
#      slide_timings.json so split boundaries are always precise.
XDOTOOL_OVERHEAD_PER_ARROW = 0.25  # s conservative per-arrow overhead estimate

# After the real advance timestamps are used as boundaries (actual_advances),
# LibreOffice still needs a moment to finish rendering the incoming slide.
# actual_advances records the wall-clock time just BEFORE each advance xdotool
# call, so boundary error is at most ~0.3 s (single xdotool subprocess latency).
# This is much more accurate than the old computed boundaries (cumulative error
# up to N×0.25 s = 3 s for 13 slides), so 1.0 s is now sufficient:
#   • xdotool execution overhead: ~0.05–0.3 s
#   • LO frame-paint delay after keypress: ~0.1–0.3 s
#   • Headroom: ~0.4 s
# For slides with an explicit transition animation the larger of
# transition_dur and MIN_SLIDE_SWITCH_DELAY is used (see step 14 and
# compute_slide_boundaries), so long transitions are still handled correctly.
#
# IMPORTANT: MIN_SLIDE_SWITCH_DELAY must be LESS than ANIM_WAIT_BEFORE_FIRST.
# If MIN_SLIDE_SWITCH_DELAY >= ANIM_WAIT_BEFORE_FIRST, the clip_starts cap
# (boundaries[i+1] - 0.1 s) leaves only ~0.067 s of content per clip, making
# all slides 2+ appear as blank/thumbnail-only videos.
#
# Render-lag note: the headless Xvfb server takes 2–3 s to paint a new slide
# to the X11 framebuffer after LibreOffice advances.  FFmpeg x11grab captures
# this delayed state, so the video shows the OLD slide for ~2–3 s after the
# actual LO advance.  MIN_SLIDE_SWITCH_DELAY is the clip-start offset from
# the advance boundary; setting it to 3.0 s ensures each clip starts only
# after the new slide content is visible in the captured video.
# ANIM_WAIT_BEFORE_FIRST must be > MIN_SLIDE_SWITCH_DELAY (currently 4.0 > 3.0).
MIN_SLIDE_SWITCH_DELAY  = 3.0   # s minimum clip-start offset per slide switch

# Safety buffer added to transition_duration when deciding:
#   (a) how long to wait before sending the first animation click on a slide
#       with an entrance transition (avoids key-press DURING the transition,
#       which causes LO to skip the slide entirely instead of triggering anim).
#   (b) how far into the clip to start the per-slide video (clip_starts offset),
#       ensuring the transition is fully rendered before the clip begins.
# Without this buffer, slides whose transition_duration == ANIM_WAIT_BEFORE_FIRST
# (e.g. spd="slow" → 2.0s == ANIM_WAIT_BEFORE_FIRST 2.0s) get their first
# animation key sent exactly as the transition finishes — LO sometimes
# still processes it as a slide-advance rather than an animation trigger,
# causing the slide to be skipped and all subsequent clips to be off by one.
# NOTE: On a headless Xvfb server with software-only rendering, LO transitions
# often take 1.5–2× longer than the nominal spd values (fast=0.5s, med=1.0s,
# slow=2.0s from the OOXML spec).  The _SPD_TO_SEC mapping below uses
# conservative values calibrated for headless operation.  TRANSITION_BUFFER
# adds an additional safety margin on top of those conservative durations.
TRANSITION_BUFFER = 1.0  # s added to transition_duration for safety margin

# Seconds to sleep AFTER _enforce_fullscreen() and BEFORE FFmpeg starts.
# This lets LibreOffice finish painting the fullscreen slide 1 so that no
# LO editor/chrome is visible in the first frame of the recording.
PRE_FFMPEG_SETTLE_TIME  = 2.0   # s

NS_PPT = "http://schemas.openxmlformats.org/presentationml/2006/main"
NS_A   = "http://schemas.openxmlformats.org/drawingml/2006/main"


# ─────────────────────────────────────────────────────────────────────────────
# Step 1 – Read per-slide timing data from PPTX
# ─────────────────────────────────────────────────────────────────────────────

def get_slide_durations(ppt_path):
    """
    Read per-slide auto-advance durations from the PPTX using python-pptx.

    <p:transition advClick="0" advTm="5000"/>  → 5 seconds on this slide.
    Slides without an explicit timing use DEFAULT_SLIDE_DURATION.

    Returns (durations, has_timings).
    """
    prs         = Presentation(ppt_path)
    durations   = []
    has_timings = False

    for idx, slide in enumerate(prs.slides, start=1):
        dur = float(DEFAULT_SLIDE_DURATION)
        try:
            trans = slide._element.find(f"{{{NS_PPT}}}transition")
            if trans is not None:
                adv_tm    = trans.get("advTm")
                adv_click = trans.get("advClick", "1")
                if adv_click == "0" and adv_tm is not None:
                    dur         = int(adv_tm) / 1000.0
                    has_timings = True
        except Exception as e:
            sys.stderr.write(f"  [timing] slide {idx}: warning – {e}\n")

        dur = max(dur, 0.5)
        durations.append(dur)
        sys.stderr.write(f"  [timing] slide {idx}: {dur:.2f}s\n")

    sys.stderr.write(
        f"  [timing] {len(durations)} slides, has_timings={has_timings}\n"
    )
    return durations, has_timings


def get_slide_click_steps(ppt_path):
    """
    Count the number of explicit click-triggered animation groups per slide.

    Traverses each slide's animation timing tree looking for the element
    <p:cTn nodeType="mainSeq"> and counts its <p:par> children whose
    <p:cTn> start condition has delay="indefinite" (triggered by click/keypress).

    Returns a list of integers, one per slide.
    Slides with no click-triggered animations return 0.

    Example: [2, 1, 1, 0, 0]
      slide 1 has 2 click groups (needs 3 Right Arrows: 2 anim + 1 advance)
      slides 4-5 are static    (need 1 Right Arrow each: 0 anim + 1 advance)
    """
    NS  = NS_PPT
    prs = Presentation(ppt_path)
    result = []

    for slide_idx, slide in enumerate(prs.slides, 1):
        timing_el = slide._element.find(f"{{{NS}}}timing")
        if timing_el is None:
            result.append(0)
            sys.stderr.write(f"  [click_steps] slide {slide_idx}: 0 (no timing element)\n")
            continue

        count = 0
        # Iterate over every <p:cTn> in the timing tree to find mainSeq
        for ctn in timing_el.iter(f"{{{NS}}}cTn"):
            if ctn.get("nodeType") != "mainSeq":
                continue
            # Found mainSeq — count its click-triggered <p:par> children
            child_list = ctn.find(f"{{{NS}}}childTnLst")
            if child_list is None:
                continue
            for par in child_list.findall(f"{{{NS}}}par"):
                par_ctn = par.find(f"{{{NS}}}cTn")
                if par_ctn is None:
                    continue
                stCondLst = par_ctn.find(f"{{{NS}}}stCondLst")
                if stCondLst is None:
                    continue
                for cond in stCondLst.findall(f"{{{NS}}}cond"):
                    if cond.get("delay") == "indefinite":
                        count += 1
                        break   # count each <p:par> only once

        result.append(count)
        sys.stderr.write(f"  [click_steps] slide {slide_idx}: {count} click group(s)\n")

    # Total Right Arrows = sum(steps+1) for all slides except last + steps for last
    if result:
        total_arrows = sum(s + 1 for s in result[:-1]) + result[-1]
    else:
        total_arrows = 0
    sys.stderr.write(
        f"  [click_steps] per-slide={result}, total Right Arrows needed={total_arrows}\n"
    )
    return result


def get_slide_transition_durations(ppt_path):
    """
    Read the entrance-transition duration for each slide from PPTX.

    When LibreOffice advances from slide N to slide N+1, it plays slide N+1's
    transition animation.  During that animation the screen still shows slide N
    content (or a blend/wipe), so the video-split boundary must be shifted
    forward by the transition duration to get a clean first frame for clip N+1.

    Reads two attributes from <p:transition>:
      • 'dur'  (ECMA-376 2nd ed)  – milliseconds, takes priority
      • 'spd'  (older format)     – "fast"≈0.5s  "med"≈2.0s  "slow"≈3.0s
        NOTE: these are conservative values for headless Xvfb/software rendering.
        On a real GPU display the values would be 0.5/1.0/2.0 s, but on headless
        servers the LO renderer runs without acceleration and transitions visually
        take roughly 1.5–2× longer.  Using larger values prevents the transition-
        skip bug (a keypress sent during an entrance transition causes LO to skip
        the current slide entirely and advance to the next one).

    Returns a list of float seconds, one per slide.  0.0 = no transition.
    """
    _SPD_TO_SEC = {"fast": 0.5, "med": 2.0, "slow": 3.0}
    prs    = Presentation(ppt_path)
    result = []
    for idx, slide in enumerate(prs.slides, 1):
        dur   = 0.0
        trans = slide._element.find(f"{{{NS_PPT}}}transition")
        if trans is not None:
            raw = trans.get("dur")
            if raw is not None:
                try:
                    dur = int(raw) / 1000.0
                except (ValueError, TypeError):
                    pass
            if dur == 0.0:
                spd = trans.get("spd", "")
                dur = _SPD_TO_SEC.get(spd, 0.5 if spd else 0.0)
        result.append(dur)
        sys.stderr.write(f"  [transition] slide {idx}: {dur:.2f}s\n")
    return result


def compute_record_duration(click_steps, transition_durations=None):
    """
    Compute the total FFmpeg recording time (seconds) for a click-driven PPTX.

    Per slide:  wait_first + steps × ANIM_WAIT_BETWEEN_STEPS
                  where wait_first = max(ANIM_WAIT_BEFORE_FIRST,
                                         transition_durations[i] + TRANSITION_BUFFER)
                  Slides with entrance transitions wait longer than ANIM_WAIT_BEFORE_FIRST
                  to avoid sending a keypress during the transition (which causes LO to
                  skip the slide entirely instead of triggering the first animation).
    Plus:       total_arrows × XDOTOOL_OVERHEAD_PER_ARROW
                  Each xdotool subprocess call adds real execution time that the
                  ANIM_WAIT_* constants do not include.  Without this padding the
                  -t limit can fire before the last slide finishes playing.
    Plus:       XVFB_END_BUFFER tail buffer

    Total arrows:
        Non-last slides: steps[i] + 1  (animations + advance)
        Last slide:      steps[-1]     (animations only, no advance)
    """
    if transition_durations is None:
        transition_durations = [0.0] * len(click_steps)
    total = 0.0
    for i, steps in enumerate(click_steps):
        td = transition_durations[i] if i < len(transition_durations) else 0.0
        wait_first = (max(ANIM_WAIT_BEFORE_FIRST, td + TRANSITION_BUFFER)
                      if td > 0.0 else ANIM_WAIT_BEFORE_FIRST)
        total += wait_first + steps * ANIM_WAIT_BETWEEN_STEPS
    if click_steps:
        total_arrows = (
            sum(s + 1 for s in click_steps[:-1]) + click_steps[-1]
        )
    else:
        total_arrows = 0
    overhead = total_arrows * XDOTOOL_OVERHEAD_PER_ARROW
    return total + overhead + XVFB_END_BUFFER


def compute_slide_boundaries(click_steps, transition_durations=None):
    """
    Compute exact per-slide split boundaries that match the Xvfb-captured video.

    These values are derived from the same timing constants used during
    recording, so they align precisely with the actual slide transitions.

    Timeline from FFmpeg t=0:
      t = 0                       FFmpeg starts recording (slide 1 already visible)
      t = FFMPEG_PRE_ROLL         xdotool begins; waits ANIM_WAIT_BEFORE_FIRST
      t = PRE_ROLL + BEFORE_FIRST + steps[0]*BETWEEN_STEPS
                                  advance arrow sent → slide 2 starts transition
                                  → boundary[1]
      t = boundary[i] + BEFORE_FIRST + steps[i]*BETWEEN_STEPS
                                  → boundary[i+1]
      t = total_record_duration   FFmpeg stops (-t flag) → last boundary

    The LAST boundary is set to total_record_duration (not the computed sum)
    so the final slide's clip absorbs the XVFB_END_BUFFER tail.

    transition_durations: per-slide entrance-transition duration in seconds
        (from get_slide_transition_durations).  Used to compute clip_starts.
        The advance arrow is sent at boundaries[i]; the incoming slide is not
        fully visible until boundaries[i] + transition_durations[i].  Trimming
        each clip to start at clip_starts[i] therefore skips the outgoing-slide
        frame that would otherwise pollute the head of each per-slide video.

        Even when the PPTX has no <p:transition> element (transition_dur=0.0),
        LibreOffice takes MIN_SLIDE_SWITCH_DELAY seconds to render the switch.
        clip_starts therefore uses max(transition_dur, MIN_SLIDE_SWITCH_DELAY)
        so that at minimum a half-second guard is always applied.

    Returns:
        boundaries        – list of (n+1) floats: [0.0, t1, ..., total_record]
        slide_durations   – list of n floats: boundaries[i+1] − boundaries[i]
        total_record      – total FFmpeg recording duration
        clip_starts       – list of n floats: actual trim-start for each clip
    """
    if transition_durations is None:
        transition_durations = [0.0] * len(click_steps)

    total_record = compute_record_duration(click_steps, transition_durations)
    boundaries = [0.0]
    t = FFMPEG_PRE_ROLL
    for i, steps in enumerate(click_steps):
        td = transition_durations[i] if i < len(transition_durations) else 0.0
        wait_first = (max(ANIM_WAIT_BEFORE_FIRST, td + TRANSITION_BUFFER)
                      if td > 0.0 else ANIM_WAIT_BEFORE_FIRST)
        t += wait_first + steps * ANIM_WAIT_BETWEEN_STEPS
        if i < len(click_steps) - 1:
            boundaries.append(round(t, 3))
    # Last boundary = total record duration so the final clip absorbs the tail
    boundaries.append(round(total_record, 3))
    slide_durations = [
        round(boundaries[i + 1] - boundaries[i], 3)
        for i in range(len(click_steps))
    ]

    # clip_starts[i]: the actual trim-start timestamp for each per-slide clip.
    #   Clip 0: always 0.0 — slide 1 is already on screen when FFmpeg starts.
    #   Clip i>0: advance arrow sent at boundaries[i], then LibreOffice plays a
    #     transition animation before the new slide is fully visible.  The offset
    #     is (td + TRANSITION_BUFFER) when td > 0, else MIN_SLIDE_SWITCH_DELAY.
    #     This matches the extra wait used in _advance_slides_xdotool so that
    #     clips start after the transition is fully rendered and the first
    #     animation key has been sent.
    clip_starts = [0.0]
    for i in range(1, len(click_steps)):
        td = transition_durations[i] if i < len(transition_durations) else 0.0
        offset = (td + TRANSITION_BUFFER) if td > 0.0 else MIN_SLIDE_SWITCH_DELAY
        offset    = max(offset, MIN_SLIDE_SWITCH_DELAY)
        raw_start = boundaries[i] + offset
        # Safety clamp: leave at least 0.1 s of content in the clip
        max_start = round(boundaries[i + 1] - 0.1, 3)
        clip_starts.append(round(min(raw_start, max_start), 3))

    return boundaries, slide_durations, total_record, clip_starts


# ─────────────────────────────────────────────────────────────────────────────
# PRIMARY: Xvfb + LibreOffice slideshow + FFmpeg screen capture
# ─────────────────────────────────────────────────────────────────────────────

def _find_binary(name, fallback_paths=None):
    """
    Locate a system binary, returning its absolute path or None.

    Web-server processes (Apache, PHP-FPM) inherit a stripped PATH that
    often excludes /bin and /usr/local/bin.  shutil.which() only searches
    PATH, so binaries may not be found even if they exist on the filesystem.
    This function tries shutil.which() first, then probes each fallback path.
    """
    found = shutil.which(name)
    if found:
        return found
    for p in (fallback_paths or []):
        if os.path.isfile(p) and os.access(p, os.X_OK):
            return p
    return None


def _find_xvfb():
    """Return the absolute path to Xvfb, or None if not installed."""
    path = _find_binary("Xvfb", [
        "/bin/Xvfb",
        "/usr/bin/Xvfb",
        "/usr/local/bin/Xvfb",
    ])
    sys.stderr.write(
        f"  [xvfb] Binary search: Xvfb → {path or 'NOT FOUND'}\n"
        f"  [xvfb] PATH visible to this process: {os.environ.get('PATH', '(empty)')}\n"
    )
    return path


def _find_xdotool():
    """Return the absolute path to xdotool, or None if not installed."""
    return _find_binary("xdotool", [
        "/bin/xdotool",
        "/usr/bin/xdotool",
        "/usr/local/bin/xdotool",
    ])


def _xvfb_available():
    """Return True if Xvfb can be found."""
    return _find_xvfb() is not None


def _start_window_manager(env, log_fh):
    """
    Start a minimal window manager on the Xvfb display and return its Popen.

    WHY THIS IS REQUIRED
    ━━━━━━━━━━━━━━━━━━━
    Without a WM running on the virtual display, two critical things break:

    1. LibreOffice --show DOES NOT go fullscreen.
       Xvfb has no window manager to honour the _NET_WM_STATE_FULLSCREEN
       EWMH hint, so LibreOffice falls back to opening in *edit* mode.
       The slideshow never starts — the video records the editor's
       first-slide thumbnail for the entire duration.

    2. xdotool windowactivate FAILS with:
       "Your windowmanager claims not to support _NET_ACTIVE_WINDOW"
       Without window activation, LibreOffice never receives keyboard focus,
       and Right Arrow keypresses cannot advance slides.

    A minimal WM (openbox, fluxbox, twm …) fixes both problems.
    Install: sudo apt install openbox

    Returns the Popen object if a WM was started, or None if none found.
    """
    # (friendly_name, binary, extra_args, known_absolute_paths)
    candidates = [
        ("openbox",  "openbox",   ["--sm-disable"],
         ["/usr/bin/openbox",   "/bin/openbox"]),
        ("fluxbox",  "fluxbox",   [],
         ["/usr/bin/fluxbox",   "/bin/fluxbox"]),
        ("twm",      "twm",       [],
         ["/usr/bin/twm",       "/bin/twm"]),
        ("metacity", "metacity",  ["--sm-disable"],
         ["/usr/bin/metacity",  "/bin/metacity"]),
        ("xfwm4",    "xfwm4",    ["--sm-disable"],
         ["/usr/bin/xfwm4",    "/bin/xfwm4"]),
        ("icewm",    "icewm",    [],
         ["/usr/bin/icewm",    "/bin/icewm"]),
    ]

    for name, binary, extra_args, fallback_paths in candidates:
        wm_bin = _find_binary(binary, fallback_paths)
        if wm_bin is None:
            log_fh.write(f"[wm] {name}: not found\n")
            continue

        cmd = [wm_bin] + extra_args
        log_fh.write(f"[wm] Trying {name}: {cmd}\n")
        sys.stderr.write(f"  [wm] Starting {name} window manager...\n")
        try:
            wm = subprocess.Popen(
                cmd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                env=env,
            )
            time.sleep(1.5)   # let WM initialise and register with X11
            if wm.poll() is None:
                log_fh.write(f"[wm] {name} running (pid={wm.pid})\n")
                sys.stderr.write(f"  [wm] {name} running (pid={wm.pid})\n")
                return wm
            else:
                log_fh.write(f"[wm] {name} exited immediately (rc={wm.returncode})\n")
        except Exception as exc:
            log_fh.write(f"[wm] {name}: failed to start — {exc}\n")

    log_fh.write(
        "[wm] *** NO WINDOW MANAGER FOUND ***\n"
        "     LibreOffice will open in EDIT mode instead of slideshow mode.\n"
        "     Install openbox to fix this:  sudo apt install openbox\n"
    )
    sys.stderr.write(
        "  [wm] WARNING: No window manager found.\n"
        "       LibreOffice may open in edit mode (first slide repeated).\n"
        "       Fix: sudo apt install openbox\n"
    )
    return None


def _create_lo_offline_profile(home_dir):
    """
    Pre-create a LibreOffice user profile that disables ALL network activity.
    Writing this registry file before LibreOffice starts suppresses update
    checks, crash reports, first-run wizard, and telemetry.
    """
    profile_dir = os.path.join(home_dir, ".config", "libreoffice", "4", "user")
    os.makedirs(profile_dir, exist_ok=True)

    reg_path = os.path.join(profile_dir, "registrymodifications.xcu")
    with open(reg_path, "w", encoding="utf-8") as fh:
        fh.write("""\
<?xml version="1.0" encoding="UTF-8"?>
<oor:items xmlns:oor="http://openoffice.org/2001/registry"
           xmlns:xs="http://www.w3.org/2001/XMLSchema"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

  <!-- ── Update checks ───────────────────────────────────────────── -->
  <item oor:path="/org.openoffice.Office.Common/Online/Update">
    <prop oor:name="AutoCheckEnabled" oor:op="fuse">
      <value>false</value>
    </prop>
    <prop oor:name="IsEnabled" oor:op="fuse">
      <value>false</value>
    </prop>
  </item>

  <!-- ── First-run wizard (makes network calls on first launch) ─── -->
  <item oor:path="/org.openoffice.Setup/Office">
    <prop oor:name="FirstStartWizardCompleted" oor:op="fuse">
      <value>true</value>
    </prop>
  </item>

  <!-- ── Crash reporting and telemetry ───────────────────────────── -->
  <item oor:path="/org.openoffice.Office.Common/Misc">
    <prop oor:name="SendCrashReport" oor:op="fuse">
      <value>false</value>
    </prop>
    <prop oor:name="CollectUsageInformation" oor:op="fuse">
      <value>false</value>
    </prop>
    <!-- ── "Did you know?" / Tip of the Day dialog ────────────────── -->
    <!-- Without this the dialog appears ~2 s after LO loads, is NOT -->
    <!-- matched by _window_looks_like_editor(), and is mistakenly    -->
    <!-- treated as the slideshow window — F5 is never sent and the   -->
    <!-- slideshow never starts in fullscreen mode.                   -->
    <prop oor:name="ShowTipOfTheDay" oor:op="fuse">
      <value>false</value>
    </prop>
  </item>

  <!-- ── "What's New in LibreOffice X.Y" information bar ─────────── -->
  <!-- Setting ooSetupLastVersion to a future version prevents LO     -->
  <!-- from showing the yellow "new version" notification bar which   -->
  <!-- can steal focus and block the slideshow transition.            -->
  <item oor:path="/org.openoffice.Setup/Product">
    <prop oor:name="ooSetupLastVersion" oor:op="fuse">
      <value>99.9.999.0</value>
    </prop>
  </item>

</oor:items>
""")
    sys.stderr.write(f"  [offline] LibreOffice offline profile → {profile_dir}\n")


def _expand_smartart_to_flat_shapes(slide, ppt_path):
    """
    When ALL click-group animations on a slide target SmartArt Pattern B
    sub-nodes (<p:graphicEl><a:dgm id="{GUID}"/>), LibreOffice cannot animate
    individual sub-nodes — it applies every set/anim to the whole graphicFrame,
    making all shapes appear simultaneously on click 1.

    This function replaces the graphicFrame with individual flat <p:sp> shapes
    whose geometry/fill/text is read from the companion dsp:drawing XML in the
    PPTX ZIP.  It then builds new per-shape timing that LibreOffice CAN process:

        Click 1 → central hub shape becomes visible
        Click 2 → arrow[1] + outer ellipse[2] become visible  (one branch)
        Click 3 → arrow[3] + outer ellipse[4]
        ...

    The reveal grouping is determined by the drawing XML order: un-targeted
    shapes (the outer ellipses) are paired with the nearest preceding targeted
    shape (the arrows) to form logical branches.

    Returns the number of diagrams expanded (0 if nothing was done / on error).
    """
    _NS_PKG_REL      = "http://schemas.openxmlformats.org/package/2006/relationships"
    _NS_DSP          = "http://schemas.microsoft.com/office/drawing/2008/diagram"
    _DIAG_DRAWING_REL = (
        "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing"
    )
    _gEdgm_path = f"{{{NS_PPT}}}graphicEl/{{{NS_A}}}dgm"

    timing = slide._element.find(f"{{{NS_PPT}}}timing")
    if timing is None:
        return 0

    # ── Only proceed when ALL click groups are SmartArt Pattern B ────────────
    all_clicks = [
        ctn for ctn in timing.iter(f"{{{NS_PPT}}}cTn")
        if ctn.get("nodeType") == "clickEffect"
    ]
    if not all_clicks:
        return 0

    sa_clicks = [
        ctn for ctn in all_clicks
        if any(
            tgt.get("dgm") is not None or
            tgt.find(_gEdgm_path) is not None
            for tgt in ctn.iter(f"{{{NS_PPT}}}spTgt")
        )
    ]
    if len(sa_clicks) != len(all_clicks) or not sa_clicks:
        return 0  # mixed slide or no SmartArt

    # ── Extract the representative GUID for each click group (first found) ───
    click_guids = []
    for ctn in sa_clicks:
        found_guid = ""
        for tgt in ctn.iter(f"{{{NS_PPT}}}spTgt"):
            ge = tgt.find(f"{{{NS_PPT}}}graphicEl")
            if ge is not None:
                adgm = ge.find(f"{{{NS_A}}}dgm")
                if adgm is not None:
                    found_guid = adgm.get("id", "")
                    break
            elif tgt.get("dgm"):
                found_guid = tgt.get("dgm")
                break
        click_guids.append(found_guid)

    # ── Locate and read the dsp:drawing XML from the PPTX ZIP ────────────────
    slide_partname = str(slide.part.partname)          # "/ppt/slides/slide3.xml"
    slide_dir, slide_fname = slide_partname.lstrip("/").rsplit("/", 1)
    rels_path = f"{slide_dir}/_rels/{slide_fname}.rels"

    try:
        with zipfile.ZipFile(ppt_path) as z:
            if rels_path not in z.namelist():
                return 0
            rels_root = etree.fromstring(z.read(rels_path))
            drawing_target = None
            for rel in rels_root.findall(f"{{{_NS_PKG_REL}}}Relationship"):
                if rel.get("Type") == _DIAG_DRAWING_REL:
                    raw = rel.get("Target", "")
                    drawing_target = posixpath.normpath(
                        posixpath.join(slide_dir, raw)
                    )
                    break
            if drawing_target is None or drawing_target not in z.namelist():
                return 0
            drawing_root = etree.fromstring(z.read(drawing_target))
    except Exception as exc:
        sys.stderr.write(
            f"  [xvfb] WARNING: SmartArt expand – could not read drawing: {exc}\n"
        )
        return 0

    sps_in_drawing = drawing_root.findall(
        f"{{{_NS_DSP}}}spTree/{{{_NS_DSP}}}sp"
    )
    if not sps_in_drawing:
        return 0

    # ── Map GUID → drawing shape index ───────────────────────────────────────
    guid_to_idx = {}
    for i, sp in enumerate(sps_in_drawing):
        mid = sp.get("modelId", "")
        if mid:
            guid_to_idx[mid] = i

    # ── Determine which drawing shapes each click group targets ───────────────
    click_to_primary_idx = {}   # click_group_k → drawing index of targeted shape
    targeted_set = set()
    for k, guid in enumerate(click_guids):
        di = guid_to_idx.get(guid)
        if di is not None:
            click_to_primary_idx[k] = di
            targeted_set.add(di)

    if not click_to_primary_idx:
        return 0

    # ── Group shapes to reveal per click ─────────────────────────────────────
    # Un-targeted drawing shapes are paired with the nearest targeted shape
    # by drawing index (bidirectional), with spatial center-Y as a tie-breaker.
    #
    # "Nearest preceding" alone fails for SmartArt list layouts (slide 2) where
    # text rects PRECEDE their number ellipses in drawing order — the old code
    # would pair rect[3] with blockArc[0] (click 1) instead of ellipse[4] (click 2),
    # causing text to appear one click before its number.  Bidirectional nearest +
    # center-Y tie-breaking correctly handles both list layouts (slide 2) and
    # radial layouts (slide 3).
    center_y_map = {}
    for i, dsp_sp in enumerate(sps_in_drawing):
        dsp_spPr = dsp_sp.find(f"{{{_NS_DSP}}}spPr")
        xfrm     = dsp_spPr.find(f"{{{NS_A}}}xfrm") if dsp_spPr is not None else None
        off      = xfrm.find(f"{{{NS_A}}}off")       if xfrm is not None else None
        ext      = xfrm.find(f"{{{NS_A}}}ext")       if xfrm is not None else None
        if off is not None and ext is not None:
            try:
                center_y_map[i] = int(off.get("y", "0")) + int(ext.get("cy", "0")) // 2
            except ValueError:
                center_y_map[i] = 0
        else:
            center_y_map[i] = 0

    reveal_groups = {k: [v] for k, v in click_to_primary_idx.items()}
    for di in range(len(sps_in_drawing)):
        if di in targeted_set:
            continue
        cy_di       = center_y_map.get(di, 0)
        best_k      = None
        best_dist   = float("inf")
        best_cy_gap = float("inf")
        for k2, pri in click_to_primary_idx.items():
            dist   = abs(pri - di)
            cy_gap = abs(center_y_map.get(pri, 0) - cy_di)
            if dist < best_dist or (dist == best_dist and cy_gap < best_cy_gap):
                best_dist   = dist
                best_cy_gap = cy_gap
                best_k      = k2
        if best_k is not None:
            reveal_groups[best_k].append(di)

    # ── Find the graphicFrame element and its position on the slide ───────────
    # All click groups target the same spid; grab it from the first one.
    sa_spid = ""
    for ctn in sa_clicks:
        for tgt in ctn.iter(f"{{{NS_PPT}}}spTgt"):
            sa_spid = tgt.get("spid", "")
            break
        if sa_spid:
            break

    cSld = slide._element.find(f"{{{NS_PPT}}}cSld")
    if cSld is None:
        return 0
    spTree = cSld.find(f"{{{NS_PPT}}}spTree")
    if spTree is None:
        return 0

    gf_elem = None
    gf_x = gf_y = 0
    for child in list(spTree):
        local_tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
        if local_tag != "graphicFrame":
            continue
        cNvPr_gf = child.find(f".//{{{NS_PPT}}}cNvPr")
        if cNvPr_gf is None:
            cNvPr_gf = child.find(f".//{{{NS_A}}}cNvPr")
        if cNvPr_gf is not None and cNvPr_gf.get("id", "") == sa_spid:
            gf_elem = child
            gf_xfrm = child.find(f"{{{NS_PPT}}}xfrm")
            if gf_xfrm is not None:
                gf_off = gf_xfrm.find(f"{{{NS_A}}}off")
                if gf_off is not None:
                    gf_x = int(gf_off.get("x", "0"))
                    gf_y = int(gf_off.get("y", "0"))
            break

    if gf_elem is None:
        return 0

    # ── Allocate new shape IDs (must be unique within the slide) ─────────────
    max_spid = 0
    for elem in slide._element.iter():
        local_tag = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag
        if local_tag == "cNvPr":
            try:
                v = int(elem.get("id", "0"))
                if v > max_spid:
                    max_spid = v
            except ValueError:
                pass

    new_spids = {}
    for i in range(len(sps_in_drawing)):
        max_spid += 1
        new_spids[i] = max_spid

    # ── Create flat <p:sp> elements from dsp:drawing shapes ──────────────────
    new_shape_elems = []
    for i, dsp_sp in enumerate(sps_in_drawing):
        dsp_spPr = dsp_sp.find(f"{{{_NS_DSP}}}spPr")
        if dsp_spPr is None:
            continue
        xfrm = dsp_spPr.find(f"{{{NS_A}}}xfrm")
        if xfrm is None:
            continue
        off = xfrm.find(f"{{{NS_A}}}off")
        ext = xfrm.find(f"{{{NS_A}}}ext")
        if off is None or ext is None:
            continue

        abs_x  = gf_x + int(off.get("x", "0"))
        abs_y  = gf_y + int(off.get("y", "0"))
        cx     = int(ext.get("cx", "0"))
        cy     = int(ext.get("cy", "0"))
        rot    = xfrm.get("rot", "")
        flip_h = xfrm.get("flipH", "")
        flip_v = xfrm.get("flipV", "")

        prstGeom  = dsp_spPr.find(f"{{{NS_A}}}prstGeom")
        prst      = prstGeom.get("prst", "rect") if prstGeom is not None else "rect"
        avLst     = (prstGeom.find(f"{{{NS_A}}}avLst")
                     if prstGeom is not None else None)
        solidFill = dsp_spPr.find(f"{{{NS_A}}}solidFill")
        ln        = dsp_spPr.find(f"{{{NS_A}}}ln")
        dsp_txBody = dsp_sp.find(f"{{{_NS_DSP}}}txBody")

        spid = new_spids[i]

        # Build <p:sp>
        sp_elem = etree.Element(f"{{{NS_PPT}}}sp")

        # nvSpPr
        nvSpPr = etree.SubElement(sp_elem, f"{{{NS_PPT}}}nvSpPr")
        cNvPr_new = etree.SubElement(nvSpPr, f"{{{NS_PPT}}}cNvPr")
        cNvPr_new.set("id", str(spid))
        cNvPr_new.set("name", f"SmartArt_flat_{i}")
        etree.SubElement(nvSpPr, f"{{{NS_PPT}}}cNvSpPr")
        etree.SubElement(nvSpPr, f"{{{NS_PPT}}}nvPr")

        # spPr
        spPr = etree.SubElement(sp_elem, f"{{{NS_PPT}}}spPr")
        a_xfrm = etree.SubElement(spPr, f"{{{NS_A}}}xfrm")
        if rot:
            a_xfrm.set("rot", rot)
        if flip_h:
            a_xfrm.set("flipH", flip_h)
        if flip_v:
            a_xfrm.set("flipV", flip_v)
        a_off = etree.SubElement(a_xfrm, f"{{{NS_A}}}off")
        a_off.set("x", str(abs_x))
        a_off.set("y", str(abs_y))
        a_ext = etree.SubElement(a_xfrm, f"{{{NS_A}}}ext")
        a_ext.set("cx", str(cx))
        a_ext.set("cy", str(cy))

        a_prstGeom = etree.SubElement(spPr, f"{{{NS_A}}}prstGeom")
        a_prstGeom.set("prst", prst)
        if avLst is not None:
            a_prstGeom.append(copy.deepcopy(avLst))
        else:
            etree.SubElement(a_prstGeom, f"{{{NS_A}}}avLst")

        if solidFill is not None:
            spPr.append(copy.deepcopy(solidFill))
        if ln is not None:
            spPr.append(copy.deepcopy(ln))

        # txBody (copy a:bodyPr, a:lstStyle, a:p children from dsp:txBody)
        txBody = etree.SubElement(sp_elem, f"{{{NS_PPT}}}txBody")
        if dsp_txBody is not None:
            body_pr   = dsp_txBody.find(f"{{{NS_A}}}bodyPr")
            lst_style = dsp_txBody.find(f"{{{NS_A}}}lstStyle")
            p_elems   = dsp_txBody.findall(f"{{{NS_A}}}p")
            txBody.append(copy.deepcopy(body_pr)   if body_pr   is not None
                          else etree.Element(f"{{{NS_A}}}bodyPr"))
            txBody.append(copy.deepcopy(lst_style) if lst_style is not None
                          else etree.Element(f"{{{NS_A}}}lstStyle"))
            for p in p_elems:
                txBody.append(copy.deepcopy(p))
            if not p_elems:
                etree.SubElement(txBody, f"{{{NS_A}}}p")
        else:
            etree.SubElement(txBody, f"{{{NS_A}}}bodyPr")
            etree.SubElement(txBody, f"{{{NS_A}}}lstStyle")
            etree.SubElement(txBody, f"{{{NS_A}}}p")

        new_shape_elems.append((i, sp_elem))

    if not new_shape_elems:
        return 0

    # ── Insert flat shapes into spTree at the graphicFrame position ───────────
    gf_pos = list(spTree).index(gf_elem)
    for j, (_, sp_elem) in enumerate(new_shape_elems):
        spTree.insert(gf_pos + j, sp_elem)
    spTree.remove(gf_elem)

    # ── Build new timing XML (per-shape appear animations) ───────────────────
    P = NS_PPT
    id_ctr = [1]

    def nid():
        v = id_ctr[0]
        id_ctr[0] += 1
        return v

    new_timing = etree.Element(f"{{{P}}}timing")
    tnLst     = etree.SubElement(new_timing, f"{{{P}}}tnLst")
    root_par  = etree.SubElement(tnLst,     f"{{{P}}}par")
    root_cTn  = etree.SubElement(root_par,  f"{{{P}}}cTn")
    root_cTn.set("id", str(nid()))
    root_cTn.set("dur", "indefinite")
    root_cTn.set("restart", "never")
    root_cTn.set("nodeType", "tmRoot")
    root_ch   = etree.SubElement(root_cTn,  f"{{{P}}}childTnLst")

    seq = etree.SubElement(root_ch, f"{{{P}}}seq")
    seq.set("concurrent", "1")
    seq.set("nextAc", "seek")

    main_cTn = etree.SubElement(seq, f"{{{P}}}cTn")
    main_cTn.set("id", str(nid()))
    main_cTn.set("dur", "indefinite")
    main_cTn.set("nodeType", "mainSeq")
    main_ch  = etree.SubElement(main_cTn, f"{{{P}}}childTnLst")

    # One click group per entry in reveal_groups
    for k in range(len(sa_clicks)):
        idxs     = reveal_groups.get(k, [])
        spids_rv = [new_spids[di] for di in idxs if di in new_spids]
        if not spids_rv:
            continue
        grp_id = k + 1

        outer_par = etree.SubElement(main_ch,   f"{{{P}}}par")
        outer_cTn = etree.SubElement(outer_par, f"{{{P}}}cTn")
        outer_cTn.set("id", str(nid()))
        outer_cTn.set("fill", "hold")
        outer_st  = etree.SubElement(outer_cTn, f"{{{P}}}stCondLst")
        outer_c   = etree.SubElement(outer_st,  f"{{{P}}}cond")
        outer_c.set("delay", "indefinite")   # ← click-triggered
        outer_ch  = etree.SubElement(outer_cTn, f"{{{P}}}childTnLst")

        inner_par = etree.SubElement(outer_ch,   f"{{{P}}}par")
        inner_cTn = etree.SubElement(inner_par,  f"{{{P}}}cTn")
        inner_cTn.set("id", str(nid()))
        inner_cTn.set("fill", "hold")
        inner_st  = etree.SubElement(inner_cTn,  f"{{{P}}}stCondLst")
        inner_c   = etree.SubElement(inner_st,   f"{{{P}}}cond")
        inner_c.set("delay", "0")
        inner_ch  = etree.SubElement(inner_cTn,  f"{{{P}}}childTnLst")

        for j, spid in enumerate(spids_rv):
            node_type = "clickEffect" if j == 0 else "withEffect"
            eff_par = etree.SubElement(inner_ch,  f"{{{P}}}par")
            eff_cTn = etree.SubElement(eff_par,   f"{{{P}}}cTn")
            eff_cTn.set("id", str(nid()))
            eff_cTn.set("presetID", "1")
            eff_cTn.set("presetClass", "entr")
            eff_cTn.set("presetSubtype", "0")
            eff_cTn.set("fill", "hold")
            eff_cTn.set("grpId", str(grp_id))
            eff_cTn.set("nodeType", node_type)
            eff_st  = etree.SubElement(eff_cTn,   f"{{{P}}}stCondLst")
            eff_c   = etree.SubElement(eff_st,    f"{{{P}}}cond")
            eff_c.set("delay", "0")
            eff_ch  = etree.SubElement(eff_cTn,   f"{{{P}}}childTnLst")

            # <p:set style.visibility = visible>
            set_e  = etree.SubElement(eff_ch,  f"{{{P}}}set")
            cBhvr  = etree.SubElement(set_e,   f"{{{P}}}cBhvr")
            set_cTn = etree.SubElement(cBhvr,  f"{{{P}}}cTn")
            set_cTn.set("id", str(nid()))
            set_cTn.set("dur", "1")
            set_cTn.set("fill", "hold")
            s_st   = etree.SubElement(set_cTn, f"{{{P}}}stCondLst")
            s_c    = etree.SubElement(s_st,    f"{{{P}}}cond")
            s_c.set("delay", "0")
            tgtEl  = etree.SubElement(cBhvr,   f"{{{P}}}tgtEl")
            spTgt  = etree.SubElement(tgtEl,   f"{{{P}}}spTgt")
            spTgt.set("spid", str(spid))
            attrNL = etree.SubElement(cBhvr,   f"{{{P}}}attrNameLst")
            attrN  = etree.SubElement(attrNL,  f"{{{P}}}attrName")
            attrN.text = "style.visibility"
            to_e   = etree.SubElement(set_e,   f"{{{P}}}to")
            strVal = etree.SubElement(to_e,    f"{{{P}}}strVal")
            strVal.set("val", "visible")

    # prevCondLst / nextCondLst (standard slide navigation)
    prevCL = etree.SubElement(seq,    f"{{{P}}}prevCondLst")
    pc     = etree.SubElement(prevCL, f"{{{P}}}cond")
    pc.set("evt", "onPrev")
    pc.set("delay", "0")
    pc_t   = etree.SubElement(pc,     f"{{{P}}}tgtEl")
    etree.SubElement(pc_t,            f"{{{P}}}sldTgt")

    nextCL = etree.SubElement(seq,    f"{{{P}}}nextCondLst")
    nc     = etree.SubElement(nextCL, f"{{{P}}}cond")
    nc.set("evt", "onNext")
    nc.set("delay", "0")
    nc_t   = etree.SubElement(nc,     f"{{{P}}}tgtEl")
    etree.SubElement(nc_t,            f"{{{P}}}sldTgt")

    etree.SubElement(new_timing, f"{{{P}}}bldLst")

    # ── Replace timing element in slide ──────────────────────────────────────
    slide_el  = slide._element
    old_timing = slide_el.find(f"{{{P}}}timing")
    if old_timing is not None:
        t_pos = list(slide_el).index(old_timing)
        slide_el.remove(old_timing)
        slide_el.insert(t_pos, new_timing)
    else:
        slide_el.append(new_timing)

    return 1


def _prepare_pptx_click_only(ppt_path, tmp_dir):
    """
    Save a temporary copy of the PPTX where:
      • Every slide advances ONLY on an explicit click/keypress (advClick="1",
        advTm removed).
      • All transition animations are stripped — <p:transition> keeps only the
        advClick="1" attribute so LibreOffice uses an instant (no-animation)
        cut between slides.
      • All AfterPrevious / WithPrevious animation durations are set to 1 ms
        so they complete instantly and do not block the advance arrow.

    WHY strip transition animations:
        LibreOffice Impress has a "transition-skip" bug on headless Xvfb servers:
        if any keypress arrives while an entrance transition is still playing,
        LO skips the current slide entirely (advances to the next one) rather
        than completing the transition and triggering the animation.  On a
        headless server the actual transition duration can be 2–3× longer than
        the nominal spd value from the PPTX, so even generous TRANSITION_BUFFER
        values are not reliable.  Stripping the transition type child element
        (e.g. <p:cover>, <p:pull>) and the spd/dur attributes forces LibreOffice
        to use an instant cut, which eliminates the skip-bug entirely.

        Per-slide SCORM clips each start with the target slide already visible,
        so the absence of an inter-slide transition animation has no visible
        effect on the final deliverable.

    WHY remove advTm:
        LibreOffice's internal auto-advance timer does NOT fire reliably on a
        headless Xvfb display.  Removing advTm and using xdotool keypresses is
        the only reliable way to drive slide advancement.

    WHY collapse afterEffect / withEffect animations to near-instant:
        PPTX slides can contain AfterPrevious animations (nodeType="afterEffect"
        / "withEffect") that auto-play after the last user-click group.  Three
        separate problems must all be fixed:

        (a) STAGGER DELAYS — stCondLst/cond delay attrs (500, 1500, …, 11000 ms)
            mean the last auto-animation does not even START until 11 s after the
            click group fires.  When our advance key arrives at T=3 s
            (ANIM_WAIT_BETWEEN_STEPS), LO skips remaining animations to
            completion but does NOT advance the slide on that keypress — it waits
            for a second keypress.  This cascades every subsequent arrow one step
            behind, so all later slides show wrong content.
            Fix: set delay="0" on every stCondLst/cond element.

        (b) DURATION NOT ON OUTER NODE — the previous fix had
            `if "dur" in ctn.attrib` which silently skipped any afterEffect node
            whose duration was specified in a child <p:cTn> rather than as an
            attribute on the outer nodeType="afterEffect" <p:cTn>.
            Fix: always write dur="1" on the outer afterEffect cTn AND on every
            descendant cTn inside it, regardless of whether the attribute was
            previously present.

        (c) INDEFINITE REPEAT — some afterEffect animations use
            repeatCount="indefinite" (e.g. a colour-pulse that loops until
            onNext).  Even with delay=0 and dur=1 they cycle every 1 ms and
            block the slide-advance keypress from being interpreted as "advance".
            Fix: set repeatCount="1" on any inner cTn with indefinite repeat.

        With all three fixes applied, every auto-animation starts at T=0,
        completes in 1 ms, and plays once.  By the time ANIM_WAIT_BETWEEN_STEPS
        (3 s) elapses and the advance arrow is sent, all auto-animations are
        long finished and LO advances to the next slide cleanly.
    """
    prs = Presentation(ppt_path)

    modified = 0
    anim_stripped = 0
    after_effect_fixed = 0
    smartart_anim_stripped = 0
    smartart_collapsed = 0
    smartart_expanded = 0

    for slide in prs.slides:
        # ── 1. Strip transition animation (prevents transition-skip bug) ──────
        trans = slide._element.find(f"{{{NS_PPT}}}transition")
        if trans is not None:
            # Remove the auto-advance timer if it exists
            if "advTm" in trans.attrib:
                del trans.attrib["advTm"]
                modified += 1

            # Remove the transition TYPE child element (<p:cover>, <p:pull>, …)
            children = list(trans)
            if children:
                for child in children:
                    trans.remove(child)
                anim_stripped += 1

            # Remove speed/duration attributes
            for attr in ("spd", "dur"):
                if attr in trans.attrib:
                    del trans.attrib[attr]

            # Ensure click-advance is explicitly enabled
            trans.set("advClick", "1")

        # ── 2. Collapse afterEffect / withEffect to near-instant ─────────────
        # Each afterEffect/withEffect animation fires AUTOMATICALLY after the
        # preceding click group without a user keypress.  Three bugs in the old
        # fix left the stagger problem fully intact:
        #
        # (a) THE STAGGER DELAY IS ON THE *WRAPPER* cTn, NOT ON THE afterEffect
        #     The PPTX tree is:
        #       click_group_cTn (delay="indefinite")
        #         childTnLst
        #           par
        #             wrapper_cTn (nodeType="", delay="11000")  ← delay IS HERE
        #               stCondLst / cond delay="11000"
        #               childTnLst
        #                 par
        #                   cTn nodeType="afterEffect"          ← delay="0" here
        #
        #     Zeroing the afterEffect cTn's own stCondLst/cond delay (as the
        #     previous fix tried) has NO effect because that delay is already 0.
        #     The wrapper_cTn is 3 ancestor levels above the afterEffect cTn.
        #     Fix: build a parent-map, walk up 3 levels from each afterEffect/
        #     withEffect cTn, and zero the wrapper cTn's stCondLst/cond delay.
        #
        # (b) afterEffect cTns have NO dur= attribute on themselves; duration is
        #     inherited from children.  The old `if "dur" in ctn.attrib` guard
        #     silently skipped every such node (i.e. ALL of them).
        #     Fix: always write dur="1" on the afterEffect cTn (add if absent).
        #
        # (c) A repeatCount="indefinite" colour-pulse on slide 7 loops forever.
        #     Setting repeatCount="1" makes it play once (1 ms) and finish so the
        #     advance key never arrives while animations are running.
        timing = slide._element.find(f"{{{NS_PPT}}}timing")
        if timing is not None:
            # Build a parent-map so we can walk UP the tree from any element.
            parent_map = {child: parent
                          for parent in timing.iter()
                          for child in parent}

            for ctn in timing.iter(f"{{{NS_PPT}}}cTn"):
                node_type = ctn.get("nodeType", "")
                if node_type not in ("afterEffect", "withEffect"):
                    continue

                # (a) Walk 3 levels up:
                #       afterEffect_cTn → <par> → <childTnLst> → wrapper_cTn
                #     and zero the wrapper's stCondLst/cond delay.
                #
                # The wrapper level also tells us whether this is a true
                # AfterPrevious node (wrapper has a non-zero stagger delay)
                # versus a regular "simultaneous-with-previous" withEffect
                # (wrapper delay already == "0").
                #
                # IMPORTANT: Regular withEffect cTns fire simultaneously with
                # a clickEffect (e.g. wipe-in on a text box alongside another
                # shape).  Setting dur="1" on them would truncate their
                # animation to 1 ms, making them appear as an invisible
                # instant pop instead of the intended wipe/fade.  Only apply
                # dur/repeatCount fixes to true AfterPrevious nodes.
                p1 = parent_map.get(ctn)    # <p:par>
                p2 = parent_map.get(p1)     # <p:childTnLst>
                p3 = parent_map.get(p2)     # wrapper <p:cTn> carrying the delay
                stagger_fixed = False
                if (p3 is not None
                        and p3.tag == f"{{{NS_PPT}}}cTn"
                        and p3.get("nodeType", "") == ""):
                    for stcl in p3.findall(f"{{{NS_PPT}}}stCondLst"):
                        for cond in stcl.findall(f"{{{NS_PPT}}}cond"):
                            d = cond.get("delay", "0")
                            if d not in ("0", "indefinite"):
                                cond.set("delay", "0")
                                after_effect_fixed += 1
                                stagger_fixed = True

                # Only apply dur/repeatCount collapse to true AfterPrevious
                # nodes (afterEffect nodeType, or withEffect with a non-zero
                # stagger delay on its wrapper).  Regular withEffect cTns that
                # sit alongside a clickEffect (wrapper delay already "0") must
                # NOT have their durations collapsed — their wipe/fade
                # animations would be truncated to 1 ms and become invisible.
                is_aftereffect_chain = (node_type == "afterEffect") or stagger_fixed
                if not is_aftereffect_chain:
                    continue

                # (b) Collapse outer afterEffect cTn duration to 1 ms and cap
                #     its own repeatCount (e.g. colour-pulse with indefinite).
                #     Always add dur even if previously absent.
                if ctn.get("dur", "") not in ("0", "1"):
                    ctn.set("dur", "1")
                if ctn.get("repeatCount") == "indefinite":
                    ctn.set("repeatCount", "1")

                # (c) Cap any indefinite-repeat inner node to one play and
                #     collapse any explicit inner durations to 1 ms as well.
                for inner in ctn.iter(f"{{{NS_PPT}}}cTn"):
                    if inner is ctn:
                        continue
                    if inner.get("repeatCount") == "indefinite":
                        inner.set("repeatCount", "1")
                    inner_dur = inner.get("dur", "")
                    if inner_dur not in ("", "0", "1"):
                        inner.set("dur", "1")

        # ── 3. Strip SmartArt sub-node position/scale animations ─────────────
        # LibreOffice Impress cannot target individual SmartArt sub-nodes.
        # SmartArt animations use one of two XML patterns to address specific
        # nodes within a diagram.  LO misapplies any <p:anim ppt_x/ppt_y> or
        # <p:animScale> transforms to the ENTIRE SmartArt container instead of
        # the intended sub-element, causing progressive layout corruption.
        #
        # Two sub-node targeting patterns exist in the wild:
        #
        #   Pattern A (attribute form):
        #     <p:spTgt spid="N" dgm="{GUID}"/>
        #     → detected by: spTgt.get("dgm") is not None
        #
        #   Pattern B (child-element form — THIS PPTX):
        #     <p:spTgt spid="N">
        #       <p:graphicEl><a:dgm id="{GUID}"/></p:graphicEl>
        #     </p:spTgt>
        #     → detected by: spTgt.find("{NS_PPT}graphicEl/{NS_A}dgm") is not None
        #
        # Fix: for every clickEffect/withEffect cTn whose subtree matches
        # EITHER pattern, collect ALL <p:anim>, <p:animScale>, and <p:animEffect>
        # at ANY depth via iter() + parent-map and remove them.
        # <p:set> (visibility toggle) is kept.
        # NOTE: <p:anim>/<p:animScale> may be nested several levels deep inside
        # <p:par>/<p:cTn nodeType="withEffect">/<p:childTnLst> — direct-child
        # iteration misses them; this code uses iter() + parent_map instead.
        _SMARTART_STRIP_TAGS = {
            f"{{{NS_PPT}}}anim",
            f"{{{NS_PPT}}}animScale",
            f"{{{NS_PPT}}}animEffect",
        }
        _graphicEl_dgm_path = f"{{{NS_PPT}}}graphicEl/{{{NS_A}}}dgm"
        timing = slide._element.find(f"{{{NS_PPT}}}timing")
        if timing is not None:
            for ctn in timing.iter(f"{{{NS_PPT}}}cTn"):
                if ctn.get("nodeType", "") not in ("clickEffect", "withEffect"):
                    continue

                # Detect SmartArt sub-node targeting at ANY depth within cTn.
                # Match Pattern A (dgm= attribute) OR Pattern B (graphicEl/dgm child).
                is_smartart_group = any(
                    tgt.get("dgm") is not None or
                    tgt.find(_graphicEl_dgm_path) is not None
                    for tgt in ctn.iter(f"{{{NS_PPT}}}spTgt")
                )
                if not is_smartart_group:
                    continue

                # Build a parent-map for the entire cTn subtree so we can
                # remove nodes regardless of how deeply nested they are.
                local_pm = {child: parent
                            for parent in ctn.iter()
                            for child in parent}

                # Collect all nodes to strip (snapshot list — safe to remove)
                to_strip = [
                    elem for elem in ctn.iter()
                    if elem.tag in _SMARTART_STRIP_TAGS
                ]
                for elem in to_strip:
                    parent = local_pm.get(elem)
                    if parent is not None:
                        try:
                            parent.remove(elem)
                            smartart_anim_stripped += 1
                        except ValueError:
                            pass  # already removed (shouldn't happen)

        # ── 3b. Collapse redundant SmartArt Pattern B click triggers ──────────
        # After section 3, every SmartArt Pattern B click group contains only
        # <p:set style.visibility=visible> targeting a sub-node GUID.
        # LibreOffice cannot address sub-nodes: it applies the first set to
        # the *entire* SmartArt container, making it fully visible on click 1.
        # Clicks 2-N re-set the already-visible container — no visual change —
        # yet each costs ANIM_WAIT_BETWEEN_STEPS (3 s) of dead air in the video.
        #
        # Fix: when ALL clickEffect groups on a slide are SmartArt Pattern B,
        # find each group's outermost click trigger (the ancestor cTn that has
        # <p:stCondLst><p:cond delay="indefinite"/></p:stCondLst>), keep the
        # FIRST trigger as "indefinite" (SmartArt reveals on first click), and
        # change triggers 2-N to delay="0" (auto-play immediately after click 1).
        #
        # Result: get_slide_click_steps() counts only 1 indefinite trigger →
        # recording sends 2 arrows (1 reveal + 1 advance) instead of N+1.
        timing = slide._element.find(f"{{{NS_PPT}}}timing")
        if timing is not None:
            all_clicks = [ctn for ctn in timing.iter(f"{{{NS_PPT}}}cTn")
                          if ctn.get("nodeType") == "clickEffect"]
            sa_clicks = [
                ctn for ctn in all_clicks
                if any(
                    tgt.get("dgm") is not None or
                    tgt.find(_graphicEl_dgm_path) is not None
                    for tgt in ctn.iter(f"{{{NS_PPT}}}spTgt")
                )
            ]
            # Only collapse when every click group on the slide is SA Pattern B
            if sa_clicks and len(sa_clicks) == len(all_clicks) and len(sa_clicks) > 1:
                full_pm = {child: parent
                           for parent in timing.iter()
                           for child in parent}
                # Walk up from each clickEffect to find its outer trigger
                outer_triggers = []
                for click_ctn in sa_clicks:
                    el = click_ctn
                    found = None
                    while el is not None:
                        cond = el.find(
                            f"{{{NS_PPT}}}stCondLst/{{{NS_PPT}}}cond"
                        )
                        if cond is not None and cond.get("delay") == "indefinite":
                            if el not in outer_triggers:
                                found = el
                            break
                        el = full_pm.get(el)
                    if found is not None:
                        outer_triggers.append(found)
                # Trigger 1: keep as click-triggered (delay="indefinite")
                # Triggers 2-N: convert to auto-play (delay="0")
                for outer in outer_triggers[1:]:
                    for cond in outer.findall(
                            f"{{{NS_PPT}}}stCondLst/{{{NS_PPT}}}cond"):
                        if cond.get("delay") == "indefinite":
                            cond.set("delay", "0")
                            smartart_collapsed += 1

        # ── 3c. Expand SmartArt Pattern B to individual flat shapes ──────────
        # Sections 3 and 3b above strip animations and collapse SmartArt
        # triggers to a single click — the whole graphicFrame still appears
        # as one unit.  For slides where every click group targets a Pattern B
        # SmartArt sub-node we can do better: read the individual shapes from
        # the dsp:drawing XML, create flat <p:sp> shapes in the slide's spTree,
        # and build per-shape "appear" animations that LibreOffice CAN execute.
        # After expansion get_slide_click_steps() correctly counts the original
        # number of click groups (hub on click 1, one branch per subsequent
        # click), so the recording sends the right number of advance arrows.
        #
        # If the expansion succeeds, sections 3 and 3b become no-ops for this
        # slide because the new timing references flat spids (no dgm GUIDs).
        # If the expansion fails (drawing XML missing, etc.) the slide falls
        # back to the collapsed single-click behaviour from section 3b.
        n_exp = _expand_smartart_to_flat_shapes(slide, ppt_path)
        smartart_expanded += n_exp

    out_path = os.path.join(tmp_dir, "prepared_click_only.pptx")
    prs.save(out_path)
    sys.stderr.write(
        f"  [xvfb] Saved PPTX in click-only mode "
        f"({modified} slide(s) had advTm removed; "
        f"{anim_stripped} slide(s) had transition animation stripped; "
        f"{after_effect_fixed} afterEffect/withEffect node(s) set to 1 ms; "
        f"{smartart_anim_stripped} SmartArt anim/animScale/animEffect node(s) stripped; "
        f"{smartart_collapsed} SmartArt redundant click trigger(s) collapsed to auto-play; "
        f"{smartart_expanded} SmartArt diagram(s) expanded to flat shapes)\n"
    )
    return out_path


# ─────────────────────────────────────────────────────────────────────────────
# Embedded-video detection + extraction
# ─────────────────────────────────────────────────────────────────────────────

def _detect_pptx_media(pptx_path):
    """
    Scan the PPTX for slides that have an embedded playable video.

    Returns a list of dicts (one per video shape found), each containing:
        slide_idx       int   0-based slide index (matches click_steps order)
        media_zip_path  str   ZIP-internal path of the video, e.g. "ppt/media/media1.mp4"
        duration_ms     int   video duration in milliseconds (from mediacall animation dur=)
        x_emu, y_emu    int   top-left offset of the video shape in EMU
        cx_emu, cy_emu  int   width/height of the video shape in EMU
        slide_cx_emu    int   slide canvas width  in EMU (from ppt/presentation.xml)
        slide_cy_emu    int   slide canvas height in EMU

    The pixel coordinates for FFmpeg overlay are computed by the caller as:
        x_px = round(x_emu  / slide_cx_emu * SLIDE_WIDTH)
        y_px = round(y_emu  / slide_cy_emu * SLIDE_HEIGHT)
        w_px = round(cx_emu / slide_cx_emu * SLIDE_WIDTH)
        h_px = round(cy_emu / slide_cy_emu * SLIDE_HEIGHT)
    """
    _NS_PPT     = "http://schemas.openxmlformats.org/presentationml/2006/main"
    _NS_A       = "http://schemas.openxmlformats.org/drawingml/2006/main"
    _NS_R       = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
    _NS_PKG_REL = "http://schemas.openxmlformats.org/package/2006/relationships"

    results = []
    try:
        with zipfile.ZipFile(pptx_path, "r") as z:
            all_files = set(z.namelist())

            # ── Slide canvas dimensions from ppt/presentation.xml ─────────
            pres_xml  = z.read("ppt/presentation.xml")
            pres_root = etree.fromstring(pres_xml)
            sz_elem   = pres_root.find(f".//{{{_NS_PPT}}}sldSz")
            slide_cx_emu = int(sz_elem.get("cx")) if sz_elem is not None else 9144000
            slide_cy_emu = int(sz_elem.get("cy")) if sz_elem is not None else 5143500

            # ── Build ordered slide list from presentation.xml.rels ───────
            pres_rels  = z.read("ppt/_rels/presentation.xml.rels")
            rels_root  = etree.fromstring(pres_rels)
            rid_to_tgt = {
                r.get("Id"): r.get("Target", "")
                for r in rels_root.findall(f"{{{_NS_PKG_REL}}}Relationship")
            }
            slide_rids = [
                e.get(f"{{{_NS_R}}}id")
                for e in pres_root.findall(f".//{{{_NS_PPT}}}sldId")
            ]
            ordered_slides = []
            for rid in slide_rids:
                t = rid_to_tgt.get(rid, "")   # e.g. "slides/slide5.xml"
                # Resolve relative to ppt/
                path = "ppt/" + t.lstrip("./")
                ordered_slides.append(path)

            # ── Scan each slide for video shapes ──────────────────────────
            for slide_idx, slide_path in enumerate(ordered_slides):
                if slide_path not in all_files:
                    continue

                slide_file = slide_path.rsplit("/", 1)[-1]
                rels_path  = f"ppt/slides/_rels/{slide_file}.rels"
                if rels_path not in all_files:
                    continue

                # Map rId → ZIP path for video-type relationships
                rels_xml = z.read(rels_path)
                rels     = etree.fromstring(rels_xml)
                vid_rids = {}
                for rel in rels.findall(f"{{{_NS_PKG_REL}}}Relationship"):
                    if rel.get("Type", "").endswith("/video"):
                        rId = rel.get("Id")
                        tgt = rel.get("Target", "")   # e.g. "../media/media1.mp4"
                        # Normalise: ppt/slides/../media/X = ppt/media/X
                        fname    = tgt.rsplit("/", 1)[-1]
                        zip_path = f"ppt/media/{fname}"
                        vid_rids[rId] = zip_path

                if not vid_rids:
                    continue

                slide_xml  = z.read(slide_path)
                slide_root = etree.fromstring(slide_xml)

                # Find <p:pic> elements that contain <a:videoFile>
                for pic in slide_root.iter(f"{{{_NS_PPT}}}pic"):
                    vid_file = pic.find(f".//{{{_NS_A}}}videoFile")
                    if vid_file is None:
                        continue
                    link_rId = vid_file.get(f"{{{_NS_R}}}link")
                    if link_rId not in vid_rids:
                        continue

                    # Shape geometry from <a:xfrm>
                    xfrm = pic.find(f".//{{{_NS_A}}}xfrm")
                    if xfrm is None:
                        continue
                    off = xfrm.find(f"{{{_NS_A}}}off")
                    ext = xfrm.find(f"{{{_NS_A}}}ext")
                    if off is None or ext is None:
                        continue

                    x_emu  = int(off.get("x", 0))
                    y_emu  = int(off.get("y", 0))
                    cx_emu = int(ext.get("cx", 0))
                    cy_emu = int(ext.get("cy", 0))

                    # Video duration from mediacall animation (dur= in ms)
                    timing = slide_root.find(f"{{{_NS_PPT}}}timing")
                    duration_ms = 0
                    if timing is not None:
                        for ctn in timing.iter(f"{{{_NS_PPT}}}cTn"):
                            if ctn.get("presetClass") == "mediacall":
                                try:
                                    duration_ms = int(ctn.get("dur", 0))
                                    break
                                except (ValueError, TypeError):
                                    pass

                    results.append({
                        "slide_idx":     slide_idx,
                        "media_zip_path": vid_rids[link_rId],
                        "duration_ms":   duration_ms,
                        "x_emu":         x_emu,
                        "y_emu":         y_emu,
                        "cx_emu":        cx_emu,
                        "cy_emu":        cy_emu,
                        "slide_cx_emu":  slide_cx_emu,
                        "slide_cy_emu":  slide_cy_emu,
                    })

    except Exception as exc:
        sys.stderr.write(f"  [media] Warning: PPTX media scan failed — {exc}\n")

    return results


def _extract_pptx_media(pptx_path, media_items, assets_dir):
    """
    Extract embedded video files from the PPTX ZIP to <assets_dir>/media/.

    Updates each dict in media_items in-place, adding:
        media_local_path  str  absolute path of the extracted video file

    Returns the updated list (same objects, convenience).
    """
    media_dir = os.path.join(assets_dir, "media")
    os.makedirs(media_dir, exist_ok=True)

    try:
        with zipfile.ZipFile(pptx_path, "r") as z:
            for item in media_items:
                zip_path = item["media_zip_path"]
                fname    = os.path.basename(zip_path)
                out_path = os.path.join(media_dir, fname)
                if not os.path.exists(out_path):
                    data = z.read(zip_path)
                    with open(out_path, "wb") as f:
                        f.write(data)
                    sys.stderr.write(
                        f"  [media] Extracted {zip_path} → {out_path} "
                        f"({len(data):,} bytes)\n"
                    )
                else:
                    sys.stderr.write(
                        f"  [media] Already exists: {out_path}\n"
                    )
                item["media_local_path"] = out_path

                # If the PPTX mediacall animation had no duration (dur=0 or
                # absent), probe the actual video file with ffprobe so the
                # overlay step receives a non-zero duration.
                if item.get("duration_ms", 0) == 0:
                    try:
                        r2 = subprocess.run(
                            [
                                "ffprobe", "-v", "error",
                                "-show_entries", "format=duration",
                                "-of", "default=noprint_wrappers=1:nokey=1",
                                out_path,
                            ],
                            capture_output=True, text=True,
                        )
                        probed = float(r2.stdout.strip())
                        if probed > 0:
                            item["duration_ms"] = int(probed * 1000)
                            sys.stderr.write(
                                f"  [media] Probed duration for {fname}: "
                                f"{probed:.3f}s (mediacall dur was 0)\n"
                            )
                    except Exception as probe_exc:
                        sys.stderr.write(
                            f"  [media] Warning: could not probe duration "
                            f"for {fname}: {probe_exc}\n"
                        )
    except Exception as exc:
        sys.stderr.write(f"  [media] Warning: video extraction failed — {exc}\n")

    return media_items


# ─────────────────────────────────────────────────────────────────────────────
# xdotool helpers – window finding, focusing, key sending
# ─────────────────────────────────────────────────────────────────────────────

def _xdo(xdotool_bin, args, env, log_fh, timeout=5):
    """Run xdotool with given args.  Log and return (returncode, stdout, stderr)."""
    cmd = [xdotool_bin] + args
    log_fh.write(f"  $ xdotool {' '.join(args)}\n")
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=timeout)
        log_fh.write(f"    rc={r.returncode}  stdout={r.stdout.strip()!r}  stderr={r.stderr.strip()!r}\n")
        return r.returncode, r.stdout.strip(), r.stderr.strip()
    except subprocess.TimeoutExpired:
        log_fh.write("    TIMEOUT\n")
        return -1, "", "timeout"
    except Exception as exc:
        log_fh.write(f"    EXCEPTION: {exc}\n")
        return -1, "", str(exc)


def _find_lo_windows(xdotool_bin, env, log_fh):
    """
    Find LibreOffice slideshow window IDs using multiple search strategies.

    IMPORTANT: LibreOffice sets WM_CLASS as ("soffice", "Soffice").
      • xdotool --classname  matches the first field  (res_name)  → "soffice"
      • xdotool --class      matches the second field (res_class) → "Soffice"
    Using the wrong flag (or wrong case) returns nothing silently.

    Returns a list of window ID strings (may be empty).
    """
    log_fh.write("[find_windows] Searching for LibreOffice window...\n")

    strategies = [
        # Most reliable for LibreOffice: res_name is always "soffice" (lowercase)
        ["search", "--classname", "soffice"],
        # res_class is "Soffice" (capital S) — note the case difference
        ["search", "--class", "Soffice"],
        # Some builds use "soffice" for res_class too
        ["search", "--class", "soffice"],
        # By window name (title bar) — varies by LO version and mode
        ["search", "--name", "LibreOffice"],
        ["search", "--name", "Impress"],
        ["search", "--name", "Presentation"],
        # Broadest: any visible window (last resort)
        ["search", "--onlyvisible", "--name", ""],
    ]

    for args in strategies:
        rc, out, _ = _xdo(xdotool_bin, args, env, log_fh)
        if rc == 0 and out:
            wids = [w for w in out.split("\n") if w.strip()]
            log_fh.write(f"  → Strategy '{' '.join(args)}' found {len(wids)} window(s): {wids}\n")
            # For the broad "any visible window" strategy, log extra info but
            # don't use it as a definitive match without verifying
            if "--name" in args and args[-1] == "":
                log_fh.write("  (broad search — skipping, using for info only)\n")
                # Get names of all windows for diagnostic purposes
                for wid in wids[:10]:
                    _xdo(xdotool_bin, ["getwindowname", wid], env, log_fh, timeout=2)
                continue
            log_fh.flush()
            return wids

    log_fh.write("  → No LibreOffice window found with any strategy\n")
    log_fh.flush()
    return []


def _get_window_details(xdotool_bin, wid, env, log_fh):
    """Log window name and class name for a given window ID (for debugging)."""
    for subcmd in [["getwindowname", wid], ["getwindowclassname", wid]]:
        _xdo(xdotool_bin, subcmd, env, log_fh, timeout=3)


def _wait_for_lo_window(xdotool_bin, env, max_wait, log_fh):
    """
    Poll for the LibreOffice window for up to max_wait seconds.

    Returns (elapsed_seconds, window_ids_list).
    If no window is found before timeout, returns (elapsed, []).
    """
    t_start = time.time()
    log_fh.write(f"[wait_window] Polling (max {max_wait}s)...\n")

    if xdotool_bin is None:
        log_fh.write("  xdotool not found — sleeping fixed duration\n")
        time.sleep(max_wait)
        return float(max_wait), []

    while (time.time() - t_start) < max_wait:
        wids = _find_lo_windows(xdotool_bin, env, log_fh)
        if wids:
            elapsed = time.time() - t_start
            log_fh.write(
                f"[wait_window] Window(s) found after {elapsed:.1f}s: {wids}\n"
            )
            # Log details for every found window
            for wid in wids:
                _get_window_details(xdotool_bin, wid, env, log_fh)
            # Dismiss any popup dialogs that appeared during file load
            # (e.g. "Did you know?", "What's New") before checking mode.
            _dismiss_stray_dialogs(xdotool_bin, env, log_fh)
            # Give the first slide 2 more seconds to fully render
            time.sleep(2)
            return time.time() - t_start, wids
        # Also dismiss dialogs while waiting for the main window to appear
        _dismiss_stray_dialogs(xdotool_bin, env, log_fh)
        time.sleep(2)

    elapsed = time.time() - t_start
    log_fh.write(f"[wait_window] Timeout after {elapsed:.1f}s — no window found\n")
    log_fh.flush()
    return elapsed, []


def _activate_lo_window(xdotool_bin, wids, env, log_fh):
    """
    Activate (bring to foreground and focus) the LibreOffice window.

    Uses windowactivate --sync with a SHORT timeout (1.5s).
    The previous 5s default caused 3×5 = 15s of wasted time when the WM
    didn't acknowledge activation (e.g. for editor/background windows),
    eating into the recording budget before FFmpeg even started.

    Returns the window ID that was successfully activated, or None.
    """
    log_fh.write(f"[activate] Activating window(s): {wids}\n")
    for wid in wids:
        rc, out, err = _xdo(
            xdotool_bin, ["windowactivate", "--sync", wid], env, log_fh,
            timeout=1.5,   # short timeout — don't waste recording time
        )
        if rc == 0:
            log_fh.write(f"  → Activated window {wid}\n")
            log_fh.flush()
            return wid
    log_fh.write("  → Could not activate any window (proceeding anyway)\n")
    log_fh.flush()
    return None


def _enforce_fullscreen(wid, xdotool_bin, env, log_fh):
    """
    Force the slideshow window to cover the virtual display and bring it to front.

    Called in step 7c — after the slideshow window is confirmed and before
    FFmpeg starts recording.  Without this the LibreOffice editor window (or
    a partially-painted window) can remain on top, causing the first seconds
    of the recording to show LO chrome instead of the slide.

    Strategy:
      1. xdotool windowmove wid 0 0              ← snap to top-left corner
      2. xdotool windowsize wid W H              ← fill virtual display
      3. xdotool windowraise wid                 ← bring above all siblings
      4. xdotool windowactivate wid              ← NO --sync (times out on
                                                    headless openbox)

    NOTE: wmctrl -b add,fullscreen is intentionally NOT used here.
    When LibreOffice Impress enters EWMH fullscreen mode it establishes an
    XGrabKeyboard on the root window.  All subsequent XSendEvent-based key
    injections (xdotool key --window WID) are then silently rejected by the
    X server because a grab is active.  xdotool still returns rc=0 (the
    XSendEvent call succeeds) but LO never processes the event, so slides
    never advance.  Positioning the window with xdotool alone is sufficient
    on a 1280×720 Xvfb display with no other visible windows.
    """
    log_fh.write(f"[fullscreen] Positioning slideshow window {wid} (no EWMH grab)\n")

    # Snap to (0,0), resize to display dimensions, raise, activate
    _xdo(xdotool_bin, ["windowmove",     wid, "0", "0"],                    env, log_fh)
    _xdo(xdotool_bin, ["windowsize",     wid, str(SLIDE_WIDTH), str(SLIDE_HEIGHT)], env, log_fh)
    _xdo(xdotool_bin, ["windowraise",    wid],                               env, log_fh)
    _xdo(xdotool_bin, ["windowactivate", wid],                               env, log_fh)  # NO --sync
    time.sleep(0.5)
    log_fh.write(f"[fullscreen] Positioning complete\n")

    log_fh.write("[fullscreen] Enforce complete\n")
    log_fh.flush()


def _send_right_arrow(xdotool_bin, wids, env, log_fh):
    """
    Send a Right Arrow keypress to the LibreOffice slideshow window to
    advance to the next slide (or next animation step).

    IMPORTANT: LibreOffice Impress slideshow mode silently discards synthetic
    keyboard events (those injected via XSendEvent, which have send_event=True).
    xdotool key --window WID uses XSendEvent and always returns rc=0 even when
    LO ignores the event — this gives a false "success" signal.

    The fix is to use XTEST-based injection:
      xdotool windowfocus WID   ← sets X11 keyboard focus (no --sync needed)
      xdotool key --clearmodifiers Right  ← no --window = XTEST, looks like
                                            real hardware input, cannot be filtered

    Tries:
      1. windowfocus WID (no --sync) + key --clearmodifiers Right  (XTEST — primary)
      2. key --window WID --clearmodifiers Right  (XSendEvent — fallback, may be ignored)
      3. key --clearmodifiers Right to currently focused window   (last resort)

    Returns True if at least one approach succeeded.
    """
    log_fh.write("[send_key] Sending Right Arrow...\n")

    for wid in wids:
        # Primary approach: XTEST — set X11 focus then inject as real hardware event.
        # windowfocus without --sync never times out on headless openbox.
        _xdo(xdotool_bin, ["windowfocus", wid], env, log_fh)
        time.sleep(0.15)   # let the focus change settle before key event
        rc, _, _ = _xdo(
            xdotool_bin,
            ["key", "--clearmodifiers", "Right"],   # no --window = XTEST
            env, log_fh,
        )
        if rc == 0:
            log_fh.write(f"  → XTEST key sent successfully (focused to {wid})\n")
            log_fh.flush()
            return True

        # Fallback: XSendEvent (may be silently ignored by LO slideshow)
        log_fh.write(f"  XTEST approach failed for {wid}, trying XSendEvent fallback...\n")
        rc, _, _ = _xdo(
            xdotool_bin,
            ["key", "--window", wid, "--clearmodifiers", "Right"],
            env, log_fh,
        )
        if rc == 0:
            log_fh.write(f"  → XSendEvent key sent to window {wid}\n")
            log_fh.flush()
            return True

    # Last resort: send to whatever is focused (no window ID)
    log_fh.write("  All window-specific approaches failed — sending to active window\n")
    rc, _, _ = _xdo(xdotool_bin, ["key", "--clearmodifiers", "Right"], env, log_fh)
    log_fh.flush()
    return rc == 0


def _window_looks_like_editor(title):
    """
    Return True if the window title indicates LibreOffice is in EDITOR mode.

    Editor window titles:   "LibreOffice 7.3", "LibreOffice Impress", "VCL Impl..."
    Slideshow window titles: presentation name, blank, or any non-"LibreOffice" string.

    When ALL found windows look like editor windows it means --show did not start
    the slideshow.  The fix is to send F5 to trigger the slideshow from the editor.
    """
    _EDITOR_KEYWORDS = ("LibreOffice 7", "LibreOffice 6", "LibreOffice 5",
                        "LibreOffice 4", "LibreOffice Impress", "VCL Impl")
    return any(kw in title for kw in _EDITOR_KEYWORDS)


def _window_looks_like_dialog(title):
    """
    Return True if the window title indicates a modal DIALOG/POPUP, NOT a slideshow.

    WHY THIS IS CRITICAL
    ────────────────────
    The "Did you know?" (Tip of the Day) dialog appears ~2 s after LO loads the
    file.  Its title ("Did you know?" or "Tip of the Day") does NOT match any
    _EDITOR_KEYWORDS, so _window_looks_like_editor() returns False.

    Without this function, step 7b sees:
        titles = {editor_wid: "LibreOffice 7.x",  dialog_wid: "Did you know?"}
        all_editor = False  ← dialog breaks the all() check
    → code INCORRECTLY concludes "slideshow confirmed"
    → _enforce_fullscreen() is called on the DIALOG window
    → F5 is never sent; slideshow never starts in fullscreen
    → FFmpeg records the editor + dialog instead of the presentation

    By identifying dialogs separately we can:
      1. Dismiss them (send Return/Escape) before attempting fullscreen.
      2. Exclude them from the "slideshow confirmed" decision.
    """
    _DIALOG_KEYWORDS = (
        "Did you know",
        "Tip of the Day",
        "What's New",
        "Information",
        "Error",
        "Warning",
        "Basic IDE",
        "Macro",
        "Security",
        "Extension",
        "Update",
        "Accessibility",
        "AutoCorrect",
    )
    tl = title.lower()
    return any(kw.lower() in tl for kw in _DIALOG_KEYWORDS)


def _dismiss_stray_dialogs(xdotool_bin, env, log_fh, known_wids=None):
    """
    Find and dismiss any LibreOffice popup dialogs that are NOT the slideshow
    or the editor.

    Sends Return (accept/close) followed by Escape to every window whose title
    matches _window_looks_like_dialog().  This handles:
      • "Did you know?" / Tip of the Day
      • "What's New in LibreOffice X.Y" info bar / dialog
      • Macro security prompts
      • Any other modal that blocks slideshow startup

    known_wids: list of window IDs that are known good (editor/slideshow) and
        should NOT be dismissed.  Pass [] or None to dismiss any dialog found.
    """
    if known_wids is None:
        known_wids = []

    all_wids = _find_lo_windows(xdotool_bin, env, log_fh)
    dismissed = 0
    for wid in all_wids:
        if wid in known_wids:
            continue
        rc, title, _ = _xdo(
            xdotool_bin, ["getwindowname", wid], env, log_fh, timeout=2
        )
        title = title.strip() if rc == 0 else ""
        if _window_looks_like_dialog(title):
            log_fh.write(
                f"[dialogs] Dismissing dialog window {wid} "
                f"(title={title!r})\n"
            )
            sys.stderr.write(
                f"  [dialogs] Dismissing popup: {title!r} (wid={wid})\n"
            )
            # Return closes "OK"/"Next"/"Accept" buttons; Escape cancels
            _xdo(xdotool_bin, ["key", "--window", wid,
                                "--clearmodifiers", "Return"], env, log_fh)
            time.sleep(0.2)
            _xdo(xdotool_bin, ["key", "--window", wid,
                                "--clearmodifiers", "Escape"], env, log_fh)
            dismissed += 1

    if dismissed:
        log_fh.write(f"[dialogs] Dismissed {dismissed} dialog(s)\n")
        log_fh.flush()
        time.sleep(0.5)   # let LO process the dismissals


def _advance_slides_xdotool(xdotool_bin, env, wids, click_steps, log_fh,
                             ffmpeg_start_time=None, transition_durations=None):
    """
    Drive the LibreOffice slideshow using timed Right Arrow keypresses.

    For each slide with N click-animation groups the sequence is:
      1. sleep(wait_first)  — where wait_first = max(ANIM_WAIT_BEFORE_FIRST,
                               transition_durations[i] + TRANSITION_BUFFER).
                               CRITICAL: must exceed the incoming slide's
                               transition duration.  Sending a key DURING an
                               LO entrance transition causes LO to skip the
                               slide entirely (advance to the next one) instead
                               of completing the transition and triggering the
                               first animation.  The TRANSITION_BUFFER ensures
                               we clear the transition before the first key is
                               sent even if LO renders it slightly late.
      2. For each of the N click groups:
             send Right Arrow  →  sleep(ANIM_WAIT_BETWEEN_STEPS)
      3. Send one final Right Arrow to advance to the next slide.
         (Omitted for the very last slide — there is nowhere to advance to.)

    Per-slide time   = ANIM_WAIT_BEFORE_FIRST + N × ANIM_WAIT_BETWEEN_STEPS
    Per-slide arrows = N + 1  (N animation triggers + 1 slide advance)
                       Last slide: N triggers only (no advance needed)

    Example (5-slide PPTX, click_steps=[2,1,1,0,0]):
      Slide 1: wait 2s → Right×2 (anims) → Right×1 (advance) = 3 arrows, 8s
      Slide 2: wait 2s → Right×1 (anim)  → Right×1 (advance) = 2 arrows, 5s
      Slide 3: wait 2s → Right×1 (anim)  → Right×1 (advance) = 2 arrows, 5s
      Slide 4: wait 2s → Right×1 (advance)                   = 1 arrow,  2s
      Slide 5: wait 2s → (last slide, no advance)             = 0 arrows, 2s
      Total: 8 arrows, 22s content + XVFB_END_BUFFER

    ffmpeg_start_time: time.time() value captured immediately before FFmpeg
        subprocess.Popen().  When provided, the function records the wall-clock
        time relative to FFmpeg start at which each advance arrow is sent.
        These actual timestamps are returned as actual_advances and written to
        slide_timings.json, replacing the theoretical computed boundaries.
        This eliminates the cumulative xdotool subprocess overhead error.

    Returns:
        actual_advances — list of (n-1) floats: seconds-since-FFmpeg-start at
            which the advance arrow from slide i to slide i+1 was sent.
            Empty list if ffmpeg_start_time was not supplied.
    """
    num          = len(click_steps)
    current_wids = list(wids)
    actual_advances = []

    # t0: reference point for timestamp recording.  If ffmpeg_start_time was
    # supplied we use it; otherwise timestamps are relative to this function's
    # start (less accurate but still useful for logging).
    t0 = ffmpeg_start_time if ffmpeg_start_time is not None else time.time()

    log_fh.write(
        f"[advance] Starting: {num} slides, click_steps={click_steps}\n"
        f"[advance] ANIM_WAIT_BEFORE_FIRST={ANIM_WAIT_BEFORE_FIRST}s, "
        f"ANIM_WAIT_BETWEEN_STEPS={ANIM_WAIT_BETWEEN_STEPS}s, "
        f"TRANSITION_BUFFER={TRANSITION_BUFFER}s\n"
        f"[advance] transition_durations={transition_durations}\n"
        f"[advance] wids={current_wids}\n"
        f"[advance] ffmpeg_start_time provided: {ffmpeg_start_time is not None}\n"
    )
    log_fh.flush()

    for i, steps in enumerate(click_steps):
        is_last = (i == num - 1)

        if is_last:
            action_desc = (
                f"last slide — {steps} click group(s) then hold"
                if steps > 0 else "last slide — static hold"
            )
        else:
            action_desc = (
                f"{steps} click group(s) + 1 advance = {steps + 1} arrow(s)"
            )

        log_fh.write(f"\n[advance] ── Slide {i+1}/{num}: {action_desc}\n")
        log_fh.flush()

        # ── Wait for the slide to appear and first animations to render ────
        # For slides with an entrance transition we MUST wait longer than the
        # transition duration before sending any key.  Pressing Right Arrow
        # while LO is still playing an entrance transition causes LO to skip
        # the slide entirely (advance to the next slide) instead of completing
        # the transition and then triggering the first animation.
        td = (transition_durations[i]
              if transition_durations and i < len(transition_durations)
              else 0.0)
        wait_first = (max(ANIM_WAIT_BEFORE_FIRST, td + TRANSITION_BUFFER)
                      if td > 0.0 else ANIM_WAIT_BEFORE_FIRST)
        log_fh.write(
            f"[advance] Waiting {wait_first:.1f}s for slide to render "
            f"(transition_dur={td:.1f}s, ANIM_WAIT_BEFORE_FIRST={ANIM_WAIT_BEFORE_FIRST}s)...\n"
        )
        time.sleep(wait_first)

        # ── Refresh window list if needed ──────────────────────────────────
        if not current_wids:
            log_fh.write("[advance] Window list empty — re-searching\n")
            current_wids = _find_lo_windows(xdotool_bin, env, log_fh)

        # ── Trigger each click-animation group ─────────────────────────────
        for step in range(steps):
            log_fh.write(
                f"[advance] → Click group {step+1}/{steps}: sending Right Arrow\n"
            )
            ok = _send_right_arrow(xdotool_bin, current_wids, env, log_fh)
            log_fh.write(f"[advance]   result: {'SUCCESS' if ok else 'FAILED'}\n")
            log_fh.flush()
            time.sleep(ANIM_WAIT_BETWEEN_STEPS)

        # ── Advance to the next slide ───────────────────────────────────────
        if not is_last:
            # Record the wall-clock time BEFORE sending the advance arrow.
            # This is the closest measurable point to when LibreOffice receives
            # the keypress and begins the slide transition.
            t_advance = round(time.time() - t0, 3)
            log_fh.write(
                f"[advance] → Advance arrow → slide {i+2}: sending Right Arrow "
                f"(t={t_advance:.3f}s since FFmpeg start)\n"
            )
            ok = _send_right_arrow(xdotool_bin, current_wids, env, log_fh)
            log_fh.write(f"[advance]   result: {'SUCCESS' if ok else 'FAILED'}\n")
            log_fh.flush()
            actual_advances.append(t_advance)

    log_fh.write(
        f"\n[advance] All slides driven — xdotool job done\n"
        f"[advance] actual_advances (relative to FFmpeg start): {actual_advances}\n"
    )
    log_fh.flush()
    return actual_advances


# ─────────────────────────────────────────────────────────────────────────────
# Main Xvfb recording function
# ─────────────────────────────────────────────────────────────────────────────

def export_with_xvfb(ppt_path, output_video, click_steps):
    """
    Record LibreOffice Impress slideshow in a virtual X11 display.
    All animations, entrance effects, transitions, and embedded media
    are rendered exactly as PowerPoint would show them.

    Recording timeline (per slide with N click-animation groups)
    ──────────────────────────────────────────────────────────────────────
      t = 0                          Xvfb starts
      t = 1.5s                       LibreOffice --show starts loading
      t = 1.5 .. STARTUP             LibreOffice creates profile + loads PPTX
      t = STARTUP                    Slideshow running; window activated
      t = STARTUP                    FFmpeg starts recording
      t = STARTUP + BEFORE_FIRST     First keypress (or advance if N=0)
      t = STARTUP + BEFORE_FIRST
              + k × BETWEEN_STEPS    k-th click-animation group triggered
      ... (advance arrow to next slide) ...
      t = STARTUP + total_content    Last slide hold completes
      t = STARTUP + total_content
              + XVFB_END_BUFFER      FFmpeg stops (-t flag)

    A detailed xdotool_debug.log is written alongside the output video.
    """
    # Read transition durations upfront so compute_record_duration can account
    # for the extra per-slide wait needed when td + TRANSITION_BUFFER >
    # ANIM_WAIT_BEFORE_FIRST.  Without this the -t timer for FFmpeg may fire
    # before the last slides finish, cutting off content.
    try:
        _transition_durations_early = get_slide_transition_durations(ppt_path)
    except Exception:
        _transition_durations_early = [0.0] * len(click_steps)

    record_duration = compute_record_duration(click_steps, _transition_durations_early)
    total_content   = record_duration - XVFB_END_BUFFER

    # Use a PID-based display number (50–249) to avoid conflicts
    display_num = str(os.getpid() % 200 + 50)
    display     = f":{display_num}"

    tmp_dir  = tempfile.mkdtemp(prefix="ppt_xvfb_")
    xvfb_log = os.path.join(tmp_dir, "xvfb.log")
    lo_log   = os.path.join(tmp_dir, "lo.log")
    procs    = []

    # Debug log saved alongside the output video so the user can inspect it
    debug_log_path = os.path.join(os.path.dirname(output_video), "xdotool_debug.log")

    try:
        with open(debug_log_path, "w", encoding="utf-8") as log_fh:

            # ── 0. Write run header ────────────────────────────────────────
            log_fh.write(
                f"=== export_with_xvfb debug log ===\n"
                f"ppt_path     : {ppt_path}\n"
                f"output_video : {output_video}\n"
                f"display      : {display}\n"
                f"slides       : {len(click_steps)}\n"
                f"click_steps  : {click_steps}\n"
                f"content_dur  : {total_content:.1f}s\n"
                f"record_dur   : {record_duration:.1f}s\n"
                f"tmp_dir      : {tmp_dir}\n\n"
            )

            # ── 1. Resolve required binaries ───────────────────────────────
            xvfb_bin    = _find_xvfb()
            xdotool_bin = _find_xdotool()

            log_fh.write(f"[binaries]\n"
                         f"  xvfb_bin   : {xvfb_bin}\n"
                         f"  xdotool_bin: {xdotool_bin}\n"
                         f"  PATH       : {os.environ.get('PATH', '(empty)')}\n\n")

            if xvfb_bin is None:
                raise RuntimeError(
                    "Xvfb binary not found.  Install: sudo apt install xvfb"
                )
            if xdotool_bin is None:
                raise RuntimeError(
                    "xdotool binary not found.  Install: sudo apt install xdotool\n"
                    "xdotool is required to advance slides on headless Xvfb."
                )

            sys.stderr.write(f"  [xvfb] Xvfb   : {xvfb_bin}\n")
            sys.stderr.write(f"  [xvfb] xdotool: {xdotool_bin}\n")

            # ── 2. Prepare PPTX in click-only mode ────────────────────────
            prepared_pptx = _prepare_pptx_click_only(ppt_path, tmp_dir)
            log_fh.write(f"[pptx] prepared_pptx: {prepared_pptx}\n\n")

            # ── 3. Build process environment ──────────────────────────────
            env            = os.environ.copy()
            env["DISPLAY"] = display
            env["HOME"]    = tmp_dir
            env.setdefault("LIBGL_ALWAYS_SOFTWARE", "1")

            _create_lo_offline_profile(tmp_dir)
            log_fh.write(f"[env] DISPLAY={display}  HOME={tmp_dir}\n\n")

            # ── 4. Start Xvfb ─────────────────────────────────────────────
            sys.stderr.write(
                f"  [xvfb] Starting virtual display {display} "
                f"({SLIDE_WIDTH}x{SLIDE_HEIGHT}x24)\n"
            )
            log_fh.write(f"[xvfb] Starting {display}\n")
            with open(xvfb_log, "w") as xvfb_err:
                xvfb = subprocess.Popen(
                    [
                        xvfb_bin, display,
                        "-screen", "0", f"{SLIDE_WIDTH}x{SLIDE_HEIGHT}x24",
                        "-ac", "-noreset",
                    ],
                    stdout=subprocess.DEVNULL,
                    stderr=xvfb_err,
                )
            procs.append(xvfb)
            time.sleep(1.5)

            if xvfb.poll() is not None:
                xvfb_err_txt = open(xvfb_log).read() if os.path.exists(xvfb_log) else ""
                raise RuntimeError(
                    f"Xvfb exited immediately (code {xvfb.returncode}).\n{xvfb_err_txt}"
                )
            log_fh.write(f"[xvfb] Running — pid={xvfb.pid}\n\n")
            sys.stderr.write(f"  [xvfb] Display {display} running (pid {xvfb.pid})\n")

            # ── 4b. Start window manager ───────────────────────────────────
            # CRITICAL: without a WM, LibreOffice --show opens in EDIT mode
            # (not slideshow mode) because Xvfb has no WM to honour the
            # _NET_WM_STATE_FULLSCREEN hint.  The first slide is then shown
            # as a thumbnail for the entire recording duration.
            # openbox (or any EWMH-compliant WM) fixes this.
            wm = _start_window_manager(env, log_fh)
            if wm:
                procs.append(wm)

            # ── 5. Start LibreOffice slideshow ─────────────────────────────
            sys.stderr.write(
                f"  [libreoffice] Starting slideshow "
                f"({len(click_steps)} slides, {total_content:.1f}s content)\n"
            )
            log_fh.write(f"[libreoffice] Starting --show {prepared_pptx}\n")
            with open(lo_log, "w") as lo_err:
                lo = subprocess.Popen(
                    [
                        LIBREOFFICE,
                        "--norestore",
                        "--nofirststartwizard",
                        "--nocrashreport",
                        # NOTE: do NOT add --impress here.
                        # --impress opens the Impress EDITOR (title = "LibreOffice 7.3").
                        # --show alone starts the fullscreen SLIDESHOW directly.
                        # Combining --impress + --show causes --impress to win and the
                        # slideshow never starts, so Right Arrow keys go to the editor
                        # and nothing advances.
                        "--show",
                        prepared_pptx,
                    ],
                    stdout=subprocess.DEVNULL,
                    stderr=lo_err,
                    env=env,
                )
            procs.append(lo)
            log_fh.write(f"[libreoffice] pid={lo.pid}\n\n")

            # ── 6. Wait for LibreOffice window ─────────────────────────────
            waited, wids = _wait_for_lo_window(
                xdotool_bin, env, XVFB_STARTUP_WAIT, log_fh
            )
            sys.stderr.write(
                f"  [xvfb] LibreOffice ready after {waited:.1f}s  "
                f"(window ids: {wids})\n"
            )
            log_fh.write(f"\n[ready] waited={waited:.1f}s  wids={wids}\n\n")

            if not wids:
                # Log all windows on the display for diagnostics
                log_fh.write("[diagnostics] No LO window found — dumping all windows:\n")
                _xdo(xdotool_bin, ["search", "--onlyvisible", "--name", ""], env, log_fh)
                sys.stderr.write(
                    "  [xvfb] WARNING: LibreOffice window not found by xdotool.\n"
                    "  Check xdotool_debug.log for details.\n"
                )

            # ── 7. Activate the LibreOffice window ────────────────────────
            if wids:
                active_wid = _activate_lo_window(xdotool_bin, wids, env, log_fh)
                log_fh.write(f"[activate] active_wid={active_wid}\n\n")
                time.sleep(0.5)

            # ── 7b. Detect editor vs slideshow mode; send F5 if needed ────
            #
            # IMPORTANT — dialog window detection:
            # LibreOffice shows a "Did you know?" / Tip of the Day dialog ~2s
            # after loading a file.  Its title ("Did you know?") does NOT match
            # _window_looks_like_editor(), so the old code would incorrectly
            # conclude "slideshow confirmed" and call _enforce_fullscreen() on
            # the DIALOG WINDOW — F5 was never sent and the slideshow never
            # started fullscreen.
            #
            # Fix: dismiss all dialogs FIRST, then re-classify windows strictly:
            #   • Editor    → _window_looks_like_editor() == True
            #   • Dialog    → _window_looks_like_dialog()  == True  (dismiss these)
            #   • Slideshow → neither editor nor dialog title
            #
            # Only a window that is neither editor nor dialog counts as the
            # running slideshow.  If no such window exists after dismissing
            # dialogs, we send F5.
            # pre_f5_wids is populated inside the block below; initialise to
            # empty set here so step 7c can reference it unconditionally.
            pre_f5_wids = set()
            if wids:
                # Step 1: dismiss any stray dialogs before classification
                _dismiss_stray_dialogs(xdotool_bin, env, log_fh, known_wids=wids)

                # Step 2: read titles of all current windows
                all_current = _find_lo_windows(xdotool_bin, env, log_fh)
                titles = {}
                for wid in all_current:
                    rc, name, _ = _xdo(
                        xdotool_bin, ["getwindowname", wid], env, log_fh, timeout=2
                    )
                    titles[wid] = name.strip() if rc == 0 else ""

                log_fh.write(f"[mode_check] Window titles after dialog dismissal: {titles}\n")

                # Record the set of windows that exist RIGHT NOW, BEFORE F5 is sent.
                # Windows that appear AFTER F5 (alongside the slideshow) are part of
                # LO's internal presenter stack and must NOT be minimised in step 7c —
                # minimising them can freeze the slideshow so it ignores key events.
                pre_f5_wids = set(all_current)

                # Step 3: classify — slideshow = not editor AND not dialog
                def _is_slideshow_win(t):
                    return not _window_looks_like_editor(t) and not _window_looks_like_dialog(t)

                slideshow_wids = [w for w, t in titles.items() if _is_slideshow_win(t)]
                editor_wids    = [w for w, t in titles.items() if _window_looks_like_editor(t)]

                no_slideshow = not slideshow_wids

                if no_slideshow:
                    log_fh.write(
                        "[mode_check] *** NO SLIDESHOW WINDOW FOUND ***\n"
                        "             Only editor/dialog windows present.\n"
                        "             Sending F5 to start the presentation...\n"
                    )
                    sys.stderr.write(
                        "  [xvfb] No slideshow window — sending F5 to start slideshow\n"
                    )
                    # Send F5 to the first editor window
                    f5_targets = editor_wids or all_current
                    for wid in f5_targets:
                        rc, _, _ = _xdo(
                            xdotool_bin,
                            ["key", "--window", wid, "--clearmodifiers", "F5"],
                            env, log_fh,
                        )
                        if rc == 0:
                            log_fh.write(f"[mode_check] F5 sent to window {wid}\n")
                            break

                    # Wait up to 8s for the slideshow window, dismissing dialogs
                    log_fh.write("[mode_check] Waiting for slideshow window (up to 8s)...\n")
                    t_f5 = time.time()
                    while (time.time() - t_f5) < 8.0:
                        _dismiss_stray_dialogs(xdotool_bin, env, log_fh)
                        new_all = _find_lo_windows(xdotool_bin, env, log_fh)
                        new_titles = {}
                        for wid in new_all:
                            rc, name, _ = _xdo(
                                xdotool_bin, ["getwindowname", wid],
                                env, log_fh, timeout=2
                            )
                            new_titles[wid] = name.strip() if rc == 0 else ""
                        new_slideshow = [
                            w for w, t in new_titles.items() if _is_slideshow_win(t)
                        ]
                        if new_slideshow:
                            wids = new_slideshow
                            log_fh.write(
                                f"[mode_check] Slideshow window(s) found: {wids}\n"
                            )
                            sys.stderr.write(
                                f"  [xvfb] Slideshow window found after F5: {wids}\n"
                            )
                            break
                        time.sleep(1.0)
                    else:
                        # Still no slideshow after 8s — use whatever we have
                        wids = new_all or wids
                        log_fh.write(
                            "[mode_check] Still no slideshow window after F5 wait —\n"
                            "             using all found windows and proceeding anyway.\n"
                        )
                        sys.stderr.write(
                            "  [xvfb] WARNING: Slideshow did not start after F5.\n"
                            "  [xvfb] Slide advancement may not work correctly.\n"
                        )
                else:
                    # Slideshow window confirmed
                    log_fh.write(
                        f"[mode_check] Slideshow mode confirmed\n"
                        f"[mode_check] Slideshow window(s): {slideshow_wids}\n"
                    )
                    sys.stderr.write("  [xvfb] Slideshow mode confirmed ✓\n")
                    wids = slideshow_wids  # use slideshow window for key sending

            # ── 7c. Enforce fullscreen + settle before recording ───────────
            #
            # Even after the slideshow window is confirmed, LO may still be
            # painting or the editor window may linger on top.  We:
            #   a) Call _enforce_fullscreen() on the primary slideshow window
            #      (wmctrl EWMH hint + xdotool geometry/raise/activate).
            #   b) Minimise any surviving editor windows so they cannot appear
            #      in the recording.
            #   c) Sleep PRE_FFMPEG_SETTLE_TIME so slide 1 is fully rendered
            #      before the first FFmpeg frame is captured.
            if wids:
                primary_wid = wids[0]

                # Dismiss any remaining dialogs BEFORE enforcing fullscreen.
                # A dialog on top (even if "closed") can prevent wmctrl/xdotool
                # from setting the fullscreen state on the slideshow window.
                _dismiss_stray_dialogs(xdotool_bin, env, log_fh, known_wids=wids)

                _enforce_fullscreen(primary_wid, xdotool_bin, env, log_fh)

                # Minimise ONLY the editor windows that existed BEFORE F5 was sent
                # (tracked in pre_f5_wids).  Windows that appeared AFTER F5, alongside
                # the slideshow window, are part of LO's internal presenter stack
                # (e.g. a secondary "LibreOffice 7.3" window that is the presenter
                # console / slide driver).  Minimising those windows freezes the
                # slideshow so it no longer responds to key events.
                # Also dismiss any dialogs that re-appeared after enforce_fullscreen.
                all_current_wids = _find_lo_windows(xdotool_bin, env, log_fh)
                for ewid in all_current_wids:
                    if ewid == primary_wid:
                        continue   # never touch the slideshow window itself
                    rc, ename, _ = _xdo(
                        xdotool_bin, ["getwindowname", ewid], env, log_fh, timeout=2
                    )
                    etitle = ename.strip() if rc == 0 else ""
                    if _window_looks_like_editor(etitle):
                        if ewid in pre_f5_wids:
                            # Pre-F5 editor window — safe to minimise
                            log_fh.write(f"[settle] Minimising pre-F5 editor window {ewid}\n")
                            _xdo(xdotool_bin, ["windowminimize", ewid], env, log_fh)
                        else:
                            # Post-F5 window — part of slideshow stack, do NOT minimise
                            log_fh.write(
                                f"[settle] Skipping post-F5 window {ewid} "
                                f"({etitle!r}) — may be slideshow presenter console\n"
                            )
                    elif _window_looks_like_dialog(etitle):
                        log_fh.write(f"[settle] Closing late dialog {ewid} ({etitle!r})\n")
                        _xdo(xdotool_bin, ["key", "--window", ewid,
                                           "--clearmodifiers", "Return"], env, log_fh)
                        _xdo(xdotool_bin, ["key", "--window", ewid,
                                           "--clearmodifiers", "Escape"], env, log_fh)

                log_fh.write(
                    f"[settle] Sleeping {PRE_FFMPEG_SETTLE_TIME}s for slide 1 "
                    "to render fully before FFmpeg starts...\n"
                )
                sys.stderr.write(
                    f"  [xvfb] Settling {PRE_FFMPEG_SETTLE_TIME}s — "
                    "waiting for slide 1 to fill the display...\n"
                )
                time.sleep(PRE_FFMPEG_SETTLE_TIME)

            # ── 8. Start FFmpeg recording in BACKGROUND ───────────────────
            sys.stderr.write(
                f"  [ffmpeg] Recording {record_duration:.1f}s "
                f"(content={total_content:.1f}s + buffer={XVFB_END_BUFFER}s "
                f"+ xdotool overhead)\n"
            )
            log_fh.write(f"[ffmpeg] Starting recording for {record_duration:.1f}s\n")
            # Capture start time BEFORE Popen so that timestamps recorded inside
            # _advance_slides_xdotool are relative to the very first FFmpeg frame.
            ffmpeg_start_time = time.time()
            ffmpeg = subprocess.Popen(
                [
                    FFMPEG, "-y",
                    "-f", "x11grab",
                    "-r", "30",
                    "-s", f"{SLIDE_WIDTH}x{SLIDE_HEIGHT}",
                    "-i", display,
                    "-t", str(record_duration),
                    "-c:v", "libx264", "-preset", "fast", "-crf", "23",
                    "-pix_fmt", "yuv420p",
                    output_video,
                ],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                env=env,
            )
            procs.append(ffmpeg)
            time.sleep(FFMPEG_PRE_ROLL)  # let FFmpeg capture the very start of slide 1

            # ── 9. Drive slide advancement via xdotool (MAIN THREAD) ──────
            # Transition animations were stripped from prepared_click_only.pptx
            # in step 5 (_prepare_pptx_click_only), so LibreOffice now uses
            # instant cuts between slides.  Override transition_durations to all
            # zeros so:
            #   • _advance_slides_xdotool uses wait_first=ANIM_WAIT_BEFORE_FIRST
            #     (2.0 s) — no unnecessary long waits before first animation key.
            #   • clip_starts use MIN_SLIDE_SWITCH_DELAY (1.0 s) — clips begin
            #     shortly after the instant slide switch, not after a phantom
            #     transition duration that no longer exists.
            # _transition_durations_early (from the original PPTX) was used only
            # for compute_record_duration; the advance loop uses zeros here.
            transition_durations_pre = [0.0] * len(click_steps)
            sys.stderr.write(
                f"  [timings] Transition animations stripped — using zero "
                f"transition durations for advance loop and clip_starts\n"
            )

            # Pass ffmpeg_start_time so the function can record actual wall-clock
            # timestamps for each advance arrow.  These real times become the
            # split boundaries in slide_timings.json, eliminating the cumulative
            # xdotool overhead error that shifted boundaries 1–3 s per slide.
            log_fh.write("[advance] Starting slide advancement loop\n")
            actual_advances = _advance_slides_xdotool(
                xdotool_bin, env, wids, click_steps, log_fh, ffmpeg_start_time,
                transition_durations=transition_durations_pre,
            )

            # ── 10. Wait for FFmpeg to finish (auto-stopped by -t) ─────────
            sys.stderr.write("  [xvfb] All slides displayed — waiting for FFmpeg...\n")
            log_fh.write("[ffmpeg] Waiting for FFmpeg to finish...\n")
            try:
                ffmpeg.wait(timeout=XVFB_END_BUFFER + 60)
            except subprocess.TimeoutExpired:
                sys.stderr.write("  [xvfb] FFmpeg timed out – forcing stop\n")
                log_fh.write("[ffmpeg] TIMEOUT — force stopping\n")
                ffmpeg.terminate()
                ffmpeg.wait()
            procs.remove(ffmpeg)
            log_fh.write("[ffmpeg] Finished\n\n")

            # ── 11. Stop LibreOffice ───────────────────────────────────────
            try:
                lo.terminate()
                lo.wait(timeout=10)
            except Exception:
                try:
                    lo.kill()
                except Exception:
                    pass
            procs.remove(lo)

            # ── 12. Forward captured logs to stderr and debug log ─────────
            for label, log_path in [("xvfb", xvfb_log), ("libreoffice", lo_log)]:
                if os.path.exists(log_path):
                    txt = open(log_path).read().strip()
                    if txt:
                        sys.stderr.write(f"  [{label} log]\n{txt}\n")
                        log_fh.write(f"\n=== {label}.log ===\n{txt}\n")

            # ── 13. Validate output ────────────────────────────────────────
            size = os.path.getsize(output_video) if os.path.exists(output_video) else 0
            log_fh.write(f"\n[output] {output_video}  size={size} bytes\n")
            if size < 4096:
                raise RuntimeError(
                    "Xvfb recording produced an empty or missing file.\n"
                    f"See {debug_log_path} for details."
                )

            sys.stderr.write(f"  [xvfb] Recording saved: {output_video}\n")
            sys.stderr.write(f"  [xvfb] Debug log     : {debug_log_path}\n")

            # ── 14. Write slide_timings.json for slide_video_scorm.py ─────
            #
            # BOUNDARY SOURCE PRIORITY
            # ────────────────────────
            # 1. actual_advances (recorded during step 9): wall-clock seconds
            #    since FFmpeg start when each inter-slide advance arrow fired.
            #    These are the REAL transition timestamps in the video.
            # 2. Fallback to compute_slide_boundaries() formula if actual
            #    timestamps are unavailable or count is wrong.
            #
            # clip_starts[i] formula (for i > 0):
            #   td = transition_durations[i]
            #   offset = (td + TRANSITION_BUFFER) if td > 0 else MIN_SLIDE_SWITCH_DELAY
            #   clip_starts[i] = boundaries[i] + max(offset, MIN_SLIDE_SWITCH_DELAY)
            #
            # TRANSITION_BUFFER is critical: the advance loop also waits
            # td + TRANSITION_BUFFER before the first key so that no key
            # is sent during the transition.  The same offset is used here so
            # clip_starts align with when the transition is fully painted and
            # the first animation key has been sent (or is about to be).
            #
            # split_video() in slide_video_scorm.py uses:
            #   • clip_starts[i]         as the clip's TRIM START
            #   • clip_starts[i+1] - 1f  as the clip's TRIM END (non-last)
            # This captures the full content of each slide and stops exactly
            # one frame before the next slide appears.
            try:
                # transition_durations_pre is [0.0]*n (set in step 9) because
                # transition animations were stripped from the prepared PPTX.
                # clip_starts therefore use MIN_SLIDE_SWITCH_DELAY (1.0 s) as
                # the per-slide offset — correct since slides switch instantly.
                transition_durations = transition_durations_pre
                sys.stderr.write(
                    f"  [timings] transition_durations for clip_starts: "
                    f"{transition_durations}\n"
                )

                # ── Build boundaries from actual advance timestamps ────────
                n = len(click_steps)
                if actual_advances and len(actual_advances) == n - 1:
                    # Ideal path: one recorded timestamp per slide boundary
                    s_boundaries = (
                        [0.0] + actual_advances + [round(record_duration, 3)]
                    )
                    boundary_source = "actual_advances"
                else:
                    # Fallback: use the formula (less accurate but always works)
                    s_boundaries_computed, _, _, _ = compute_slide_boundaries(
                        click_steps, transition_durations
                    )
                    s_boundaries = s_boundaries_computed
                    boundary_source = (
                        f"computed_formula "
                        f"(actual_advances had {len(actual_advances)} entries, "
                        f"expected {n - 1})"
                    )
                    sys.stderr.write(
                        f"  [timings] WARNING: using fallback boundaries ({boundary_source})\n"
                    )

                log_fh.write(f"[timings] boundary_source: {boundary_source}\n")

                # ── Compute slide_durations ───────────────────────────────
                s_slide_durs = [
                    round(s_boundaries[i + 1] - s_boundaries[i], 3)
                    for i in range(n)
                ]

                # ── Compute clip_starts from actual boundaries ────────────
                # clip_starts[0] = 0.0 (slide 1 is already on screen at t=0)
                # clip_starts[i] = boundary[i] + offset, where:
                #   offset = td + TRANSITION_BUFFER  (when td > 0)
                #          = MIN_SLIDE_SWITCH_DELAY   (when td == 0)
                # The TRANSITION_BUFFER matches the extra wait added in step 9
                # before the first animation key.  Clips therefore start just
                # AFTER the transition has fully painted and the first animation
                # key has been sent, avoiding any residual transition frames.
                s_clip_starts = [0.0]
                for i in range(1, n):
                    td = transition_durations[i] if i < len(transition_durations) else 0.0
                    if td > 0.0:
                        offset = td + TRANSITION_BUFFER
                    else:
                        offset = MIN_SLIDE_SWITCH_DELAY
                    offset    = max(offset, MIN_SLIDE_SWITCH_DELAY)
                    raw_start = s_boundaries[i] + offset
                    max_start = round(s_boundaries[i + 1] - 0.1, 3)
                    s_clip_starts.append(round(min(raw_start, max_start), 3))

                # ── Write JSON ────────────────────────────────────────────
                timings_path = os.path.join(
                    os.path.dirname(output_video), "slide_timings.json"
                )
                timings_data = {
                    "boundaries":           s_boundaries,
                    "clip_starts":          s_clip_starts,
                    "slide_durations":      s_slide_durs,
                    "transition_durations": transition_durations,
                    "click_steps":          list(click_steps),
                    "record_duration":      record_duration,
                    "boundary_source":      boundary_source,
                }
                # ── Detect + store embedded video media info ──────────────
                # LO Impress cannot play embedded videos in headless mode;
                # slide_video_scorm.py will overlay the real video file in
                # post-processing using FFmpeg.  We scan the ORIGINAL pptx
                # (not the prepared click-only copy) for <a:videoFile> shapes
                # and store their geometry and duration so the splitter can
                # create the composite clip without needing the PPTX again.
                assets_dir  = os.path.dirname(output_video)
                media_items = _detect_pptx_media(ppt_path)
                if media_items:
                    media_items = _extract_pptx_media(
                        ppt_path, media_items, assets_dir
                    )
                    # Convert EMU geometry to recording pixels and store
                    slide_media_out = []
                    for mi in media_items:
                        scx = mi["slide_cx_emu"] or 1
                        scy = mi["slide_cy_emu"] or 1
                        x_px  = round(mi["x_emu"]  / scx * SLIDE_WIDTH)
                        y_px  = round(mi["y_emu"]  / scy * SLIDE_HEIGHT)
                        w_px  = round(mi["cx_emu"] / scx * SLIDE_WIDTH)
                        h_px  = round(mi["cy_emu"] / scy * SLIDE_HEIGHT)
                        slide_media_out.append({
                            "slide_idx":       mi["slide_idx"],
                            "media_file":      os.path.join(
                                "media",
                                os.path.basename(mi["media_local_path"]),
                            ),
                            "duration_s":      round(mi["duration_ms"] / 1000.0, 3),
                            "x_px":            x_px,
                            "y_px":            y_px,
                            "w_px":            w_px,
                            "h_px":            h_px,
                        })
                        sys.stderr.write(
                            f"  [media] slide {mi['slide_idx'] + 1}: "
                            f"video {os.path.basename(mi['media_local_path'])} "
                            f"{mi['duration_ms'] / 1000:.1f}s @ "
                            f"({x_px},{y_px}) {w_px}×{h_px}px\n"
                        )
                    timings_data["slide_media"] = slide_media_out
                    log_fh.write(
                        f"[media] {len(slide_media_out)} embedded video(s) detected "
                        f"and extracted\n"
                    )

                with open(timings_path, "w", encoding="utf-8") as tf:
                    json.dump(timings_data, tf, indent=2)
                sys.stderr.write(f"  [timings] Wrote {timings_path}\n")
                sys.stderr.write(
                    f"  [timings] boundary_source: {boundary_source}\n"
                    f"  [timings] boundaries : {s_boundaries}\n"
                    f"  [timings] clip_starts: {s_clip_starts}\n"
                )
                log_fh.write(
                    f"[timings] boundaries         : {s_boundaries}\n"
                    f"[timings] clip_starts         : {s_clip_starts}\n"
                    f"[timings] slide_durations     : {s_slide_durs}\n"
                    f"[timings] transition_durations: {transition_durations}\n"
                    f"[timings] TRANSITION_BUFFER   : {TRANSITION_BUFFER}s\n"
                )
            except Exception as tex:
                sys.stderr.write(
                    f"  [timings] WARNING: could not write timings: {tex}\n"
                )
                log_fh.write(f"[timings] WARNING: {tex}\n")

            log_fh.write("[done] Export successful\n")

    finally:
        for proc in reversed(procs):
            try:
                proc.terminate()
                proc.wait(timeout=5)
            except Exception:
                try:
                    proc.kill()
                except Exception:
                    pass
        shutil.rmtree(tmp_dir, ignore_errors=True)


# ─────────────────────────────────────────────────────────────────────────────
# FALLBACK: LibreOffice → PDF → image per slide → FFmpeg  (static, no anims)
# ─────────────────────────────────────────────────────────────────────────────

def ppt_to_pdf(ppt_path, out_dir, env=None):
    """Convert PPT/PPTX to PDF using LibreOffice headless."""
    result = subprocess.run(
        [
            LIBREOFFICE,
            "--headless",
            "--norestore",
            "--nofirststartwizard",
            "--nocrashreport",
            "--convert-to", "pdf",
            "--outdir", out_dir,
            ppt_path,
        ],
        capture_output=True, text=True,
        env=env,
    )
    if result.stderr:
        sys.stderr.write(result.stderr)
    if result.returncode != 0:
        raise RuntimeError(f"LibreOffice PDF conversion failed:\n{result.stderr}")
    base     = os.path.splitext(os.path.basename(ppt_path))[0]
    pdf_path = os.path.join(out_dir, base + ".pdf")
    if not os.path.exists(pdf_path):
        raise RuntimeError(f"LibreOffice ran but PDF not found: {pdf_path}")
    return pdf_path


def pdf_to_images(pdf_path, out_dir, dpi=150):
    """
    Convert PDF pages → PNG images, one per slide.

    Tries four methods in order:
      1. PyMuPDF  (pip install PyMuPDF)
      2. pdftoppm (sudo apt install poppler-utils)
      3. gs       (sudo apt install ghostscript)
      4. convert  (sudo apt install imagemagick)
    """
    prefix = os.path.join(out_dir, "slide")

    # ── Option 1: PyMuPDF ───────────────────────────────────────────────
    try:
        import fitz
        doc    = fitz.open(pdf_path)
        images = []
        mat    = fitz.Matrix(dpi / 72, dpi / 72)
        for page_num, page in enumerate(doc, start=1):
            pix      = page.get_pixmap(matrix=mat)
            img_path = os.path.join(out_dir, f"slide-{page_num:03d}.png")
            pix.save(img_path)
            images.append(img_path)
        doc.close()
        sys.stderr.write(f"  [images] PyMuPDF produced {len(images)} images\n")
        return images
    except ImportError:
        sys.stderr.write("  [images] PyMuPDF not installed, trying pdftoppm...\n")

    # ── Option 2: pdftoppm ──────────────────────────────────────────────
    try:
        result = subprocess.run(
            [PDFTOPPM, "-r", str(dpi), "-png", pdf_path, prefix],
            capture_output=True, text=True,
        )
        images = sorted(glob.glob(os.path.join(out_dir, "slide*.png")))
        if result.returncode == 0 and images:
            sys.stderr.write(f"  [images] pdftoppm produced {len(images)} images\n")
            return images
        sys.stderr.write(f"  [images] pdftoppm failed (exit {result.returncode})\n")
    except FileNotFoundError:
        sys.stderr.write("  [images] pdftoppm not found, trying Ghostscript...\n")

    # ── Option 3: Ghostscript ───────────────────────────────────────────
    try:
        result = subprocess.run(
            ["gs", "-dNOPAUSE", "-dBATCH", "-dSAFER",
             "-sDEVICE=png16m", f"-r{dpi}",
             f"-sOutputFile={prefix}-%03d.png", pdf_path],
            capture_output=True, text=True,
        )
        images = sorted(glob.glob(os.path.join(out_dir, "slide*.png")))
        if result.returncode == 0 and images:
            sys.stderr.write(f"  [images] Ghostscript produced {len(images)} images\n")
            return images
        sys.stderr.write(f"  [images] Ghostscript failed (exit {result.returncode})\n")
    except FileNotFoundError:
        sys.stderr.write("  [images] Ghostscript not found, trying ImageMagick...\n")

    # ── Option 4: ImageMagick ───────────────────────────────────────────
    try:
        result = subprocess.run(
            ["convert", "-density", str(dpi), pdf_path,
             "-quality", "90", os.path.join(out_dir, "slide-%d.png")],
            capture_output=True, text=True,
        )
        images = sorted(glob.glob(os.path.join(out_dir, "slide*.png")))
        if result.returncode == 0 and images:
            sys.stderr.write(f"  [images] ImageMagick produced {len(images)} images\n")
            return images
        sys.stderr.write(f"  [images] ImageMagick failed (exit {result.returncode})\n")
    except FileNotFoundError:
        sys.stderr.write("  [images] ImageMagick (convert) not found\n")

    raise RuntimeError(
        "No PDF-to-image converter is available on this server.\n"
        "Install one of: pip install PyMuPDF  OR  sudo apt install poppler-utils"
    )


def create_video_from_images(images, durations, output_video):
    """Stitch PNG slide images into MP4 using FFmpeg concat demuxer."""
    list_file = output_video + "_slides.txt"
    with open(list_file, "w") as f:
        for i, img in enumerate(images):
            dur = durations[i] if i < len(durations) else DEFAULT_SLIDE_DURATION
            f.write(f"file '{img}'\n")
            f.write(f"duration {dur}\n")
        if images:
            f.write(f"file '{images[-1]}'\n")

    result = subprocess.run(
        [
            FFMPEG, "-y",
            "-f", "concat", "-safe", "0", "-i", list_file,
            "-vf", (
                f"scale={SLIDE_WIDTH}:{SLIDE_HEIGHT}"
                ":force_original_aspect_ratio=decrease,"
                f"pad={SLIDE_WIDTH}:{SLIDE_HEIGHT}:(ow-iw)/2:(oh-ih)/2"
            ),
            "-c:v", "libx264", "-preset", "fast", "-crf", "23",
            "-pix_fmt", "yuv420p", "-r", "30",
            output_video,
        ],
        capture_output=True, text=True,
    )
    if result.stderr:
        sys.stderr.write(result.stderr[-2000:] + "\n")
    try:
        os.remove(list_file)
    except Exception:
        pass
    if result.returncode != 0 or not os.path.exists(output_video):
        raise RuntimeError(f"FFmpeg stitching failed (exit {result.returncode})")


def static_export(ppt_path, output_video, durations):
    """Fallback export pipeline: static PNGs only, no animations."""
    tmp_dir = tempfile.mkdtemp(prefix="ppt_static_")
    try:
        lo_env         = os.environ.copy()
        lo_env["HOME"] = tmp_dir
        _create_lo_offline_profile(tmp_dir)

        sys.stderr.write("  [static] Converting PPT → PDF (LibreOffice)...\n")
        pdf_path = ppt_to_pdf(ppt_path, tmp_dir, env=lo_env)

        sys.stderr.write("  [static] Converting PDF → PNG images...\n")
        images = pdf_to_images(pdf_path, tmp_dir)
        sys.stderr.write(f"  [static] {len(images)} images generated\n")

        while len(durations) < len(images):
            durations.append(float(DEFAULT_SLIDE_DURATION))
        durations = durations[: len(images)]

        sys.stderr.write("  [static] Stitching images → MP4 (FFmpeg)...\n")
        create_video_from_images(images, durations, output_video)
    finally:
        shutil.rmtree(tmp_dir, ignore_errors=True)


# ─────────────────────────────────────────────────────────────────────────────
# Main entry
# ─────────────────────────────────────────────────────────────────────────────

def export(ppt_path, output_video):
    """
    PPTX → full_presentation.mp4

    Tries animation-preserving Xvfb method first.
    Falls back to static image export if Xvfb is unavailable or fails.
    """
    ppt_path     = os.path.abspath(ppt_path)
    output_video = os.path.abspath(output_video)

    out_dir = os.path.dirname(output_video)
    if out_dir:
        os.makedirs(out_dir, exist_ok=True)

    sys.stderr.write("=== Step 1: Reading slide timings ===\n")
    durations, has_timings = get_slide_durations(ppt_path)

    sys.stderr.write("=== Step 1b: Counting per-slide click-animation groups ===\n")
    click_steps = get_slide_click_steps(ppt_path)

    # ── Primary: Xvfb  (animations preserved) ─────────────────────────────
    if _xvfb_available():
        sys.stderr.write(
            "=== Step 2: Xvfb slideshow recording (animations preserved) ===\n"
        )
        try:
            export_with_xvfb(ppt_path, output_video, click_steps)
            sys.stderr.write(
                f"Export complete (animations preserved): {output_video}\n"
            )
            return
        except Exception as e:
            sys.stderr.write(
                f"  [xvfb] Failed: {e}\n"
                "  Falling back to static export (no animations)...\n"
            )
    else:
        sys.stderr.write(
            "=== Step 2: Xvfb not found – using static fallback ===\n"
            "  To preserve animations: sudo apt install xvfb\n"
        )

    # ── Fallback: static PNG pipeline  (no animations) ────────────────────
    sys.stderr.write(
        "=== Step 2 (static fallback): Exporting slides as images ===\n"
    )
    static_export(ppt_path, output_video, list(durations))
    sys.stderr.write(
        f"Export complete (static slides, no animations): {output_video}\n"
    )


if __name__ == "__main__":
    if len(sys.argv) :
        sys.stderr.write("Usage: python3 export_video.py <ppt_path> <output_mp4>\n")
        sys.exit(1)
    try:
        export(sys.argv[1], sys.argv[2])
    except Exception as exc:
        sys.stderr.write(f"Error: {exc}\n")
        sys.exit(1)
