"""
slide_video_scorm.py  –  Two operating modes  (Linux version)
=============================================================

MODE 1 (called from PHP web server):
    python3 slide_video_scorm.py <ppt_path> <work_dir>

    Expects <work_dir>/assets/full_presentation.mp4 to ALREADY EXIST
    (created by export_video.py).

    Boundary source priority:
      1. slide_timings.json  (written by export_video.py alongside the MP4)
         Contains exact boundaries derived from recording constants, so they
         always match the Xvfb-captured video precisely.
      2. Per-slide advTm from the PPTX (python-pptx).
         Used when slide_timings.json is absent (e.g. static fallback export).
      3. Scene detection (FFmpeg scene filter).
         Last resort when PPTX timings don't match the video duration.

MODE 2 (run manually to export video then split):
    python3 slide_video_scorm.py --export <ppt_path> <work_dir>

    Calls export_video.py to produce full_presentation.mp4, then splits it.

Dependencies (install once on the server):
    sudo apt install ffmpeg poppler-utils libreoffice
    pip install python-pptx
"""

import subprocess
import os
import sys
import shutil
import json
import re
from pptx import Presentation


FFMPEG  = "ffmpeg"
FFPROBE = "ffprobe"

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

FREEZE_DURATION        = 1.5   # seconds to freeze on last frame
DEFAULT_SLIDE_DURATION = 5     # must match CreateVideo DefaultSlideDuration
VIDEO_FPS              = 30.0  # must match CreateVideo FramesPerSecond


# ─────────────────────────────────────────────────
# FFmpeg helpers
# ─────────────────────────────────────────────────

def get_video_duration(video_path):
    cmd = [
        FFPROBE, "-v", "error",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1",
        video_path,
    ]
    r = subprocess.run(cmd, capture_output=True, text=True)
    return float(r.stdout.strip())


def has_audio_stream(video_path):
    """Return True if the video file contains at least one audio stream."""
    cmd = [
        FFPROBE, "-v", "error",
        "-select_streams", "a",
        "-show_entries", "stream=codec_type",
        "-of", "default=noprint_wrappers=1:nokey=1",
        video_path,
    ]
    r = subprocess.run(cmd, capture_output=True, text=True)
    return bool(r.stdout.strip())


def add_freeze_frame(src_mp4, out_mp4, with_audio=True):
    vf = f"tpad=stop_mode=clone:stop_duration={FREEZE_DURATION}"
    cmd = [FFMPEG, "-y", "-i", src_mp4, "-vf", vf,
           "-c:v", "libx264", "-preset", "fast", "-crf", "23",
           "-pix_fmt", "yuv420p"]
    if with_audio:
        cmd += ["-c:a", "aac"]
    else:
        cmd += ["-an"]
    cmd.append(out_mp4)

    r = subprocess.run(cmd, capture_output=True, text=True)
    if r.returncode != 0:
        sys.stderr.write(f"  [ffmpeg freeze] error: {r.stderr[-400:]}\n")
        shutil.move(src_mp4, out_mp4)   # fallback: keep raw clip
    else:
        try:
            os.remove(src_mp4)
        except Exception:
            pass


# ─────────────────────────────────────────────────
# Pre-computed timings from export_video.py  (PRIMARY boundary source)
# ─────────────────────────────────────────────────

