CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/speaker-toolkit

Six-skill presentation system: ingest talks into a rhetoric vault, run interactive clarification, generate a speaker profile, create presentations that match your documented patterns, produce the deck illustrations + thumbnail visual layer, and publish talk pages to a Jekyll shownotes site. Includes a 111-entry Presentation Patterns taxonomy (81 observable: 62 patterns + 19 antipatterns; 30 unobservable: 21 patterns + 9 antipatterns) for scoring, brainstorming, and go-live preparation.

75

Quality

94%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

video-slide-extraction.pyskills/vault-ingress/scripts/

#!/usr/bin/env python3
"""Extract scoped slide-region and context artifacts from conference videos.

Downloads frames via ffmpeg, resolves a slide-region crop, deduplicates using
perceptual hashing, and writes separately scoped slide-region/context PDFs.

Usage:
    video-slide-extraction.py <video> <outdir> <youtube_id> [--fps 0.5]
                              [--threshold 8]
                              [--region auto|none|LEFT,TOP,RIGHT,BOTTOM]
                              [--region-verified]
                              [--no-context-pdf]

    <video>       Path to downloaded MP4 video
    <outdir>      Directory for intermediate files and output artifacts
    <youtube_id>  YouTube video ID (used for naming the output PDF)
    --fps         Frames per second to extract (default: 0.5 = 1 frame per 2s)
    --threshold   Largest perceptual-hash distance treated as the same slide
                  (default: 8). Higher values merge more and keep fewer frames.
    --region      Crop used for hashing: auto-detect, none, or four normalized
                  coordinates (default: auto)
    --region-verified
                  Assert that a manually supplied crop was visually verified
    --no-context-pdf
                  Omit the extra full-frame context PDF after a verified manual
                  crop; review-required runs always preserve context

Examples:
    video-slide-extraction.py /vault/video.mp4 /vault/output AbCdEfGhI_1
    video-slide-extraction.py /vault/video.mp4 /vault/output AbCdEfGhI_1 --fps 0.5 --threshold 12
"""

import argparse
import copy
import hashlib
import json
import os
import stat
import subprocess
import sys
import tempfile

from artifact_locator import ArtifactLocatorError, materialize_native_root
from ingress_contract import YOUTUBE_ID_RE
from video_evidence import (
    VideoEvidenceAssessment,
    VideoEvidenceError,
    build_video_source_receipt,
    video_source_receipt_generation_drift,
)

# Pipeline version — stamped into every video-extracted vault entry (DB row +
# PDF metadata) so artifacts record which extraction iteration produced them.
# Bump this whenever extraction BEHAVIOR changes: default --fps or --threshold,
# the download tier, region-detection logic, dedup hashing, or PDF assembly.
# See skills/vault-ingress/references/video-slide-extraction.md ("Pipeline
# Versioning") for the policy.
PIPELINE_VERSION = "0.13.0"

# Shape version of the structured_data.video_extraction record (distinct from
# PIPELINE_VERSION, which tracks extractor behavior — this tracks the record's
# field shape). Bump on any field add/remove/rename. Records written before this
# field existed have no schema_version and are read as the legacy shape (0).
# See skills/vault-ingress/references/schemas-db.md ("Video Extraction Output Schema").
SCHEMA_VERSION = 4

VIDEO_DEPENDENCY_INSTALL = (
    'pip install "ImageHash==4.3.2" "numpy==2.2.6" "Pillow==12.3.0" "filelock==3.32.2"'
)

# Heavy deps are only needed for the extraction pipeline itself. Import them
# without exiting on failure so the module stays importable (and --version /
# --help stay answerable) in a minimal environment. main() enforces presence
# before any extraction runs.
try:
    import imagehash
    import numpy as _numpy_dependency
    from filelock import FileLock
    from PIL import Image

    _DEPS_ERROR = None
except ImportError as exc:
    imagehash = None
    _numpy_dependency = None
    FileLock = None
    Image = None
    _DEPS_ERROR = exc


NormalizedSlideRegion = tuple[float, float, float, float]


def validate_youtube_id(value: object) -> str:
    """Return one canonical ingress YouTube ID or fail with a closed reason."""
    if not isinstance(value, str) or YOUTUBE_ID_RE.fullmatch(value) is None:
        raise ValueError("youtube_id_invalid")
    return value


class VideoSourceLineageError(RuntimeError):
    """No derivative could be bound to one exact source-video generation.

    Raised instead of returning a manifest. A schema-4 record exists only when
    the engine-owned receipt was captured around the same extraction run, so a
    run that cannot prove that produces no record at all.
    """

    def __init__(
        self,
        message: str,
        *,
        reason_code: str,
        details: dict[str, object] | None = None,
    ) -> None:
        super().__init__(message)
        self.reason_code = reason_code
        self.details = dict(details or {})


def _capture_source_receipt(assessment, source_video_path):
    """Return the engine-owned receipt for the exact source generation."""
    try:
        probe = assessment.probe(source_video_path)
    except VideoEvidenceError as exc:
        raise VideoSourceLineageError(
            "source video did not pass bounded evidence inspection — hydrate a "
            "cloud placeholder, or reacquire the MP4 with "
            "batch-download-videos.sh, then rerun extraction",
            reason_code=exc.reason_code,
            details=dict(exc.details),
        ) from exc
    return build_video_source_receipt(probe)


