#!/usr/bin/env python3 """Extract stable, deduplicated slide pages from one local recording.""" from __future__ import annotations import argparse import json import math import os from pathlib import Path import shutil import subprocess import sys import time from dataclasses import dataclass from typing import BinaryIO, Callable, Iterable, Sequence import uuid import zlib import numpy as np from PIL import Image SCAN_MAX_WIDTH = 320 FEATURE_WIDTH = 64 MIN_STABLE_SAMPLES = 3 PIXEL_CHANGE_THRESHOLD = 12 STABLE_PREVIOUS_MAD = 3.0 STABLE_PREVIOUS_CHANGED_RATIO = 0.045 STABLE_ANCHOR_MAD = 4.5 STABLE_ANCHOR_CHANGED_RATIO = 0.075 STABLE_BEST_UPDATE_MAD = 0.75 ANIMATION_CURRENT_MAD = 24.0 ANIMATION_CURRENT_CHANGED_RATIO = 0.42 ANIMATION_ANCHOR_MAD = 34.0 ANIMATION_ANCHOR_CHANGED_RATIO = 0.58 ANIMATION_HASH_DISTANCE = 24 DEDUPE_MAD = 5.5 DEDUPE_CHANGED_RATIO = 0.085 DEDUPE_HASH_DISTANCE = 7 EDGE_THRESHOLD = 18 CONTENT_SCORE_TOLERANCE = 0.012 PROCESS_WAIT_SECONDS = 5.0 STDERR_TAIL_BYTES = 8192 class SlideExtractionError(Exception): """Expected, user-facing extraction failure.""" @dataclass(frozen=True) class VideoInfo: width: int height: int duration: float | None @dataclass(frozen=True) class FrameFeature: sample_index: int relative_seconds: float gray: np.ndarray perceptual_hash: int content_score: float sharpness: float @dataclass(frozen=True) class PageCandidate: timestamp: float feature: np.ndarray perceptual_hash: int content_score: float sharpness: float @dataclass class LogicalPage: logical_anchor_feature: np.ndarray current_feature: np.ndarray current_hash: int current_timestamp: float current_score: float first_seen_order: int def read_exact(stream: BinaryIO, size: int) -> bytes | None: """Read exactly one raw frame, distinguish clean EOF from a partial frame.""" if size <= 0: raise ValueError("size must be positive") chunks: list[bytes] = [] remaining = size while remaining: chunk = stream.read(remaining) if not chunk: if not chunks: return None actual = size - remaining raise SlideExtractionError( f"rawvideo 半帧 EOF:期望 {size} bytes,实际 {actual} bytes" ) chunks.append(chunk) remaining -= len(chunk) return b"".join(chunks) def _tail(path: Path, limit: int = STDERR_TAIL_BYTES) -> str: try: with path.open("rb") as handle: handle.seek(0, os.SEEK_END) length = handle.tell() handle.seek(max(0, length - limit)) return handle.read(limit).decode("utf-8", errors="replace").strip() except OSError: return "" def _reap(process: subprocess.Popen[bytes]) -> None: stdout = getattr(process, "stdout", None) if stdout is not None: try: stdout.close() except OSError: pass if process.poll() is not None: process.wait() return process.terminate() try: process.wait(timeout=PROCESS_WAIT_SECONDS) except subprocess.TimeoutExpired: process.kill() process.wait() def _attempt_reap(process: subprocess.Popen[bytes]) -> Exception | None: try: _reap(process) return None except Exception as exc: # cleanup must not replace KeyboardInterrupt/SystemExit return exc def _run_file_command(command: Sequence[str], stderr_path: Path, stage: str) -> None: stderr_handle: BinaryIO | None = None process: subprocess.Popen[bytes] | None = None active_error: BaseException | None = None try: stderr_handle = stderr_path.open("wb") process = subprocess.Popen( list(command), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=stderr_handle, shell=False, ) return_code = process.wait() if return_code != 0: stderr_handle.flush() detail = _tail(stderr_path) suffix = f":{detail}" if detail else "" raise SlideExtractionError(f"{stage}失败(exit={return_code}){suffix}") except BaseException as exc: active_error = exc cleanup_error = _attempt_reap(process) if process is not None else None if not isinstance(exc, Exception): raise if cleanup_error is not None: raise SlideExtractionError( f"{stage}失败:{exc};子进程清理失败:{cleanup_error}" ) from exc if isinstance(exc, SlideExtractionError): raise raise SlideExtractionError(f"{stage}失败:{exc}") from exc finally: if stderr_handle is not None: try: stderr_handle.close() except Exception as close_error: if active_error is None: raise SlideExtractionError( f"{stage}关闭 stderr 失败:{close_error}" ) from close_error def _which_or_error(name: str) -> str: resolved = shutil.which(name) if not resolved: raise SlideExtractionError(f"未找到 {name},请先安装并加入 PATH") return resolved def probe_video(source: Path, staging: Path) -> VideoInfo: probe_path = staging / ".probe.json" stderr_path = staging / ".ffprobe.stderr.log" command = [ _which_or_error("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=index,width,height,codec_type:format=duration", "-of", "json", "-o", str(probe_path), str(source), ] _run_file_command(command, stderr_path, "FFprobe 探测") try: payload = json.loads(probe_path.read_text(encoding="utf-8")) streams = payload.get("streams") or [] stream = streams[0] width = int(stream["width"]) height = int(stream["height"]) duration_raw = (payload.get("format") or {}).get("duration") duration = float(duration_raw) if duration_raw not in (None, "N/A") else None except (OSError, ValueError, TypeError, KeyError, IndexError, json.JSONDecodeError) as exc: raise SlideExtractionError(f"无法解析第一条视频流信息:{exc}") from exc if width <= 0 or height <= 0: raise SlideExtractionError("第一条视频流宽高无效") return VideoInfo(width=width, height=height, duration=duration) def _scan_size(info: VideoInfo) -> tuple[int, int]: width = min(info.width, SCAN_MAX_WIDTH) height = max(1, round(info.height * width / info.width)) return width, height def _gray_feature(rgb: bytes, width: int, height: int, index: int) -> FrameFeature: array = np.frombuffer(rgb, dtype=np.uint8).reshape((height, width, 3)) with Image.fromarray(array, mode="RGB") as image: feature_height = max(1, round(height * FEATURE_WIDTH / width)) gray_image = image.convert("L").resize( (FEATURE_WIDTH, feature_height), Image.Resampling.BILINEAR ) gray = np.asarray(gray_image, dtype=np.uint8).copy() gx = np.abs(np.diff(gray.astype(np.int16), axis=1)) gy = np.abs(np.diff(gray.astype(np.int16), axis=0)) gradient_count = gx.size + gy.size edge_density = ( (int(np.count_nonzero(gx >= EDGE_THRESHOLD)) + int(np.count_nonzero(gy >= EDGE_THRESHOLD))) / gradient_count if gradient_count else 0.0 ) contrast = float(np.std(gray, dtype=np.float64)) / 255.0 sharpness = float(np.var(gx, dtype=np.float64) + np.var(gy, dtype=np.float64)) with Image.fromarray(gray, mode="L") as feature_image: hash_pixels = np.asarray( feature_image.resize((8, 8), Image.Resampling.BILINEAR), dtype=np.uint8 ).reshape(-1) mean = float(np.mean(hash_pixels)) perceptual_hash = 0 for value in hash_pixels: perceptual_hash = (perceptual_hash << 1) | int(value >= mean) return FrameFeature( sample_index=index, relative_seconds=float(index), gray=gray, perceptual_hash=perceptual_hash, content_score=float(edge_density + 0.25 * contrast), sharpness=sharpness, ) def difference(left: np.ndarray, right: np.ndarray) -> tuple[float, float]: if left.shape != right.shape: return math.inf, 1.0 delta = np.abs(left.astype(np.int16) - right.astype(np.int16)) return float(np.mean(delta)), float(np.count_nonzero(delta > PIXEL_CHANGE_THRESHOLD) / delta.size) def hash_distance(left: int, right: int) -> int: return (left ^ right).bit_count() def _within( left: np.ndarray, right: np.ndarray, mad_limit: float, ratio_limit: float, ) -> bool: mad, ratio = difference(left, right) return mad <= mad_limit and ratio <= ratio_limit class StableSegmentDetector: """Streaming stable-run detector retaining no frame list.""" def __init__(self) -> None: self.anchor: FrameFeature | None = None self.previous: FrameFeature | None = None self.best: FrameFeature | None = None self.committed_best: FrameFeature | None = None self.count = 0 self.started_from_anchor_drift = False def _candidate(self) -> PageCandidate | None: if self.count < MIN_STABLE_SAMPLES or self.committed_best is None: return None best = self.committed_best return PageCandidate( timestamp=best.relative_seconds, feature=best.gray, perceptual_hash=best.perceptual_hash, content_score=best.content_score, sharpness=best.sharpness, ) def _start(self, frame: FrameFeature, *, from_anchor_drift: bool = False) -> None: self.anchor = frame self.previous = frame self.best = frame self.committed_best = None self.count = 1 self.started_from_anchor_drift = from_anchor_drift def add(self, frame: FrameFeature) -> PageCandidate | None: if self.anchor is None or self.previous is None: self._start(frame) return None adjacent = _within( self.previous.gray, frame.gray, STABLE_PREVIOUS_MAD, STABLE_PREVIOUS_CHANGED_RATIO, ) anchored = _within( self.anchor.gray, frame.gray, STABLE_ANCHOR_MAD, STABLE_ANCHOR_CHANGED_RATIO, ) if adjacent and anchored: self.count += 1 self.previous = frame if self.best is None or frame.sharpness >= self.best.sharpness: self.best = frame if self.count == MIN_STABLE_SAMPLES: self.committed_best = self.best elif self.committed_best is not None and self.anchor is not None: # Improve sharpness only while the frame remains very close to the # immutable page anchor; do not let a slow transition win. mad, ratio = difference(self.anchor.gray, frame.gray) if ( mad <= STABLE_BEST_UPDATE_MAD and ratio <= STABLE_PREVIOUS_CHANGED_RATIO and frame.sharpness >= self.committed_best.sharpness ): self.committed_best = frame return None anchor_drift = adjacent and not anchored candidate = None if (anchor_drift and self.started_from_anchor_drift) else self._candidate() self._start(frame, from_anchor_drift=anchor_drift) return candidate def flush(self) -> PageCandidate | None: candidate = self._candidate() self.anchor = None self.previous = None self.best = None self.committed_best = None self.count = 0 self.started_from_anchor_drift = False return candidate class PageClassifier: """Classify candidates as recent-page animation, historical return, or new page.""" def __init__(self) -> None: self.pages: list[LogicalPage] = [] @staticmethod def _animation(candidate: PageCandidate, page: LogicalPage) -> bool: return ( _within( page.current_feature, candidate.feature, ANIMATION_CURRENT_MAD, ANIMATION_CURRENT_CHANGED_RATIO, ) and _within( page.logical_anchor_feature, candidate.feature, ANIMATION_ANCHOR_MAD, ANIMATION_ANCHOR_CHANGED_RATIO, ) and hash_distance(page.current_hash, candidate.perceptual_hash) <= ANIMATION_HASH_DISTANCE ) @staticmethod def _duplicate(candidate: PageCandidate, page: LogicalPage) -> bool: return ( _within( page.current_feature, candidate.feature, DEDUPE_MAD, DEDUPE_CHANGED_RATIO, ) and hash_distance(page.current_hash, candidate.perceptual_hash) <= DEDUPE_HASH_DISTANCE ) def consume(self, candidate: PageCandidate) -> str: if self.pages: recent = self.pages[-1] if self._animation(candidate, recent): if candidate.content_score >= recent.current_score - CONTENT_SCORE_TOLERANCE: recent.current_feature = candidate.feature recent.current_hash = candidate.perceptual_hash recent.current_timestamp = candidate.timestamp recent.current_score = candidate.content_score return "animation_updated" return "animation_retained" for historical in self.pages[:-1]: if self._duplicate(candidate, historical): return "historical_duplicate" self.pages.append( LogicalPage( logical_anchor_feature=candidate.feature.copy(), current_feature=candidate.feature, current_hash=candidate.perceptual_hash, current_timestamp=candidate.timestamp, current_score=candidate.content_score, first_seen_order=len(self.pages), ) ) return "new_page" def _scan_command(ffmpeg: str, source: Path, scan_width: int, scan_height: int) -> list[str]: filter_graph = ( f"setpts=PTS-STARTPTS,fps=fps=1:start_time=0," f"scale={scan_width}:{scan_height}:flags=bilinear" ) return [ ffmpeg, "-hide_banner", "-loglevel", "error", "-i", str(source), "-map", "0:v:0", "-vf", filter_graph, "-pix_fmt", "rgb24", "-f", "rawvideo", "-", ] def scan_video(source: Path, staging: Path, info: VideoInfo) -> list[LogicalPage]: scan_width, scan_height = _scan_size(info) frame_bytes = scan_width * scan_height * 3 stderr_path = staging / ".scan.stderr.log" stderr_handle: BinaryIO | None = None process: subprocess.Popen[bytes] | None = None active_error: BaseException | None = None detector = StableSegmentDetector() classifier = PageClassifier() samples = 0 try: stderr_handle = stderr_path.open("wb") process = subprocess.Popen( _scan_command(_which_or_error("ffmpeg"), source, scan_width, scan_height), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=stderr_handle, shell=False, ) assert process.stdout is not None while True: raw = read_exact(process.stdout, frame_bytes) if raw is None: break candidate = detector.add(_gray_feature(raw, scan_width, scan_height, samples)) if candidate is not None: classifier.consume(candidate) samples += 1 if samples % 300 == 0: if info.duration and info.duration > 0: percent = min(100.0, samples / info.duration * 100) print(f"扫描进度:{samples} 秒({percent:.1f}%)", flush=True) else: print(f"扫描进度:{samples} 秒", flush=True) candidate = detector.flush() if candidate is not None: classifier.consume(candidate) process.stdout.close() return_code = process.wait() if return_code != 0: stderr_handle.flush() detail = _tail(stderr_path) suffix = f":{detail}" if detail else "" raise SlideExtractionError(f"FFmpeg 稳定帧扫描失败(exit={return_code}){suffix}") except BaseException as exc: active_error = exc cleanup_error = _attempt_reap(process) if process is not None else None if not isinstance(exc, Exception): raise if cleanup_error is not None: raise SlideExtractionError( f"FFmpeg 稳定帧扫描失败:{exc};子进程清理失败:{cleanup_error}" ) from exc if isinstance(exc, SlideExtractionError): raise raise SlideExtractionError(f"FFmpeg 稳定帧扫描失败:{exc}") from exc finally: if stderr_handle is not None: try: stderr_handle.close() except Exception as close_error: if active_error is None: raise SlideExtractionError( f"FFmpeg 稳定帧扫描关闭 stderr 失败:{close_error}" ) from close_error if samples == 0: raise SlideExtractionError("第一条视频流没有可扫描画面") if not classifier.pages: raise SlideExtractionError("未发现持续至少 3 个采样点的稳定页面") return classifier.pages def _extract_command(ffmpeg: str, source: Path, timestamp: float, destination: Path) -> list[str]: return [ ffmpeg, "-hide_banner", "-loglevel", "error", "-ss", f"{timestamp:.3f}", "-i", str(source), "-map", "0:v:0", "-frames:v", "1", "-c:v", "png", "-compression_level", "6", "-n", str(destination), ] def extract_original_pages( source: Path, staging: Path, info: VideoInfo, pages: Sequence[LogicalPage], ) -> list[Path]: ffmpeg = _which_or_error("ffmpeg") digits = max(3, len(str(len(pages)))) pages_directory = staging / "pages" pages_directory.mkdir() outputs: list[Path] = [] for index, page in enumerate(pages, 1): destination = pages_directory / f"slide_{index:0{digits}d}.png" stderr_path = staging / f".extract-{index:0{digits}d}.stderr.log" _run_file_command( _extract_command(ffmpeg, source, page.current_timestamp, destination), stderr_path, f"提取第 {index} 页原分辨率 PNG", ) try: with Image.open(destination) as image: if image.format != "PNG": raise SlideExtractionError(f"第 {index} 页不是 PNG") if image.size != (info.width, info.height): raise SlideExtractionError( f"第 {index} 页尺寸 {image.size},预期 {(info.width, info.height)}" ) image.verify() except OSError as exc: raise SlideExtractionError(f"第 {index} 页 PNG 无法读取:{exc}") from exc outputs.append(destination) print(f"原图进度:{index}/{len(pages)}", flush=True) return outputs def write_image_pdf( png_paths: Sequence[Path], destination: Path, image_opener: Callable[[Path], Image.Image] = Image.open, ) -> None: if not png_paths: raise SlideExtractionError("没有 PNG 页面可写入 PDF") page_count = len(png_paths) max_object = 2 + page_count * 3 offsets = [0] * (max_object + 1) def page_object(index: int) -> int: return 3 + index * 3 def image_object(index: int) -> int: return 4 + index * 3 def content_object(index: int) -> int: return 5 + index * 3 with destination.open("xb") as output: output.write(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") def write_object(number: int, body: bytes) -> None: offsets[number] = output.tell() output.write(f"{number} 0 obj\n".encode("ascii")) output.write(body) output.write(b"\nendobj\n") write_object(1, b"<< /Type /Catalog /Pages 2 0 R >>") kids = " ".join(f"{page_object(i)} 0 R" for i in range(page_count)) write_object(2, f"<< /Type /Pages /Count {page_count} /Kids [{kids}] >>".encode("ascii")) for index, path in enumerate(png_paths): with image_opener(path) as opened: rgb_image = opened.convert("RGB") try: width, height = rgb_image.size raw_rgb = rgb_image.tobytes() compressed = zlib.compress(raw_rgb, level=6) del raw_rgb page_body = ( f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] " f"/Resources << /XObject << /Im0 {image_object(index)} 0 R >> >> " f"/Contents {content_object(index)} 0 R >>" ).encode("ascii") write_object(page_object(index), page_body) image_header = ( f"<< /Type /XObject /Subtype /Image /Width {width} /Height {height} " f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode " f"/Length {len(compressed)} >>\nstream\n" ).encode("ascii") image_number = image_object(index) offsets[image_number] = output.tell() output.write(f"{image_number} 0 obj\n".encode("ascii")) output.write(image_header) output.write(compressed) output.write(b"\nendstream\nendobj\n") content = f"q\n{width} 0 0 {height} 0 0 cm\n/Im0 Do\nQ\n".encode("ascii") content_body = f"<< /Length {len(content)} >>\nstream\n".encode("ascii") + content + b"endstream" write_object(content_object(index), content_body) finally: rgb_image.close() if "compressed" in locals(): del compressed xref_offset = output.tell() output.write(f"xref\n0 {max_object + 1}\n".encode("ascii")) output.write(b"0000000000 65535 f \n") for number in range(1, max_object + 1): output.write(f"{offsets[number]:010d} 00000 n \n".encode("ascii")) output.write( f"trailer\n<< /Size {max_object + 1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode( "ascii" ) ) output.flush() os.fsync(output.fileno()) def _lexists(path: Path) -> bool: return os.path.lexists(path) def _is_symlink(path: Path) -> bool: return path.is_symlink() def _absolute_lexical(value: str | os.PathLike[str]) -> Path: """Make an absolute normalized path without following symlinks.""" expanded = Path(value).expanduser() return Path(os.path.abspath(os.fspath(expanded))) def _validate_paths(source_arg: str, output_arg: str | None) -> tuple[Path, Path]: source = _absolute_lexical(source_arg) if _is_symlink(source): raise SlideExtractionError(f"输入视频必须是普通文件,拒绝符号链接:{source}") if not _lexists(source) or not source.is_file(): raise SlideExtractionError(f"输入视频不存在或不是文件:{source}") output = ( _absolute_lexical(output_arg) if output_arg else source.with_name(f"{source.stem}.slides") ) if _lexists(output): raise SlideExtractionError(f"输出目录已存在,默认不覆盖:{output}") if not output.parent.exists() or not output.parent.is_dir(): raise SlideExtractionError(f"输出目录的父目录不存在:{output.parent}") return source, output def extract_slides(source_arg: str, output_arg: str | None = None) -> Path: source, output = _validate_paths(source_arg, output_arg) staging = output.parent / f".{output.name}.staging-{uuid.uuid4().hex[:12]}" started = time.monotonic() stage = "创建暂存目录" staging_created = False try: staging.mkdir() staging_created = True stage = "探测第一条视频流" info = probe_video(source, staging) print( f"视频:{info.width}x{info.height}" + (f",{info.duration:.3f} 秒" if info.duration is not None else ""), flush=True, ) stage = "扫描稳定页面" pages = scan_video(source, staging, info) print(f"稳定页面:{len(pages)}", flush=True) stage = "提取原分辨率 PNG" png_paths = extract_original_pages(source, staging, info, pages) stage = "生成合并 PDF" pdf_path = staging / f"{source.stem}.slides.pdf" write_image_pdf(png_paths, pdf_path) for temporary in staging.glob(".*.log"): temporary.unlink(missing_ok=True) (staging / ".probe.json").unlink(missing_ok=True) if _lexists(output): raise SlideExtractionError(f"提交前发现输出目录已存在,拒绝覆盖:{output}") stage = "提交正式输出目录" os.rename(staging, output) elapsed = time.monotonic() - started print(f"完成:{output}", flush=True) print(f"页面:{len(png_paths)};耗时:{elapsed:.3f} 秒", flush=True) return output except BaseException as exc: if staging_created and _lexists(staging): shutil.rmtree(staging, ignore_errors=True) if not isinstance(exc, Exception): raise if isinstance(exc, SlideExtractionError): raise raise SlideExtractionError(f"{stage}失败:{exc}") from exc def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="从单个本地会议录屏提取稳定、动画合并且去重的原分辨率 PPT 页面。" ) parser.add_argument("video", help="单个本地视频路径") parser.add_argument( "--output", help="输出目录;默认是视频同目录下的 <视频名>.slides,已存在时拒绝覆盖", ) return parser def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) try: extract_slides(args.video, args.output) return 0 except KeyboardInterrupt: print("错误:用户中断,暂存已清理。", file=sys.stderr) return 130 except SlideExtractionError as exc: print(f"错误:{exc}", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())