def load_slide_timings(work_dir):
    """
    Load pre-computed exact split boundaries written by export_video.py.

    export_video.py saves slide_timings.json alongside full_presentation.mp4
    in work_dir/assets/ after every successful Xvfb recording.  The file
    contains exact per-slide boundaries derived from the recording constants
    (FFMPEG_PRE_ROLL, ANIM_WAIT_BEFORE_FIRST, ANIM_WAIT_BETWEEN_STEPS) so
    they align precisely with the captured video — regardless of whether
    advTm timings were present in the original PPTX.

    This is the preferred boundary source.  If the file is absent (e.g. the
    video was produced by the static fallback) the caller falls back to the
    existing PPTX-timing / scene-detection logic.

    Returns:
        (boundaries, slide_durations, clip_starts, slide_media)
        where slide_media is a list of embedded-video dicts (may be empty),
        or (None, None, None, []) if the file is missing or invalid
    """
    timings_path = os.path.join(work_dir, "assets", "slide_timings.json")
    try:
        with open(timings_path, encoding="utf-8") as f:
            data = json.load(f)
        boundaries      = data.get("boundaries")
        slide_durations = data.get("slide_durations")
        # clip_starts: absent in recordings made before this fix — falls back
        # to None, which causes split_video() to use boundaries[i] unchanged.
        clip_starts     = data.get("clip_starts")
        # slide_media: list of embedded-video dicts (may be absent / empty)
        slide_media     = data.get("slide_media", [])
        if not boundaries or len(boundaries) < 2:
            sys.stderr.write(
                f"  [timings] {timings_path}: invalid "
                "(boundaries missing or too short)\n"
            )
            return None, None, None, []
        sys.stderr.write(
            f"  [timings] Loaded {len(boundaries) - 1} slide boundaries "
            f"from {timings_path}\n"
        )
        sys.stderr.write(f"  [timings] boundaries : {boundaries}\n")
        if clip_starts:
            sys.stderr.write(f"  [timings] clip_starts: {clip_starts}\n")
        if slide_media:
            sys.stderr.write(
                f"  [timings] slide_media: "
                f"{len(slide_media)} embedded video(s) detected\n"
            )
        return boundaries, slide_durations, clip_starts, slide_media
    except FileNotFoundError:
        sys.stderr.write(
            f"  [timings] {timings_path} not found — "
            "will use PPTX timing / scene-detection fallback\n"
        )
        return None, None, None, []
    except Exception as e:
        sys.stderr.write(f"  [timings] Cannot load {timings_path}: {e}\n")
        return None, None, None, []


# ─────────────────────────────────────────────────
# Export helper  (used in --export / MODE 2 only)
# ─────────────────────────────────────────────────

def export_full_video_ppt(ppt_path, output_video):
    """
    Export PPTX → full_presentation.mp4 using LibreOffice + FFmpeg (Linux).
    Delegates to export_video.py which runs the full pipeline:
      LibreOffice (PDF) → pdftoppm (PNG images) → FFmpeg (MP4).
    """
    if os.path.exists(output_video):
        os.remove(output_video)

    script_dir    = os.path.dirname(os.path.abspath(__file__))
    export_script = os.path.join(script_dir, "export_video.py")

    sys.stderr.write(f"  [export] Running export_video.py for {ppt_path}\n")
    result = subprocess.run(
        [sys.executable, export_script, ppt_path, output_video],
        stderr=sys.stderr,          # stream stderr live so caller sees progress
    )
    if result.returncode != 0 or not os.path.exists(output_video):
        raise RuntimeError(
            f"export_video.py failed (exit {result.returncode}) – "
            f"output not found: {output_video}"
        )
    sys.stderr.write("  [export] Export complete.\n")


# ─────────────────────────────────────────────────
# Get slide count + per-slide durations from PPT
# ─────────────────────────────────────────────────

def get_slide_info(ppt_path):
    """
    Read (slide_count, durations, has_timings) from a PPTX using python-pptx.

    Mirrors the logic originally handled by PowerPoint's COM API:
      - If a slide has <p:transition advClick="0" advTm="N"/> the duration is
        N milliseconds (converted to seconds).
      - Otherwise the slide uses DEFAULT_SLIDE_DURATION.

    This matches the timing that export_video.py feeds to FFmpeg, so the
    split boundaries computed here will align with the exported video.
    """
    prs = Presentation(ppt_path)
    count       = len(prs.slides)
    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")        # milliseconds string
                adv_click = trans.get("advClick", "1")
                # advClick="0" → auto-advance after advTm milliseconds
                if adv_click == "0" and adv_tm is not None:
                    dur = int(adv_tm) / 1000.0        # ms → seconds
                    has_timings = True
        except Exception as e:
            sys.stderr.write(f"  [pptx] slide {idx}: error reading timing – {e}\n")

        dur = max(dur, 0.5)          # at least 0.5 s per slide
        durations.append(dur)
        sys.stderr.write(f"  [pptx] slide {idx}: duration={dur:.2f}s\n")

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


# ─────────────────────────────────────────────────
# Timing-based boundary calculation  (primary method)
# ─────────────────────────────────────────────────