def _require_stable_source(assessment, source_video_path, source_receipt):
    """Fail the run when the source changed while derivatives were produced."""
    try:
        probe = assessment.probe(source_video_path)
    except VideoEvidenceError as exc:
        raise VideoSourceLineageError(
            "source video became uninspectable during extraction — restore the "
            "MP4 at its recorded path and rerun extraction with nothing else "
            "writing to it",
            reason_code=exc.reason_code,
            details=dict(exc.details),
        ) from exc
    drift = video_source_receipt_generation_drift(source_receipt, probe)
    if drift:
        raise VideoSourceLineageError(
            "source video was replaced during extraction — no derivatives were "
            "kept; rerun extraction once the MP4 will stay unchanged for the "
            "whole run",
            reason_code="video_source_replaced_during_extraction",
            details={"drift": list(drift)},
        )


def _discard_unbound_artifacts(staged):
    """Drop every stage this run wrote, leaving prior derivatives untouched."""
    for path in staged:
        _remove_stale_pdf_stage(path)


def _commit_bound_artifacts(staged):
    """Publish this run's staged derivatives once the binding is proven.

    Several destinations cannot be replaced in one portable atomic rename, so
    each publish moves the prior version aside first and any exit part-way —
    interrupts included — puts every already-replaced destination back. Callers
    see the whole set published or none of it, never one run's slide-region PDF
    beside another run's context PDF. A process the host kills outright cannot
    run this; `_recover_stale_pdf_publish` repairs that at the next run.
    """
    replaced: list[tuple[str, str | None]] = []
    published = False
    try:
        for path in staged:
            backup = _pdf_backup_path(path)
            _remove_regular_file(backup)
            try:
                os.replace(path, backup)
            except FileNotFoundError:
                # Nothing to put back, which is itself state worth recording.
                backup = None
                _mark_pdf_absent(path)
            # Recorded before the publish, not after: the prior version is
            # already moved aside, so a failure in the publish itself still has
            # to put it back.
            replaced.append((path, backup))
            commit_pdf_stage(path)
        published = True
    finally:
        if not published:
            for path, backup in reversed(replaced):
                if backup is None:
                    _remove_regular_file(path)
                    _clear_pdf_absent_marker(path)
                    continue
                os.replace(backup, path)
    for path, backup in replaced:
        if backup is None:
            _clear_pdf_absent_marker(path)
        else:
            _remove_regular_file(backup)


def _confined_output_path(output_root: str, filename: str) -> str:
    """Return a derived output that remains under the canonical authorized root."""
    lexical_path = os.path.join(output_root, filename)
    candidate = os.path.realpath(lexical_path)
    try:
        common = os.path.commonpath((output_root, candidate))
    except ValueError:
        common = ""
    if os.path.normcase(common) != os.path.normcase(output_root):
        raise ValueError("video_output_path_escape")
    if os.path.lexists(lexical_path) and (
        os.path.islink(lexical_path) or not os.path.isfile(lexical_path)
    ):
        raise ValueError("video_output_leaf_invalid")
    return candidate


def _require_image_dependencies():
    """Return imported image modules or fail clearly for direct callers."""
    if Image is None or imagehash is None or _numpy_dependency is None:
        raise RuntimeError(f"Install dependencies: {VIDEO_DEPENDENCY_INSTALL}") from (
            _DEPS_ERROR
        )
    return Image, imagehash


def _video_run_lock(path):
    """Return the declared cross-platform lock without burdening --version."""
    if FileLock is None:
        raise RuntimeError(f"Install dependencies: {VIDEO_DEPENDENCY_INSTALL}") from (
            _DEPS_ERROR
        )
    return FileLock(path)


def _video_run_lock_path(output_dir: str, youtube_id: str) -> str:
    """Map one local output identity to a stable OS-temporary lock file."""
    identity = (
        os.fsencode(os.path.normcase(output_dir)) + b"\0" + youtube_id.encode("ascii")
    )
    lock_name = f"{hashlib.sha256(identity).hexdigest()}.lock"
    lock_root = os.path.join(tempfile.gettempdir(), "speaker-toolkit-video-locks")
    os.makedirs(lock_root, exist_ok=True)
    return os.path.join(lock_root, lock_name)


def validate_slide_region(region) -> NormalizedSlideRegion:
    """Return a normalized manual crop or raise ValueError.

    Coordinates are fractions of the source frame in Pillow crop order:
    (left, upper, right, lower). Keeping this validation pure makes the CLI and
    direct Python entry point enforce the same geometry contract.
    """
    if not isinstance(region, (tuple, list)) or len(region) != 4:
        raise ValueError(
            "manual slide region must contain four coordinates: LEFT,TOP,RIGHT,BOTTOM"
        )
    if any(
        isinstance(value, bool) or not isinstance(value, (int, float))
        for value in region
    ):
        raise ValueError("manual slide-region coordinates must be numbers")
    left, upper, right, lower = (float(value) for value in region)
    if not (0.0 <= left < right <= 1.0 and 0.0 <= upper < lower <= 1.0):
        raise ValueError(
            "manual slide region must satisfy "
            "0 <= LEFT < RIGHT <= 1 and 0 <= TOP < BOTTOM <= 1"
        )
    return left, upper, right, lower


def parse_slide_region(value: str) -> str | NormalizedSlideRegion:
    """Parse --region as auto, none, or normalized crop coordinates."""
    normalized = value.strip().lower()
    if normalized in ("auto", "none"):
        return normalized
    parts = [part.strip() for part in value.split(",")]
    if len(parts) != 4:
        raise argparse.ArgumentTypeError(
            "--region must be auto, none, or LEFT,TOP,RIGHT,BOTTOM"
        )
    try:
        region = tuple(float(part) for part in parts)
        return validate_slide_region(region)
    except ValueError as exc:
        raise argparse.ArgumentTypeError(str(exc)) from exc


