#!/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 MODULE_DIRECTORY = str(Path(__file__).resolve().parent) if MODULE_DIRECTORY not in sys.path: sys.path.insert(0, MODULE_DIRECTORY) from transcribe_media import ( MEDIA_DECODE_THREADS, MEDIA_FILTER_THREADS, MediaHostGuardError, _subprocess_creation_flags, media_host_guard, ) 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 PALETTE_BINS_PER_CHANNEL = 8 PALETTE_BIN_COUNT = PALETTE_BINS_PER_CHANNEL ** 3 SEQUENCE_THEME_COVERAGE = 0.12 SEQUENCE_THEME_MAX_GAP = 1 SEQUENCE_THEME_MIN_MARKERS = 8 SEQUENCE_THEME_MIN_SHARE = 0.50 SEQUENCE_THEME_MIN_DENSITY = 0.80 PROCESS_WAIT_SECONDS = 5.0 STDERR_TAIL_BYTES = 8192 class SlideExtractionError(Exception): """Expected, user-facing extraction failure.""" class PartialFrameError(SlideExtractionError): """A rawvideo stream ended in the middle of one frame.""" class HardwareDecodePathError(SlideExtractionError): """The NVDEC decoder/transfer pipe did not yield a complete frame stream.""" @dataclass(frozen=True) class VideoInfo: width: int height: int duration: float | None codec_name: str | None = None pixel_format: str | None = None @dataclass(frozen=True) class ScanResult: pages: tuple[LogicalPage, ...] sample_count: int stable_candidate_count: int classification_counts: tuple[tuple[str, int], ...] decoder: str | None @dataclass(frozen=True) class FrameFeature: sample_index: int relative_seconds: float gray: np.ndarray palette_histogram: np.ndarray perceptual_hash: int content_score: float sharpness: float @dataclass(frozen=True) class PageCandidate: timestamp: float feature: np.ndarray palette_histogram: np.ndarray perceptual_hash: int content_score: float sharpness: float @dataclass class LogicalPage: logical_anchor_feature: np.ndarray current_feature: np.ndarray current_palette: 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 PartialFrameError( f"rawvideo 半帧 EOF:期望 {size} bytes,实际 {actual} bytes" ) chunks.append(chunk) remaining -= len(chunk) return b"".join(chunks) def _attach_cleanup_note(error: BaseException, detail: str) -> None: error.add_note(f"清理诊断:{detail}") if isinstance(error, HardwareDecodePathError): setattr(error, "_media_cleanup_failed", True) 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, creationflags=_subprocess_creation_flags(), ) 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_name,pix_fmt,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"]) codec_name = str(stream.get("codec_name") or "").strip().lower() or None pixel_format = str(stream.get("pix_fmt") or "").strip().lower() or None 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, codec_name=codec_name, pixel_format=pixel_format, ) 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 _palette_histogram(rgb_array: np.ndarray) -> np.ndarray: """Return a normalized 8x8x8 RGB histogram without retaining the RGB frame.""" if rgb_array.ndim != 3 or rgb_array.shape[2] != 3 or rgb_array.size == 0: raise SlideExtractionError("颜色主题特征要求非空 RGB 图像") if rgb_array.dtype != np.uint8: raise SlideExtractionError("颜色主题特征要求 uint8 RGB 图像") quantized = rgb_array >> 5 indexes = ( quantized[:, :, 0].astype(np.int16) * PALETTE_BINS_PER_CHANNEL ** 2 + quantized[:, :, 1].astype(np.int16) * PALETTE_BINS_PER_CHANNEL + quantized[:, :, 2].astype(np.int16) ) histogram = np.bincount(indexes.reshape(-1), minlength=PALETTE_BIN_COUNT).astype( np.float32 ) histogram /= float(indexes.size) return histogram def _gray_feature(rgb: bytes, width: int, height: int, index: int) -> FrameFeature: array = np.frombuffer(rgb, dtype=np.uint8).reshape((height, width, 3)) palette_histogram = _palette_histogram(array) 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, palette_histogram=palette_histogram, 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, palette_histogram=best.palette_histogram.copy(), 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_palette = candidate.palette_histogram.copy() 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_palette=candidate.palette_histogram.copy(), current_hash=candidate.perceptual_hash, current_timestamp=candidate.timestamp, current_score=candidate.content_score, first_seen_order=len(self.pages), ) ) return "new_page" def _validated_page_palettes(pages: Sequence[LogicalPage]) -> np.ndarray: palettes: list[np.ndarray] = [] for index, page in enumerate(pages, 1): palette = np.asarray(page.current_palette) if palette.shape != (PALETTE_BIN_COUNT,): raise SlideExtractionError( f"第 {index} 个稳定候选颜色主题维度错误:{palette.shape}" ) if not np.all(np.isfinite(palette)) or np.any(palette < 0): raise SlideExtractionError(f"第 {index} 个稳定候选颜色主题包含非法数值") total = float(np.sum(palette, dtype=np.float64)) if not math.isclose(total, 1.0, rel_tol=1e-4, abs_tol=1e-4): raise SlideExtractionError( f"第 {index} 个稳定候选颜色主题未归一化:sum={total:.6f}" ) palettes.append(palette.astype(np.float32, copy=False)) return np.stack(palettes) def select_main_sequence(pages: Sequence[LogicalPage]) -> list[LogicalPage]: """Conservatively keep the dominant contiguous visual-theme sequence.""" if not pages: raise SlideExtractionError("主课件序列过滤没有稳定候选页面") palettes = _validated_page_palettes(pages) total_pages = len(pages) best_score: tuple[int, int, float, int, int] | None = None best_bounds: tuple[int, int] | None = None for color_bin in range(PALETTE_BIN_COUNT): marker_indexes = np.flatnonzero( palettes[:, color_bin] >= SEQUENCE_THEME_COVERAGE ).tolist() if not marker_indexes: continue groups: list[list[int]] = [] current_group = [int(marker_indexes[0])] for marker_index in marker_indexes[1:]: marker_index = int(marker_index) if marker_index - current_group[-1] <= SEQUENCE_THEME_MAX_GAP + 1: current_group.append(marker_index) else: groups.append(current_group) current_group = [marker_index] groups.append(current_group) for group in groups: marker_count = len(group) start, end = group[0], group[-1] span = end - start + 1 share = marker_count / total_pages density = marker_count / span if ( marker_count < SEQUENCE_THEME_MIN_MARKERS or share < SEQUENCE_THEME_MIN_SHARE or density < SEQUENCE_THEME_MIN_DENSITY ): continue coverage_sum = float( np.sum(palettes[start : end + 1, color_bin], dtype=np.float64) ) score = (marker_count, span, coverage_sum, -start, -color_bin) if best_score is None or score > best_score: best_score = score best_bounds = (start, end) if best_bounds is None: return list(pages) start, end = best_bounds if start == 0 and end == total_pages - 1: return list(pages) return list(pages[start : end + 1]) def _decoder_for_codec( codec_name: str | None, pixel_format: str | None = None, ) -> str | None: del pixel_format # evidence only; codec decides whether the single NVDEC attempt is made return {"h264": "h264_cuvid", "hevc": "hevc_cuvid"}.get(codec_name or "") def _scan_command( ffmpeg: str, source: Path, scan_width: int, scan_height: int, decoder: str | None = None, ) -> list[str]: filters = [] if decoder is not None: filters.extend(("hwdownload", "format=nv12")) filters.extend( ( "setpts=PTS-STARTPTS", "fps=fps=1:start_time=0", f"scale={scan_width}:{scan_height}:flags=bilinear", ) ) command = [ ffmpeg, "-hide_banner", "-loglevel", "error", ] if decoder is not None: command.extend( ( "-xerror", "-hwaccel", "cuda", "-hwaccel_output_format", "cuda", "-c:v", decoder, ) ) command.extend( ( "-threads", str(MEDIA_DECODE_THREADS), "-filter_threads", str(MEDIA_FILTER_THREADS), "-filter_complex_threads", str(MEDIA_FILTER_THREADS), "-i", str(source), ) ) command.extend( ( "-map", "0:v:0", "-vf", ",".join(filters), "-pix_fmt", "rgb24", "-f", "rawvideo", "-", ) ) return command def _scan_video_once( source: Path, staging: Path, info: VideoInfo, *, decoder: str | None, ) -> ScanResult: scan_width, scan_height = _scan_size(info) frame_bytes = scan_width * scan_height * 3 path_label = decoder or "cpu" stderr_path = staging / f".scan-{path_label}.stderr.log" stderr_handle: BinaryIO | None = None process: subprocess.Popen[bytes] | None = None active_error: BaseException | None = None detector = StableSegmentDetector() classifier = PageClassifier() samples = 0 stable_candidates = 0 classifications: dict[str, int] = {} try: try: stderr_handle = stderr_path.open("wb") except OSError as exc: raise SlideExtractionError(f"FFmpeg 稳定帧扫描打开 stderr 失败:{exc}") from exc command = _scan_command( _which_or_error("ffmpeg"), source, scan_width, scan_height, decoder, ) try: process = subprocess.Popen( command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=stderr_handle, shell=False, creationflags=_subprocess_creation_flags(), ) except BaseException as exc: if not isinstance(exc, Exception): raise if decoder is not None: raise HardwareDecodePathError( f"{decoder} 启动失败:{exc}" ) from exc raise SlideExtractionError(f"受限 CPU 扫描启动失败:{exc}") from exc assert process.stdout is not None while True: try: raw = read_exact(process.stdout, frame_bytes) except PartialFrameError as exc: if decoder is not None: raise HardwareDecodePathError(f"{decoder} 输出半帧:{exc}") from exc raise if raw is None: break candidate = detector.add(_gray_feature(raw, scan_width, scan_height, samples)) if candidate is not None: stable_candidates += 1 classification = classifier.consume(candidate) classifications[classification] = classifications.get(classification, 0) + 1 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: stable_candidates += 1 classification = classifier.consume(candidate) classifications[classification] = classifications.get(classification, 0) + 1 try: process.stdout.close() except OSError as exc: raise SlideExtractionError(f"FFmpeg 稳定帧扫描关闭 stdout 失败:{exc}") from exc return_code = process.wait() if return_code != 0: stderr_handle.flush() detail = _tail(stderr_path) suffix = f":{detail}" if detail else "" message = f"FFmpeg 稳定帧扫描失败(exit={return_code}){suffix}" if decoder is not None: raise HardwareDecodePathError(f"{decoder} {message}") raise SlideExtractionError(message) except BaseException as exc: active_error = exc cleanup_error = _attempt_reap(process) if process is not None else None if not isinstance(exc, Exception): if cleanup_error is not None: _attach_cleanup_note(exc, f"子进程回收失败:{cleanup_error}") raise if cleanup_error is not None: _attach_cleanup_note(exc, f"子进程回收失败:{cleanup_error}") raise 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 _attach_cleanup_note(active_error, f"关闭 stderr 失败:{close_error}") if samples == 0: if decoder is not None: raise HardwareDecodePathError(f"{decoder} 未输出任何完整扫描帧") raise SlideExtractionError("第一条视频流没有可扫描画面") return ScanResult( pages=tuple(classifier.pages), sample_count=samples, stable_candidate_count=stable_candidates, classification_counts=tuple(sorted(classifications.items())), decoder=decoder, ) def scan_video(source: Path, staging: Path, info: VideoInfo) -> list[LogicalPage]: decoder = _decoder_for_codec(info.codec_name, info.pixel_format) if decoder is None: print( "警告:视频编码没有 H.264/H.265 NVDEC 映射," "使用 4 解码线程/2 滤镜线程的受限 CPU。", file=sys.stderr, flush=True, ) result = _scan_video_once(source, staging, info, decoder=None) path_label = "受限 CPU" else: try: result = _scan_video_once(source, staging, info, decoder=decoder) path_label = f"NVDEC/CUDA ({decoder})" except HardwareDecodePathError as exc: if getattr(exc, "_media_cleanup_failed", False): raise print( f"警告:NVDEC 不可用({decoder}):{exc};改用 4 解码线程/2 滤镜线程的受限 CPU。", file=sys.stderr, flush=True, ) result = _scan_video_once(source, staging, info, decoder=None) path_label = "受限 CPU(NVDEC 一次回退)" print( f"扫描解码路径:{path_label};解码线程={MEDIA_DECODE_THREADS};" f"滤镜线程={MEDIA_FILTER_THREADS}", flush=True, ) if not result.pages: raise SlideExtractionError("未发现持续至少 3 个采样点的稳定页面") return list(result.pages) def _extract_command(ffmpeg: str, source: Path, timestamp: float, destination: Path) -> list[str]: return [ ffmpeg, "-hide_banner", "-loglevel", "error", "-ss", f"{timestamp:.3f}", "-threads", str(MEDIA_DECODE_THREADS), "-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 = "扫描稳定页面" candidates = scan_video(source, staging, info) pages = select_main_sequence(candidates) print( f"稳定候选:{len(candidates)};主课件页面:{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: with media_host_guard(): extract_slides(args.video, args.output) return 0 except KeyboardInterrupt: print("错误:用户中断,暂存已清理。", file=sys.stderr) return 130 except (SlideExtractionError, MediaHostGuardError) as exc: print(f"错误:{exc}", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())