def compute_timing_boundaries(slide_durations, total_video_dur):
    """
    Compute exact split boundaries from per-slide durations.

    Returns a list of (n+1) floats: [0.0, t1, t2, ..., total_video_dur]
    where n = len(slide_durations).

    Returns None if the calculated total differs from total_video_dur by
    more than the allowed tolerance (triggers scene-detection fallback).

    IMPORTANT: intermediate boundaries are derived directly from the PPT
    slide durations WITHOUT any proportional scaling.  Scaling would push
    every intermediate boundary past the actual slide-transition frame when
    the encoder adds a tail (e.g. PowerPoint's CreateVideo routinely makes
    the file a second or two longer than the sum of slide durations).  That
    would cause the trimmer to include the first frame(s) of slide N+1 in
    slide N's clip, which add_freeze_frame then freezes – producing the
    "next slide bleeds into previous" artefact.

    Only the LAST boundary is set to total_video_dur so the final slide
    absorbs any encoder padding without affecting earlier cuts.
    """
    calculated_total = sum(slide_durations)
    n = len(slide_durations)

    # Allow up to 20 % discrepancy OR 5 seconds, whichever is larger.
    # If the video is wildly different from the PPT data, fall back to
    # scene detection instead.
    tolerance = max(5.0, calculated_total * 0.20)
    diff = abs(calculated_total - total_video_dur)

    sys.stderr.write(
        f"  [timing] calculated={calculated_total:.2f}s  "
        f"video={total_video_dur:.2f}s  "
        f"diff={diff:.2f}s  tolerance={tolerance:.2f}s\n"
    )

    if diff > tolerance:
        sys.stderr.write(
            "  [timing] Discrepancy too large – falling back to scene detection\n"
        )
        return None

    # Build boundaries from exact cumulative PPT timings.
    # Do NOT apply a scale factor to intermediate boundaries.
    boundaries = [0.0]
    t = 0.0
    for dur in slide_durations[:-1]:   # all slides except the last
        t += dur
        boundaries.append(round(t, 3))
    # The last boundary is total_video_dur so the final slide clip gets
    # everything to the end of the file (including any encoder tail).
    boundaries.append(total_video_dur)

    sys.stderr.write(f"  [timing] boundaries ({n} slides): {boundaries}\n")
    return boundaries


# ─────────────────────────────────────────────────
# Scene-detection split  (fallback)
# ─────────────────────────────────────────────────

def detect_scene_boundaries(full_video, expected_slides):
    """
    Use FFmpeg scene filter to find where slides change.
    Returns a list of (expected_slides + 1) timestamps.

    This is a FALLBACK used only when PPT timing data is unavailable or
    doesn't match the video duration.
    """
    total_dur = get_video_duration(full_video)
    sys.stderr.write(f"  [scene] full video duration: {total_dur:.3f}s\n")

    raw_times = None

    # Try progressively lower thresholds until we find enough boundaries
    for threshold in [0.35, 0.25, 0.15, 0.10, 0.05]:
        cmd = [
            FFMPEG, "-i", full_video,
            "-vf", f"select='gt(scene,{threshold})',showinfo",
            "-vsync", "vfr", "-f", "null", "-",
        ]
        r = subprocess.run(cmd, capture_output=True, text=True)

        times = [0.0]
        for m in re.finditer(r'pts_time:([\d.]+)', r.stderr):
            t = float(m.group(1))
            if t - times[-1] >= 0.3:   # min 0.3 s gap between boundaries
                times.append(t)

        sys.stderr.write(
            f"  [scene] threshold={threshold}: "
            f"found {len(times)-1} boundaries → {times}\n"
        )

        raw_times = times
        if len(times) >= expected_slides:
            break

    # Normalize to exactly (expected_slides + 1) boundaries
    # so split_video always receives the right number of entries.
    raw_times = raw_times or [0.0]
    boundaries = _normalize_boundaries(raw_times, expected_slides, total_dur)
    sys.stderr.write(f"  [scene] normalized boundaries: {boundaries}\n")
    return boundaries, total_dur