def extract_frames(video_path, frames_dir, fps=0.5):
    """Extract frames into one empty workspace and enumerate them literally."""
    video_path = canonical_path(video_path)
    frames_dir = canonical_path(frames_dir)
    os.makedirs(frames_dir, exist_ok=True)
    with os.scandir(frames_dir) as entries:
        if next(entries, None) is not None:
            raise RuntimeError("frame workspace is not empty")
    output_pattern = os.path.join(frames_dir, "frame_%05d.jpg")
    completed = subprocess.run(
        [
            "ffmpeg",
            "-i",
            video_path,
            "-vf",
            f"fps={fps}",
            "-q:v",
            "2",
            output_pattern,
            "-y",
            "-loglevel",
            "warning",
        ],
        check=False,
        shell=False,
    )
    if completed.returncode != 0:
        raise RuntimeError(f"ffmpeg failed with exit status {completed.returncode}")
    numbered_frames = []
    with os.scandir(frames_dir) as entries:
        for entry in entries:
            frame_number = entry.name[len("frame_") : -len(".jpg")]
            if (
                entry.is_file(follow_symlinks=False)
                and entry.name.startswith("frame_")
                and entry.name.endswith(".jpg")
                and frame_number.isdigit()
            ):
                numbered_frames.append((int(frame_number), entry.name, entry.path))
    frames = [path for _, _, path in sorted(numbered_frames)]
    print(f"  Extracted {len(frames)} frames", file=sys.stderr)
    return frames


def _label_components(mask):
    """Label 4-connected True regions in a boolean mask.

    Implemented with an explicit stack rather than scipy.ndimage.label so the
    extractor keeps its declared dependency set (numpy/Pillow/imagehash); the
    mask is 180x320, so the cost is irrelevant.

    Yields (row_indices, col_indices) arrays per component.
    """
    import numpy as np

    seen = np.zeros(mask.shape, dtype=bool)
    h, w = mask.shape
    for r0 in range(h):
        for c0 in range(w):
            if not mask[r0, c0] or seen[r0, c0]:
                continue
            rows, cols, stack = [], [], [(r0, c0)]
            seen[r0, c0] = True
            while stack:
                r, c = stack.pop()
                rows.append(r)
                cols.append(c)
                for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                    rr, cc = r + dr, c + dc
                    if (
                        0 <= rr < h
                        and 0 <= cc < w
                        and mask[rr, cc]
                        and not seen[rr, cc]
                    ):
                        seen[rr, cc] = True
                        stack.append((rr, cc))
            yield np.array(rows), np.array(cols)


# A crop is only taken when the chosen component actually looks like a projected
# display. These bounds exist because component selection alone will happily
# return a text block inside a FULL-FRAME slide — cropping a deck into a fragment
# of itself and silently discarding the rest. Measured over 94 corpus decks,
# unconstrained selection produced boxes with aspect ratios from 0.32 to 9.45;
# the gate leaves 26. A missed crop leaves an over-count visible; a
# wrong crop destroys content, so these are deliberately strict.
_MIN_REGION_AREA_FRAC = 0.15  # smaller than this is a slide element, not a slide
_MIN_REGION_ASPECT = 1.0  # 4:3 is 1.33, 16:9 is 1.78; allow margin either way
_MAX_REGION_ASPECT = 2.4


def _largest_rectangular_component(
    mask,
    min_fill=0.5,
    min_area_frac=_MIN_REGION_AREA_FRAC,
    min_aspect=_MIN_REGION_ASPECT,
    max_aspect=_MAX_REGION_ASPECT,
):
    """Pick the component most likely to be the projected slide.

    A slide region is a solid rectangle that changes wholesale between slides, so
    its component nearly fills its own bounding box; a speaker picture-in-picture
    is an irregular blob of moving person and fills much less. Fill ratio
    separates those two. Area and aspect then reject the other failure mode —
    a localized text block inside a full-frame deck, which is rectangular and
    well-filled but is not the display.

    The mask is 320x180, so its pixel aspect equals the source frame's aspect for
    16:9 recordings and box_w/box_h is directly comparable to a display ratio.

    Returns (rmin, rmax, cmin, cmax) or None when nothing qualifies.
    """
    total = mask.size
    best, best_area = None, 0
    for rows, cols in _label_components(mask):
        rmin, rmax = int(rows.min()), int(rows.max())
        cmin, cmax = int(cols.min()), int(cols.max())
        box_h, box_w = rmax - rmin + 1, cmax - cmin + 1
        box_area = box_h * box_w
        if box_area / total < min_area_frac:
            continue
        # Fill ratio separates a solid slide rectangle from a person-shaped blob.
        if len(rows) / box_area < min_fill:
            continue
        # Aspect rejects strips and columns — neither is a projected display.
        if not (min_aspect <= box_w / box_h <= max_aspect):
            continue
        if box_area > best_area:
            best, best_area = (rmin, rmax, cmin, cmax), box_area
    return best