def _normalize_boundaries(raw_times, expected_slides, total_dur):
    """
    Given a raw list of scene-change timestamps (starting at 0.0, NOT
    including the video end), produce exactly (expected_slides + 1) entries
    ending with total_dur.

    • Too many  → keep the first expected_slides timestamps; the last slide
                  absorbs any extra detected changes.
    • Too few   → fill missing slots by evenly subdividing the last segment.
    """
    # raw_times starts at 0.0 and does NOT include total_dur yet
    starts = raw_times  # list of start timestamps, len >= 1

    if len(starts) > expected_slides:
        # Keep only the first expected_slides entries
        starts = starts[:expected_slides]
    elif len(starts) < expected_slides:
        # Fill missing entries by subdividing the last gap evenly
        last = starts[-1]
        remaining = expected_slides - len(starts)
        gap = (total_dur - last) / (remaining + 1)
        for k in range(1, remaining + 1):
            starts.append(round(last + gap * k, 3))

    boundaries = starts + [total_dur]
    return boundaries


# ─────────────────────────────────────────────────
# Embedded-video overlay (post-processing)
# ─────────────────────────────────────────────────

def overlay_embedded_video(slide_mp4, video_path, x_px, y_px, w_px, h_px,
                           video_dur_s, out_mp4):
    """
    Replace a per-slide clip that shows a static video poster with a
    composite clip where the actual embedded video plays inside the slide.

    Strategy
    --------
    LibreOffice Impress cannot play embedded videos in headless Xvfb mode,
    so the recording of slide N captures only a static poster/thumbnail.
    Here we:
      1. Extract one representative frame from the slide recording as a
         static PNG background (covers the full slide including title,
         decorations, and the video placeholder poster).
      2. Loop that frame as a static background for the full video duration.
      3. Scale the embedded video to the shape's pixel size and overlay it at
         the shape's pixel offset on the slide canvas.
      4. Mix the embedded video's audio stream (if any) into the output.

    The resulting clip is `video_dur_s` seconds long and shows the slide's
    static content with the video playing in its placeholder area — exactly
    what the learner would see in a live PowerPoint session.

    Parameters
    ----------
    slide_mp4   : path of the existing (short) per-slide clip from split_video()
    video_path  : path of the extracted embedded video file
    x_px, y_px  : top-left pixel offset of the video shape on the 1280×720 canvas
    w_px, h_px  : pixel width/height of the video shape
    video_dur_s : duration of the embedded video in seconds
    out_mp4     : destination path for the composite clip
                  (may be the same path as slide_mp4 — handled safely)
    """
    # ── 0a. If video_dur_s is zero (PPTX mediacall dur was absent), probe the
    #         actual media file duration so FFmpeg gets a valid -t value.
    if video_dur_s <= 0:
        try:
            video_dur_s = get_video_duration(video_path)
            sys.stderr.write(
                f"  [overlay] video_dur_s was 0 — "
                f"probed actual duration: {video_dur_s:.3f}s\n"
            )
        except Exception as probe_exc:
            sys.stderr.write(
                f"  [overlay] Cannot determine video duration ({probe_exc}) "
                "— skipping overlay\n"
            )
            return

    # ── 0b. When slide_mp4 and out_mp4 are the same file, make a safe temp
    #         copy of the source so the original is preserved as a fallback.
    temp_src = None
    input_mp4 = slide_mp4
    if os.path.abspath(slide_mp4) == os.path.abspath(out_mp4):
        temp_src = out_mp4 + "_src.mp4"
        try:
            shutil.copy2(slide_mp4, temp_src)
            input_mp4 = temp_src
        except Exception as exc:
            sys.stderr.write(f"  [overlay] Could not copy source clip: {exc}\n")
            return

    def _cleanup_temps(*paths):
        for p in paths:
            if p and os.path.exists(p):
                try:
                    os.remove(p)
                except Exception:
                    pass

    # ── 1. Extract a mid-point frame from the slide recording ─────────────
    bg_png = out_mp4 + "_bg.png"
    try:
        mid = get_video_duration(input_mp4) / 2.0
    except Exception:
        mid = 0.0

    r = subprocess.run(
        [FFMPEG, "-y",
         "-ss", str(mid), "-i", input_mp4,
         "-frames:v", "1", bg_png],
        capture_output=True, text=True,
    )
    if r.returncode != 0 or not os.path.exists(bg_png):
        sys.stderr.write(
            f"  [overlay] Could not extract background frame: {r.stderr[-300:]}\n"
        )
        _cleanup_temps(temp_src, bg_png)
        return   # original out_mp4 (= slide_mp4 copy) still intact

    # ── 2. Build FFmpeg overlay command ───────────────────────────────────
    # Inputs:
    #   [0] static background image looped at 25 fps
    #   [1] embedded video file
    #
    # Filter graph:
    #   [0:v] looped background (full 1280×720 slide canvas)
    #   [1:v] scale to (w_px × h_px) → overlay at (x_px, y_px)
    #   shortest=1 on overlay: output stops when the shorter stream (the
    #   finite embedded video) ends — prevents an infinite encode loop.
    #
    # Clamp position so the video never goes off-canvas (defensive).
    slide_w, slide_h = 1280, 720
    x_px = max(0, min(x_px, slide_w - 2))
    y_px = max(0, min(y_px, slide_h - 2))
    w_px = max(2, min(w_px, slide_w - x_px))
    h_px = max(2, min(h_px, slide_h - y_px))

    # libx264 requires even dimensions
    w_px = w_px if w_px % 2 == 0 else w_px - 1
    h_px = h_px if h_px % 2 == 0 else h_px - 1

    filter_complex = (
        f"[1:v]scale={w_px}:{h_px}[vid];"
        f"[0:v][vid]overlay={x_px}:{y_px}:shortest=1[v]"
    )

    vid_has_audio = has_audio_stream(video_path)

    tmp_out = out_mp4 + "_tmp.mp4"
    cmd = [
        FFMPEG, "-y",
        # Input 0: looped background image at 25 fps
        "-loop", "1", "-r", "25", "-i", bg_png,
        # Input 1: embedded video
        "-i", video_path,
        "-filter_complex", filter_complex,
        "-map", "[v]",
    ]
    if vid_has_audio:
        cmd += ["-map", "1:a", "-c:a", "aac"]
    else:
        cmd += ["-an"]

    cmd += [
        "-c:v", "libx264", "-preset", "fast", "-crf", "23",
        # Main Profile + level 4.0: universally supported by Windows decoders
        "-profile:v", "main", "-level", "4.0",
        "-pix_fmt", "yuv420p",
        # Output at 25 fps (avoids variable-framerate artefacts from looped PNG)
        "-r", "25",
        # faststart: move moov atom to front so the file is valid for streaming
        "-movflags", "+faststart",
        "-t", str(video_dur_s),
        tmp_out,
    ]

    r = subprocess.run(cmd, capture_output=True, text=True)
    _cleanup_temps(bg_png)

    # Validate the output: rc=0 but empty/tiny file = silent FFmpeg failure
    tmp_size = os.path.getsize(tmp_out) if os.path.exists(tmp_out) else 0
    if r.returncode != 0 or tmp_size < 4096:
        sys.stderr.write(
            f"  [overlay] FFmpeg overlay failed "
            f"(rc={r.returncode}, size={tmp_size}): {r.stderr[-600:]}\n"
        )
        _cleanup_temps(tmp_out, temp_src)
        # out_mp4 is either untouched (different path) or still the original
        # copy (temp_src was the input, out_mp4 was never written to yet)
        return

    shutil.move(tmp_out, out_mp4)
    _cleanup_temps(temp_src)
    sys.stderr.write(
        f"  [overlay] Created composite clip: {out_mp4} "
        f"({video_dur_s:.1f}s, video @ ({x_px},{y_px}) {w_px}×{h_px}px)\n"
    )