def detect_slide_region(frames, sample_size=10) -> NormalizedSlideRegion | None:
    """Auto-detect the slide region by analyzing variance across sample frames.

    Conference videos typically have a static border (conference branding,
    speaker PiP in a fixed corner) and a dynamic center (the slides).
    We find the bounding box of the high-variance region.

    Returns (left, upper, right, lower) as fraction of image dimensions,
    or None if slides appear to be full-frame.

    A RETURNED REGION IS NOT A VERIFIED ONE. Detection is reliable only for the
    extreme case it was built for: a broadcast composite where a fixed slide
    rectangle sits beside static venue furniture. On room recordings it can and
    does return the speaker — a torso is rectangular, well-filled, and passes
    every size and aspect gate a screen passes. Spot-checking 94 corpus decks by
    eye found correct screen crops and confident crops of a presenter's chest in
    the same pass. Treat the output as a hint to verify, never as ground truth,
    and never derive a slide count from a crop nobody looked at.

    KNOWN LIMIT — wide-angle room recordings are NOT reliably handled. Ambient
    motion clears the threshold across the frame and the largest plausible
    component is as often a person as a screen. Separating them needs a signal
    this function does not have (screen-edge geometry, projector luminance, or
    boundary stability across frames) plus ground truth to validate against.
    See references/video-slide-extraction.md.
    """
    import numpy as np

    if len(frames) < sample_size * 2:
        return None  # Too few frames, assume full-frame
    pil_image, _ = _require_image_dependencies()

    # Sample evenly spaced frame pairs
    step = max(1, len(frames) // sample_size)
    diffs = []

    for i in range(0, len(frames) - step, step):
        with pil_image.open(frames[i]) as source1:
            with source1.convert("L") as gray1:
                with gray1.resize((320, 180)) as resized1:
                    img1 = np.array(resized1)
        with pil_image.open(frames[i + step]) as source2:
            with source2.convert("L") as gray2:
                with gray2.resize((320, 180)) as resized2:
                    img2 = np.array(resized2)
        diff = np.abs(img1.astype(float) - img2.astype(float))
        diffs.append(diff)

    # Average difference map — high values = dynamic (slide content changes)
    avg_diff = np.mean(diffs, axis=0)

    # Threshold: regions with above-median change are "slide area"
    threshold = np.percentile(avg_diff, 60)
    mask = avg_diff > threshold

    if not mask.any():
        return None  # No clear region detected

    # A broadcast composite has MORE than one moving thing: the slide rectangle
    # and a live speaker picture-in-picture, which are disjoint. Taking the
    # bounding box of every above-threshold pixel merges them into one box that
    # spans the frame, trips the >90% guard below, and returns None — so the
    # deck is never cropped and the deduper hashes the moving presenter. That is
    # how one 43-slide talk extracted to 963 pages. Pick the best single
    # component instead of boxing them all.
    component = _largest_rectangular_component(mask)
    if component is None:
        return None
    rmin, rmax, cmin, cmax = component

    h, w = avg_diff.shape  # 180, 320

    # Convert to fractions with a small margin
    margin = 0.02
    region = (
        max(0, cmin / w - margin),
        max(0, rmin / h - margin),
        min(1, (cmax + 1) / w + margin),
        min(1, (rmax + 1) / h + margin),
    )

    # If region covers >90% of the frame, it's effectively full-frame
    area = (region[2] - region[0]) * (region[3] - region[1])
    if area > 0.9:
        return None

    print(
        f"  Detected slide region: {region[0]:.0%}-{region[2]:.0%} horizontal, "
        f"{region[1]:.0%}-{region[3]:.0%} vertical ({area:.0%} of frame)",
        file=sys.stderr,
    )
    return region


def crop_frame(img, region):
    """Crop an image to the detected slide region."""
    if region is None:
        return img
    w, h = img.size
    box = (
        int(region[0] * w),
        int(region[1] * h),
        int(region[2] * w),
        int(region[3] * h),
    )
    return img.crop(box)


def canonical_path(path):
    """Return a native absolute, symlink-resolved path for durable provenance."""
    try:
        native = materialize_native_root(path)
    except ArtifactLocatorError as exc:
        raise ValueError(
            f"artifact path must be native absolute ({exc.reason_code})"
        ) from None
    return os.path.realpath(os.fspath(native))


def deduplicate_frames(frames, slide_region=None, hash_threshold=8):
    """Deduplicate consecutive similar frames using perceptual hashing.

    Returns list of (frame_path, frame_index) for retained unique frames.
    hash_threshold is the largest distance still treated as the same slide.
    Because a frame is kept only when distance > threshold, higher values merge
    more aggressively and keep fewer variants:
      - 4-6: conservative merging; preserves reveals but keeps more motion noise
      - 8-12: moderate; 8 is the default for most talks
      - 14+: aggressive merging; reduces moving-overlay duplicates but risks
        merging progressive reveals or visually similar authored slides
    """
    unique_frames = []
    prev_hash = None
    if not frames:
        print("  Deduplicated: 0 frames -> 0 unique frames", file=sys.stderr)
        return unique_frames
    pil_image, perceptual_hash = _require_image_dependencies()

    for i, frame_path in enumerate(frames):
        with pil_image.open(frame_path) as source:
            # Hash the CROPPED region (slide only, not speaker PiP).
            cropped = crop_frame(source, slide_region)
            try:
                h = perceptual_hash.phash(cropped, hash_size=16)
            finally:
                if cropped is not source:
                    cropped.close()

        if prev_hash is None or abs(h - prev_hash) > hash_threshold:
            unique_frames.append((frame_path, i))
            prev_hash = h

    print(
        f"  Deduplicated: {len(frames)} frames -> {len(unique_frames)} unique frames",
        file=sys.stderr,
    )
    return unique_frames


def select_slide_region(
    frames, requested: str | NormalizedSlideRegion = "auto", verified=False
) -> tuple[NormalizedSlideRegion | None, dict]:
    """Resolve the hashing crop and return it with explicit provenance.

    Auto-detection is always unverified: the heuristic can select a presenter's
    torso on a room recording. A manual crop is marked verified only when the
    caller explicitly says it was checked. `none` disables cropping.
    """
    if requested == "auto":
        if verified:
            raise ValueError(
                "slide_region_verified requires a manual region; an auto-detected "
                "crop is a hint until someone checks it"
            )
        region = detect_slide_region(frames)
        return region, {
            "slide_region_method": "auto",
            "slide_region_detected": region is not None,
            "slide_region_applied": region is not None,
            "slide_region_verified": False,
        }
    if requested == "none":
        if verified:
            raise ValueError(
                "slide_region_verified requires a manual region; --region none "
                "applies no crop"
            )
        return None, {
            "slide_region_method": "none",
            "slide_region_detected": False,
            "slide_region_applied": False,
            "slide_region_verified": False,
        }

    region = validate_slide_region(requested)
    return region, {
        "slide_region_method": "manual",
        "slide_region_detected": False,
        "slide_region_applied": True,
        "slide_region_verified": bool(verified),
    }


def review_reason_for_region(region, provenance):
    """Explain why a region result may or may not support authored-slide trust."""
    method = provenance["slide_region_method"]
    verified = provenance["slide_region_verified"]
    if region is not None and method == "manual" and verified:
        return None
    if region is None:
        return (
            "No verified slide region is available; the PDF is full-frame context "
            "only until an operator verifies a manual region."
        )
    if method == "auto":
        return (
            "The auto-detected crop is unverified; inspect it against the source "
            "and context, then rerun with a verified manual region."
        )
    return (
        "The manual crop was not marked visually verified; review it and rerun "
        "with --region-verified before promotion."
    )


_PDF_STAGE_SUFFIX = ".speaker-toolkit-stage.tmp"
_PDF_BACKUP_SUFFIX = ".speaker-toolkit-prior.tmp"
_PDF_ABSENT_SUFFIX = ".speaker-toolkit-absent.tmp"


def _pdf_stage_path(output_pdf: str) -> str:
    """Return the deterministic stage owned by one locked PDF destination."""
    return os.path.join(
        os.path.dirname(output_pdf),
        f".{os.path.basename(output_pdf)}{_PDF_STAGE_SUFFIX}",
    )


def _pdf_backup_path(output_pdf: str) -> str:
    """Return the deterministic prior-version slot for one PDF destination."""
    return os.path.join(
        os.path.dirname(output_pdf),
        f".{os.path.basename(output_pdf)}{_PDF_BACKUP_SUFFIX}",
    )


def _remove_regular_file(path: str) -> None:
    """Unlink one file this run owns, tolerating an already-absent leaf."""
    try:
        os.unlink(path)
    except FileNotFoundError:
        pass


def _pdf_absent_marker_path(output_pdf: str) -> str:
    """Return the deterministic "held nothing" marker for one destination."""
    return os.path.join(
        os.path.dirname(output_pdf),
        f".{os.path.basename(output_pdf)}{_PDF_ABSENT_SUFFIX}",
    )


def _require_regular_recovery_leaf(path: str) -> None:
    if not stat.S_ISREG(os.lstat(path).st_mode):
        raise ValueError("video_pdf_recovery_leaf_invalid")


def _mark_pdf_absent(output_pdf: str) -> None:
    """Record that this destination held nothing before the publish.

    Without it, a process killed after this destination publishes and before
    the run completes leaves a PDF the next run cannot tell from a prior
    version worth keeping.
    """
    marker = _pdf_absent_marker_path(output_pdf)
    try:
        # Exclusive create, like the stage: O_CREAT|O_EXCL never follows a
        # symlink, so a planted link cannot redirect this write.
        handle = open(marker, "xb")
    except FileExistsError:
        raise ValueError("video_pdf_absent_marker_invalid") from None
    with handle:
        handle.flush()
        os.fsync(handle.fileno())


def _clear_pdf_absent_marker(output_pdf: str) -> None:
    _remove_regular_file(_pdf_absent_marker_path(output_pdf))


def _remove_stale_pdf_stage(output_pdf: str) -> None:
    """Reclaim the exact stage left by an interrupted prior run."""
    try:
        os.unlink(_pdf_stage_path(output_pdf))
    except FileNotFoundError:
        pass
    except IsADirectoryError:
        raise ValueError("video_pdf_stage_invalid") from None


def _recover_stale_pdf_publish(output_pdf: str) -> None:
    """Undo a publish a killed process left half-applied at this destination.

    Both markers exist only inside one run's publish, so finding either means
    that run never completed and never wrote a manifest. The destination is
    put back to what a completed run last left there: the prior version, or
    nothing at all.
    """
    marker = _pdf_absent_marker_path(output_pdf)
    if os.path.exists(marker):
        _require_regular_recovery_leaf(marker)
        _remove_regular_file(output_pdf)
        _remove_regular_file(marker)
        return
    backup = _pdf_backup_path(output_pdf)
    if not os.path.exists(backup):
        return
    _require_regular_recovery_leaf(backup)
    os.replace(backup, output_pdf)


def _open_pdf_stage(output_pdf: str):
    """Open a fresh stage with normal umask mode or the prior PDF's mode."""
    prior_mode = None
    try:
        metadata = os.stat(output_pdf, follow_symlinks=False)
    except FileNotFoundError:
        pass
    else:
        if not stat.S_ISREG(metadata.st_mode):
            raise ValueError("video_output_leaf_invalid")
        prior_mode = stat.S_IMODE(metadata.st_mode)

    _remove_stale_pdf_stage(output_pdf)
    staged_path = _pdf_stage_path(output_pdf)
    staged = open(staged_path, "x+b")
    if prior_mode is not None:
        try:
            os.chmod(staged_path, prior_mode)
        except OSError:
            staged.close()
            _remove_stale_pdf_stage(output_pdf)
            raise
    return staged_path, staged


def commit_pdf_stage(output_pdf: str) -> None:
    """Publish one staged derivative over its destination."""
    os.replace(_pdf_stage_path(output_pdf), output_pdf)


def combine_to_pdf(
    unique_frames,
    output_pdf,
    slide_region=None,
    artifact_scope=None,
    source_video_id=None,
    crop_method="none",
    crop_verified=False,
    commit=True,
):
    """Write retained video frames as one explicitly scoped PDF artifact.

    ``slide_region`` is applied to the saved pages, not only to the hashes.
    Callers write a separate ``full_frame_context`` artifact when room or PiP
    context is useful. PDF metadata names the scope so a context artifact can
    never masquerade as an authored deck after it is separated from the JSON.

    ``commit=False`` leaves the finished pages in this destination's stage and
    returns the path they are staged for. A caller running several derivatives
    under one source binding uses it to hold every replacement until the whole
    run is proven, so a failed re-extraction cannot destroy the artifacts a
    previous run published. ``commit_pdf_stage`` publishes one such stage.
    """
    if artifact_scope is None:
        artifact_scope = (
            "slide_region" if slide_region is not None else "full_frame_context"
        )
    if artifact_scope not in ("slide_region", "full_frame_context"):
        raise ValueError(f"unknown artifact scope: {artifact_scope!r}")
    if artifact_scope == "slide_region" and slide_region is None:
        raise ValueError("slide_region artifacts require a physical crop")
    if artifact_scope == "full_frame_context" and slide_region is not None:
        raise ValueError("full_frame_context artifacts must preserve the full frame")
    if source_video_id is not None:
        source_video_id = validate_youtube_id(source_video_id)

    if not unique_frames:
        print("  WARNING: No unique frames found", file=sys.stderr)
        return None

    images = []
    pil_image, _ = _require_image_dependencies()
    staged_path = None
    try:
        for frame_path, _ in unique_frames:
            with pil_image.open(frame_path) as source:
                cropped = crop_frame(source, slide_region)
                try:
                    images.append(cropped.convert("RGB"))
                finally:
                    if cropped is not source:
                        cropped.close()

        if not images:
            print("  WARNING: No unique frames found", file=sys.stderr)
            return None

        output_pdf = canonical_path(output_pdf)
        output_parent = os.path.dirname(output_pdf)
        os.makedirs(output_parent, exist_ok=True)
        producer = f"speaker-toolkit/video-slide-extraction {PIPELINE_VERSION}"
        if artifact_scope == "full_frame_context":
            title = f"{source_video_id or 'video'} full-frame context"
            subject = "Full-frame video context; not authored slides"
        else:
            title = f"{source_video_id or 'video'} cropped slide region"
            trust = "verified" if crop_verified else "unverified; review required"
            subject = (
                f"Cropped slide-region frames from video; crop method={crop_method}; "
                f"{trust}"
            )

        staged_path, staged = _open_pdf_stage(output_pdf)
        with staged:
            images[0].save(
                staged,
                format="PDF",
                save_all=True,
                append_images=images[1:],
                producer=producer,
                creator=producer,
                title=title,
                subject=subject,
            )
            staged.flush()

        size_mb = os.path.getsize(staged_path) / (1024 * 1024)
        if commit:
            os.replace(staged_path, output_pdf)
        staged_path = None
        state = "Saved" if commit else "Staged"
        print(
            f"  {state} {artifact_scope} PDF: {output_pdf} "
            f"({len(images)} pages, {size_mb:.1f} MB)",
            file=sys.stderr,
        )
        return output_pdf
    finally:
        if staged_path is not None:
            try:
                os.unlink(staged_path)
            except FileNotFoundError:
                pass
        for image in images:
            image.close()


def retained_frame_provenance(unique_frames, fps):
    """Map PDF page order back to zero-based sampled-frame positions."""
    if fps <= 0:
        raise ValueError("fps must be greater than zero")
    return [
        {
            "page_number": page_number,
            "frame_index": frame_index,
            "timestamp_seconds": round(frame_index / fps, 3),
        }
        for page_number, (_, frame_index) in enumerate(unique_frames, start=1)
    ]


def artifact_record(
    path,
    artifact_scope,
    page_count,
    source_video_id,
    source_video_path,
    source_receipt,
    crop_method="none",
    crop_verified=False,
    trusted_for_authored_slide_analysis=False,
):
    """Build a self-describing PDF artifact record for the extraction result.

    ``source_receipt`` is the run's engine-owned source receipt. Every
    derivative carries it, so a PDF separated from the manifest still names the
    exact source bytes it came from.
    """
    if artifact_scope not in ("slide_region", "full_frame_context"):
        raise ValueError(f"unknown artifact scope: {artifact_scope!r}")
    if artifact_scope == "full_frame_context" and (
        crop_method != "none" or crop_verified or trusted_for_authored_slide_analysis
    ):
        raise ValueError("full-frame context cannot be cropped or trusted as slides")
    if artifact_scope == "slide_region" and crop_method not in ("auto", "manual"):
        raise ValueError("slide-region artifacts require an auto or manual crop")
    if trusted_for_authored_slide_analysis and not (
        crop_method == "manual" and crop_verified
    ):
        raise ValueError("authored-slide trust requires a verified manual crop")
    source_video_id = validate_youtube_id(source_video_id)
    return {
        "path": canonical_path(path),
        "artifact_scope": artifact_scope,
        "page_count": page_count,
        "source_video_id": source_video_id,
        "source_video_path": canonical_path(source_video_path),
        "source_receipt": copy.deepcopy(source_receipt),
        "crop_method": crop_method,
        "crop_verified": bool(crop_verified),
        "trusted_for_authored_slide_analysis": bool(
            trusted_for_authored_slide_analysis
        ),
    }


def _extract_slides_in_workspace(
    video_path,
    output_dir,
    youtube_id,
    frames_dir,
    source_receipt,
    published,
    fps=0.5,
    hash_threshold=8,
    slide_region: str | NormalizedSlideRegion = "auto",
    slide_region_verified=False,
    include_context_pdf=True,
):
    """Full pipeline: frames -> detect region -> dedup -> scoped PDF artifacts.

    Args:
        video_path: Path to downloaded MP4
        output_dir: Directory for intermediate files and output PDF
        youtube_id: YouTube video ID (used for naming)
        source_receipt: Engine-owned receipt for the exact source generation,
                        stamped onto the manifest and every derivative record.
        published: Caller-owned list this appends each staged PDF path to the
                   moment its pages land. The caller publishes them once the
                   source binding holds, and drops the stages otherwise, so a
                   failed run never disturbs a prior run's artifacts.
        fps: Frames per second to extract (0.5 = 1 frame per 2 seconds)
        hash_threshold: Largest hash distance treated as the same slide. Higher
                        values merge more and keep fewer frames.
        slide_region: "auto", "none", or normalized (left, upper, right, lower)
                      coordinates used for hashing.
        slide_region_verified: True only when a manual crop was visually checked.
        include_context_pdf: Preserve an additional full-frame PDF after a verified
                             crop. Review-required results always keep context even
                             when this is False.

    Returns:
        dict with extraction results for structured_data
    """
    youtube_id = validate_youtube_id(youtube_id)
    if fps <= 0:
        raise ValueError("fps must be greater than zero")
    output_dir = canonical_path(output_dir)
    source_video_path = canonical_path(video_path)
    slide_pdf = _confined_output_path(
        output_dir,
        f"{youtube_id}.slide-region.pdf",
    )
    context_pdf = _confined_output_path(output_dir, f"{youtube_id}.context.pdf")

    print(f"Extracting video artifacts from {youtube_id}...", file=sys.stderr)

    # Step 2: Extract frames
    frames = extract_frames(source_video_path, frames_dir, fps=fps)
    if not frames:
        return {
            "slide_source": "video_extracted",
            "schema_version": SCHEMA_VERSION,
            "pipeline_version": PIPELINE_VERSION,
            "source_video_id": youtube_id,
            "source_video_path": source_video_path,
            "source_receipt": copy.deepcopy(source_receipt),
            "total_frames_extracted": 0,
            "unique_frame_count": 0,
            "authored_slide_count": None,
            "retained_frames": [],
            "artifacts": [],
            "review_required": True,
            "review_reason": "No frames were extracted.",
            "error": "No frames extracted",
        }

    # Step 3: Resolve the slide region. Auto-detection is a hint, while a manual
    # crop carries explicit verification provenance.
    resolved_region, region_provenance = select_slide_region(
        frames, slide_region, slide_region_verified
    )

    # Step 4: Deduplicate
    unique_frames = deduplicate_frames(frames, resolved_region, hash_threshold)

    # Step 5: Write separately scoped artifacts. A crop is saved into the
    # slide-region PDF itself; the uncropped broadcast frame, when retained, is
    # a context artifact and is never labeled as authored slides.
    trusted_slide_evidence = bool(
        resolved_region is not None
        and region_provenance["slide_region_method"] == "manual"
        and region_provenance["slide_region_verified"]
    )
    review_reason = review_reason_for_region(resolved_region, region_provenance)
    artifacts = []
    if resolved_region is not None:
        slide_pdf_path = combine_to_pdf(
            unique_frames,
            slide_pdf,
            resolved_region,
            artifact_scope="slide_region",
            source_video_id=youtube_id,
            crop_method=region_provenance["slide_region_method"],
            crop_verified=region_provenance["slide_region_verified"],
            commit=False,
        )
        if slide_pdf_path:
            published.append(slide_pdf_path)
            artifacts.append(
                artifact_record(
                    slide_pdf_path,
                    "slide_region",
                    len(unique_frames),
                    youtube_id,
                    source_video_path,
                    source_receipt,
                    crop_method=region_provenance["slide_region_method"],
                    crop_verified=region_provenance["slide_region_verified"],
                    trusted_for_authored_slide_analysis=trusted_slide_evidence,
                )
            )

    # With no region, the full frame is the only visual evidence and must be
    # preserved as context. With a region, callers may explicitly omit this
    # additional derivative; the source video itself is never deleted here.
    if include_context_pdf or not trusted_slide_evidence:
        context_pdf_path = combine_to_pdf(
            unique_frames,
            context_pdf,
            artifact_scope="full_frame_context",
            source_video_id=youtube_id,
            commit=False,
        )
        if context_pdf_path:
            published.append(context_pdf_path)
            artifacts.append(
                artifact_record(
                    context_pdf_path,
                    "full_frame_context",
                    len(unique_frames),
                    youtube_id,
                    source_video_path,
                    source_receipt,
                )
            )

    result = {
        "slide_source": "video_extracted",
        "schema_version": SCHEMA_VERSION,
        "pipeline_version": PIPELINE_VERSION,
        "source_video_id": youtube_id,
        "source_video_path": source_video_path,
        "source_receipt": copy.deepcopy(source_receipt),
        "total_frames_extracted": len(frames),
        "unique_frame_count": len(unique_frames),
        # Frame/page count is not an authored slide count: animations, camera
        # motion, missed samples, and dedup thresholds make that unknowable here.
        "authored_slide_count": None,
        "hash_threshold_used": hash_threshold,
        "slide_region": resolved_region,
        "fps_used": fps,
        "retained_frames": retained_frame_provenance(unique_frames, fps),
        "artifacts": artifacts,
        "review_required": review_reason is not None,
        "review_reason": review_reason,
        **region_provenance,
    }

    print(f"  Done: {len(unique_frames)} unique frames retained", file=sys.stderr)
    return result


def extract_slides_from_video(
    video_path,
    output_dir,
    youtube_id,
    fps=0.5,
    hash_threshold=8,
    slide_region: str | NormalizedSlideRegion = "auto",
    slide_region_verified=False,
    include_context_pdf=True,
):
    """Run one extraction bound end to end to one exact source generation.

    The source is probed before any frame is sampled and again after every
    derivative is written. One assessment owns both probes, so the closing
    probe costs a stat when the generation held and a full re-probe exactly
    when it did not. Any drift discards the derivatives and fails the run —
    a manifest is never written against bytes it did not come from.

    Derivatives stay staged until the closing probe passes, so no exit path can
    leave a half-bound PDF behind and a failed re-extraction leaves the previous
    run's artifacts exactly as it found them.
    """
    youtube_id = validate_youtube_id(youtube_id)
    if fps <= 0:
        raise ValueError("fps must be greater than zero")
    output_dir = canonical_path(output_dir)
    source_video_path = canonical_path(video_path)
    os.makedirs(output_dir, exist_ok=True)
    run_lock = _video_run_lock_path(output_dir, youtube_id)

    with _video_run_lock(run_lock):
        assessment = VideoEvidenceAssessment()
        source_receipt = _capture_source_receipt(assessment, source_video_path)
        for filename in (
            f"{youtube_id}.slide-region.pdf",
            f"{youtube_id}.context.pdf",
        ):
            destination = _confined_output_path(output_dir, filename)
            _recover_stale_pdf_publish(destination)
            _remove_stale_pdf_stage(destination)
        published: list[str] = []
        bound = False
        try:
            with tempfile.TemporaryDirectory(
                prefix="speaker-toolkit-video-frames-"
            ) as frames_dir:
                result = _extract_slides_in_workspace(
                    source_video_path,
                    output_dir,
                    youtube_id,
                    frames_dir,
                    source_receipt,
                    published,
                    fps=fps,
                    hash_threshold=hash_threshold,
                    slide_region=slide_region,
                    slide_region_verified=slide_region_verified,
                    include_context_pdf=include_context_pdf,
                )
            _require_stable_source(assessment, source_video_path, source_receipt)
            _commit_bound_artifacts(published)
            bound = True
        finally:
            if not bound:
                _discard_unbound_artifacts(published)
        return result


def main():
    parser = argparse.ArgumentParser(
        description="Extract slide images from conference talk videos."
    )
    parser.add_argument(
        "--version",
        action="store_true",
        help="Print the pipeline version as JSON and exit",
    )
    parser.add_argument("video", nargs="?", help="Path to downloaded MP4 video")
    parser.add_argument(
        "outdir", nargs="?", help="Directory for intermediate files and output PDF"
    )
    parser.add_argument(
        "youtube_id", nargs="?", help="YouTube video ID (used for naming)"
    )
    parser.add_argument(
        "--fps",
        type=float,
        default=0.5,
        help="Frames per second to extract (default: 0.5)",
    )
    parser.add_argument(
        "--threshold",
        type=int,
        default=8,
        help="largest hash distance treated as the same slide; "
        "higher merges more and keeps fewer frames (default: 8)",
    )
    parser.add_argument(
        "--region",
        type=parse_slide_region,
        default="auto",
        metavar="auto|none|LEFT,TOP,RIGHT,BOTTOM",
        help="crop used for hashing: auto-detect, none, or normalized coordinates",
    )
    parser.add_argument(
        "--region-verified",
        action="store_true",
        help="mark a manually supplied --region as visually verified",
    )
    parser.add_argument(
        "--no-context-pdf",
        action="store_false",
        dest="include_context_pdf",
        help="omit extra full-frame context after a verified manual crop",
    )
    args = parser.parse_args()

    # Structured version query — JSON, not prose, per script-delegation. Handled
    # before the dependency guard so the version stays queryable in a minimal env.
    if args.version:
        print(json.dumps({"pipeline_version": PIPELINE_VERSION}))
        return

    if None in (args.video, args.outdir, args.youtube_id):
        parser.error("video, outdir, and youtube_id are required")
    if args.region_verified and not isinstance(args.region, tuple):
        parser.error(
            "--region-verified requires manual LEFT,TOP,RIGHT,BOTTOM coordinates"
        )
    try:
        youtube_id = validate_youtube_id(args.youtube_id)
    except ValueError as exc:
        parser.error(str(exc))

    if _DEPS_ERROR is not None:
        print(
            json.dumps({"error": f"Install dependencies: {VIDEO_DEPENDENCY_INSTALL}"}),
            file=sys.stderr,
        )
        sys.exit(1)

    try:
        result = extract_slides_from_video(
            args.video,
            args.outdir,
            youtube_id,
            fps=args.fps,
            hash_threshold=args.threshold,
            slide_region=args.region,
            slide_region_verified=args.region_verified,
            include_context_pdf=args.include_context_pdf,
        )
    except VideoSourceLineageError as exc:
        print(
            json.dumps(
                {
                    "error": str(exc),
                    "reason_code": exc.reason_code,
                    "details": exc.details,
                }
            ),
            file=sys.stderr,
        )
        sys.exit(1)
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()

skills

vault-ingress

SKILL.md

README.md

tile.json