# ─────────────────────────────────────────────────
# Video splitting
# ─────────────────────────────────────────────────

def split_video(full_video, boundaries, total_dur, total_slides, slides_dir,
                clip_starts=None):
    """
    Cut full_video into per-slide MP4s using trim filter, then add freeze.

    clip_starts: optional list of actual trim-start timestamps (one per slide).
        When present (from slide_timings.json written by export_video.py),
        each per-slide clip begins at clip_starts[i] rather than boundaries[i].
        clip_starts[i] = boundaries[i] + max(transition_dur, MIN_SLIDE_SWITCH_DELAY)
        so the clip skips the outgoing-slide transition while LibreOffice
        renders the incoming slide — preventing the previous slide's frame
        from appearing as a thumbnail at the start of the current clip.

        Trim END for non-last clips is always boundaries[i+1] - ONE_FRAME,
        i.e. just before the advance keypress was sent.  This means:
          • The current clip always ends on the last frame of its OWN content.
          • The transition animation frames (immediately after the advance) are
            excluded — no next-slide content bleeds into the current clip.
          • With actual_advances-based boundaries (boundary_source =
            "actual_advances" in slide_timings.json) boundaries[i+1] is the
            true wall-clock advance time, so no content is ever cut short.

        Using clip_starts[i+1] as end_trim was WRONG: it would extend the
        current clip 1.5 s PAST the advance timestamp into the next slide's
        transition, causing next-slide frames to appear at the end of the
        current video.

        Falls back to boundaries[i] / boundaries[i+1] when clip_starts absent.
    """
    os.makedirs(slides_dir, exist_ok=True)
    output_paths = []

    # Detect audio once for all clips; fallback videos (PNG-based) have no audio.
    audio = has_audio_stream(full_video)
    sys.stderr.write(f"  [split] audio detected in full video: {audio}\n")

    # One video-frame's worth of time at the target FPS.
    ONE_FRAME = 1.0 / VIDEO_FPS

    for i in range(total_slides):
        # ── CLIP START ────────────────────────────────────────────────────────
        # Use clip_starts[i] when available so the clip begins with the new
        # slide already fully visible (skips LO transition animation).
        if clip_starts is not None and i < len(clip_starts):
            start = max(0.0, float(clip_starts[i]))
        else:
            start = boundaries[i]

        end   = boundaries[i + 1] if i + 1 < len(boundaries) else total_dur
        end   = min(end, total_dur)

        # ── CLIP END ──────────────────────────────────────────────────────────
        # Always cut at boundaries[i+1] - ONE_FRAME for non-last clips.
        #
        # boundaries[i+1]  = actual wall-clock time the advance keypress was SENT
        #                     (from actual_advances in slide_timings.json).
        # Cutting one frame before this ensures:
        #   • The current clip ends on its OWN last frame.
        #   • The transition animation (LO rendering the next slide) is fully
        #     excluded — no next-slide frames bleed into this clip.
        #   • Because boundaries are ACTUAL timestamps (not computed), no content
        #     is ever cut short: all slide content up to the advance is included.
        #
        # DO NOT use clip_starts[i+1] here: that is boundaries[i+1] + 1.5 s,
        # which would extend this clip 1.5 s INTO the next slide's transition.
        is_last = (i == total_slides - 1)
        if is_last:
            end_trim = end
        else:
            # end = boundaries[i+1]  (set above from the boundaries list)
            end_trim = max(start + ONE_FRAME, end - ONE_FRAME)

        dur = end_trim - start
        if dur <= 0:
            sys.stderr.write(f"  [split] slide {i+1}: zero duration – skipping\n")
            continue

        tmp_mp4 = os.path.join(slides_dir, f"_tmp_{i+1}.mp4")
        out_mp4 = os.path.join(slides_dir, f"slide_{i+1}.mp4")

        sys.stderr.write(
            f"  [split] slide {i+1}: {start:.3f}s → {end_trim:.3f}s "
            f"(boundary={end:.3f}s, dur={dur:.3f}s)\n"
        )

        vf = f"trim=start={start}:end={end_trim},setpts=PTS-STARTPTS"

        cmd = [FFMPEG, "-y", "-i", full_video, "-vf", vf,
               "-c:v", "libx264", "-preset", "fast", "-crf", "23",
               "-pix_fmt", "yuv420p"]

        if audio:
            af = f"atrim=start={start}:end={end_trim},asetpts=PTS-STARTPTS"
            cmd += ["-af", af, "-c:a", "aac"]
        else:
            cmd += ["-an"]

        cmd.append(tmp_mp4)

        r = subprocess.run(cmd, capture_output=True, text=True)
        if r.returncode != 0:
            sys.stderr.write(f"  [split] FFmpeg cut error: {r.stderr[-300:]}\n")
            continue

        add_freeze_frame(tmp_mp4, out_mp4, with_audio=audio)
        sys.stderr.write(f"  [split] created: {out_mp4}\n")
        output_paths.append(out_mp4)

    return output_paths


# ─────────────────────────────────────────────────
# Main pipelines
# ─────────────────────────────────────────────────

def pipeline_split_only(ppt_path, work_dir):
    """
    MODE 1 – called from PHP.
    full_presentation.mp4 must already exist in work_dir/assets/.
    """
    slides_dir = os.path.join(work_dir, "assets", "slides")
    full_video = os.path.join(work_dir, "assets", "full_presentation.mp4")

    if not os.path.exists(full_video):
        raise RuntimeError(
            f"full_presentation.mp4 not found at {full_video}. "
            "Run with --export first from a desktop session."
        )

    total_dur = get_video_duration(full_video)
    sys.stderr.write(f"Video duration: {total_dur:.3f}s\n")

    # ── Primary: pre-computed boundaries from export_video.py ──────────────
    sys.stderr.write("=== Checking for pre-computed slide timings ===\n")
    precomp_boundaries, _, precomp_clip_starts, slide_media = load_slide_timings(work_dir)

    if precomp_boundaries is not None:
        total_slides = len(precomp_boundaries) - 1
        sys.stderr.write(
            f"  [timings] Using pre-computed boundaries "
            f"for {total_slides} slides\n"
        )
        output_paths = split_video(
            full_video, precomp_boundaries, total_dur, total_slides, slides_dir,
            clip_starts=precomp_clip_starts,
        )
    else:
        slide_media = []
        # ── Secondary: use PPT timing data for exact boundaries ──────────────
        sys.stderr.write("=== Getting slide info from PPT ===\n")
        total_slides, slide_durations, has_timings = get_slide_info(ppt_path)
        sys.stderr.write(f"Slide count from PPT: {total_slides}\n")

        sys.stderr.write("=== Computing timing-based boundaries ===\n")
        boundaries = compute_timing_boundaries(slide_durations, total_dur)

        # ── Fallback: scene detection ──────────────────────────────────────
        if boundaries is None:
            sys.stderr.write("=== Falling back to scene detection ===\n")
            boundaries, total_dur = detect_scene_boundaries(
                full_video, total_slides
            )

        output_paths = split_video(
            full_video, boundaries, total_dur, total_slides, slides_dir
        )

    # ── Embedded-video overlay (post-processing) ────────────────────────────
    if slide_media and output_paths:
        sys.stderr.write("=== Applying embedded-video overlays ===\n")
        assets_dir   = os.path.join(work_dir, "assets")
        media_by_idx = {m["slide_idx"]: m for m in slide_media}
        for i, out_mp4 in enumerate(output_paths):
            if i not in media_by_idx:
                continue
            mi         = media_by_idx[i]
            video_path = os.path.join(assets_dir, mi["media_file"])
            if not os.path.exists(video_path):
                sys.stderr.write(
                    f"  [overlay] slide {i+1}: media file missing "
                    f"({video_path}) – skipping overlay\n"
                )
                continue
            if not os.path.exists(out_mp4):
                sys.stderr.write(
                    f"  [overlay] slide {i+1}: clip missing ({out_mp4}) "
                    "– skipping overlay\n"
                )
                continue
            sys.stderr.write(f"  [overlay] slide {i+1}: overlaying {video_path}\n")
            overlay_embedded_video(
                out_mp4, video_path,
                mi["x_px"], mi["y_px"], mi["w_px"], mi["h_px"],
                mi["duration_s"], out_mp4,
            )

    slides = [
        {
            "id":    i + 1,
            "title": f"Slide {i + 1}",
            "type":  "video",
            "file":  f"slides/{os.path.basename(p)}",
        }
        for i, p in enumerate(output_paths)
    ]

    sys.stderr.write(f"Done. {len(slides)} slides produced.\n")
    print(json.dumps(slides))


def pipeline_export_and_split(ppt_path, work_dir):
    """
    MODE 2 – run manually from desktop.
    Exports full video then splits.
    """
    slides_dir = os.path.join(work_dir, "assets", "slides")
    full_video = os.path.join(work_dir, "assets", "full_presentation.mp4")

    os.makedirs(os.path.dirname(full_video), exist_ok=True)

    sys.stderr.write("=== Step 1: Export full video via PowerPoint ===\n")
    export_full_video_ppt(ppt_path, full_video)

    total_dur = get_video_duration(full_video)
    sys.stderr.write(f"Video duration: {total_dur:.3f}s\n")

    # ── Primary: pre-computed boundaries from export_video.py ──────────────
    sys.stderr.write("=== Step 2: Checking for pre-computed slide timings ===\n")
    precomp_boundaries, _, precomp_clip_starts, slide_media = load_slide_timings(work_dir)

    if precomp_boundaries is not None:
        total_slides = len(precomp_boundaries) - 1
        sys.stderr.write(
            f"  [timings] Using pre-computed boundaries "
            f"for {total_slides} slides\n"
        )
        sys.stderr.write("=== Step 3: Split video ===\n")
        output_paths = split_video(
            full_video, precomp_boundaries, total_dur, total_slides, slides_dir,
            clip_starts=precomp_clip_starts,
        )
    else:
        slide_media = []
        # ── Secondary: use PPT timing data for exact boundaries ──────────────
        sys.stderr.write("=== Step 2b: Get slide info from PPT ===\n")
        total_slides, slide_durations, has_timings = get_slide_info(ppt_path)
        sys.stderr.write(f"Slide count: {total_slides}\n")

        sys.stderr.write("=== Step 3: Compute boundaries ===\n")
        boundaries = compute_timing_boundaries(slide_durations, total_dur)

        # ── Fallback: scene detection ──────────────────────────────────────
        if boundaries is None:
            sys.stderr.write(
                "=== Step 3b: Falling back to scene detection ===\n"
            )
            boundaries, total_dur = detect_scene_boundaries(
                full_video, total_slides
            )

        sys.stderr.write("=== Step 4: Split video ===\n")
        output_paths = split_video(
            full_video, boundaries, total_dur, total_slides, slides_dir
        )

    # ── Embedded-video overlay (post-processing) ────────────────────────────
    if slide_media and output_paths:
        sys.stderr.write("=== Step 5: Applying embedded-video overlays ===\n")
        assets_dir   = os.path.join(work_dir, "assets")
        media_by_idx = {m["slide_idx"]: m for m in slide_media}
        for i, out_mp4 in enumerate(output_paths):
            if i not in media_by_idx:
                continue
            mi         = media_by_idx[i]
            video_path = os.path.join(assets_dir, mi["media_file"])
            if not os.path.exists(video_path):
                sys.stderr.write(
                    f"  [overlay] slide {i+1}: media file missing "
                    f"({video_path}) – skipping overlay\n"
                )
                continue
            if not os.path.exists(out_mp4):
                sys.stderr.write(
                    f"  [overlay] slide {i+1}: clip missing ({out_mp4}) "
                    "– skipping overlay\n"
                )
                continue
            sys.stderr.write(f"  [overlay] slide {i+1}: overlaying {video_path}\n")
            overlay_embedded_video(
                out_mp4, video_path,
                mi["x_px"], mi["y_px"], mi["w_px"], mi["h_px"],
                mi["duration_s"], out_mp4,
            )

    slides = [
        {
            "id":    i + 1,
            "title": f"Slide {i + 1}",
            "type":  "video",
            "file":  f"slides/{os.path.basename(p)}",
        }
        for i, p in enumerate(output_paths)
    ]

    sys.stderr.write(f"Done. {len(slides)} slides produced.\n")
    print(json.dumps(slides))


# ─────────────────────────────────────────────────
if __name__ == "__main__":
    if len(sys.argv) == 4 and sys.argv[1] == "--export":
        # Manual desktop mode: --export <ppt_path> <work_dir>
        pipeline_export_and_split(sys.argv[2], sys.argv[3])

    elif len(sys.argv) == 3:
        # PHP web mode: <ppt_path> <work_dir>
        pipeline_split_only(sys.argv[1], sys.argv[2])

    else:
        sys.stderr.write(
            "Usage:\n"
            "  Web mode:     python slide_video_scorm.py <ppt_path> <work_dir>\n"
            "  Desktop mode: python slide_video_scorm.py --export <ppt_path> <work_dir>\n"
        )
        sys.exit(1)
