diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d75b851 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +on: + push: + branches: [master, develop] + pull_request: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13"] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install FFmpeg (Ubuntu) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes ffmpeg + + - name: Install FFmpeg (macOS) + if: runner.os == 'macOS' + run: brew install ffmpeg + + - name: Install FFmpeg (Windows) + if: runner.os == 'Windows' + run: choco install ffmpeg --yes --no-progress + + - name: Install package and test dependencies + run: python -m pip install --upgrade pip && python -m pip install -r requirements.txt + + - name: Run tests + run: python -m pytest -q + + - name: Build wheel + run: python -m pip wheel . --no-deps --wheel-dir dist + + minimum-textual: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + + - name: Install FFmpeg + run: sudo apt-get update && sudo apt-get install --yes ffmpeg + + - name: Install minimum supported Textual + run: >- + python -m pip install --upgrade pip && + python -m pip install -e . -r tests/requirements.txt "textual==5.0.0" && + python -m pip check + + - name: Run tests at the minimum Textual version + run: python -m pytest -q + + package: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + + - name: Install build tooling and FFmpeg + run: | + sudo apt-get update && sudo apt-get install --yes ffmpeg + python -m pip install --upgrade pip build + + - name: Build distribution artifacts + run: python -m build + + - name: Smoke test installed wheel outside the checkout + run: | + python -m venv "$RUNNER_TEMP/wheel-smoke" + "$RUNNER_TEMP/wheel-smoke/bin/python" -m pip install dist/*.whl + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/wheel-smoke/bin/python" -c "import sharp_frames; from sharp_frames.ui.components.raster_preview import RasterImagePreview; print(sharp_frames.__version__)" + "$RUNNER_TEMP/wheel-smoke/bin/sharp-frames" --help diff --git a/.gitignore b/.gitignore index aa61f75..b104b7a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,9 +30,10 @@ pip-delete-this-directory.txt htmlcov/ .tox/ -# Editor files +# Editor and local agent files .vscode/ .idea/ +.codex/ *.swp *.swo diff --git a/CHANGELOG.md b/CHANGELOG.md index b16c18f..9d372dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to the Sharp Frames project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - 2026-07-18 + +### Added +- Scrollable full-frame selection timeline with click-to-inspect and keyboard navigation +- Inline terminal frame preview via Sixel/Kitty raster graphics with an external-viewer fallback +- Automatic color space detection and conversion to sRGB for HDR and wide-gamut sources (BT.2020, HLG, PQ) +- Resolution-normalized focus scoring for more consistent comparisons across source sizes +- Trend-aware outlier detection with improved defaults +- Responsive selection layout that keeps the preview and chart visible on short terminals +- Shared alpha-preserving image decode/resize/encode helpers +- Cross-platform CI coverage for Python 3.10 through 3.13 on Linux, macOS, and Windows, plus the minimum supported Textual version +- Regression coverage for selection quality, output collisions, cancellation, and runtime cleanup + +### Changed +- Selection methods now balance sharpness and temporal distribution more consistently +- Frame extraction, analysis, preview, and saving pipelines provide stricter validation and failure reporting +- Image-directory exports preserve content while correctly handling format conversion and case-insensitive filename collisions +- Configuration and selection screens provide clearer controls, status, and keyboard behavior; the processing screen is vertically centered +- Textual requirement raised to 5.0 or newer; new dependencies `textual-image` and `Pillow` + +### Fixed +- FFmpeg and FFprobe process cleanup, cancellation, timeout, and pipe handling +- Partial or failed image reads being treated as valid zero-quality frames +- Selection previews disagreeing with the frames produced during processing +- Output metadata reporting frames that were not successfully saved +- Platform-specific dependency guidance and Windows-safe process startup +- Start Over after saving returning to the middle of setup instead of the first step +- Cancel during setup leaving the application running on an empty screen + ## [0.3.1] - 2025-01-29 ### Fixed @@ -136,4 +165,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - outlier-removal: Remove outliers based on comparison with neighbors - Interactive mode with guided prompts - Proper Python package structure for pip and pipx installation -- Command-line interface with `sharp-frames` command \ No newline at end of file +- Command-line interface with `sharp-frames` command diff --git a/README.md b/README.md index 25f88d6..8c277e2 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,13 @@ Or with pipx for isolated installation: pipx install sharp-frames ``` -**IMPORTANT: Video Processing Requirement**: Install FFmpeg separately for video input support. +**IMPORTANT: Video Processing Requirement**: Install an FFmpeg distribution that includes both `ffmpeg` and `ffprobe`. Both executables must be on `PATH`; image-directory processing does not require them. - **Windows**: Download from [FFmpeg website](https://ffmpeg.org/download.html) and add to PATH - **macOS**: `brew install ffmpeg` - **Linux**: `sudo apt install ffmpeg` +HDR-to-SDR extraction additionally requires an FFmpeg build with the `zscale` filter (`libzimg`). You can verify support with `ffmpeg -filters | grep zscale`. Non-HDR video processing does not require `zscale`. + ## Quick Start ### Modern Interface (Default) @@ -45,9 +47,9 @@ sharp-frames [options] ``` **Input Types:** -- Video files: `.mp4`, `.avi`, `.mov`, `.mkv`, `.wmv`, `.flv`, `.webm`, `.m4v`, etc. +- Video files: `.mp4`, `.avi`, `.mov`, `.mkv`, `.wmv`, `.flv`, `.webm`, `.m4v`, `.3gp`, `.3g2`, `.ogv`, `.ts`, `.mts`, `.m2ts`, `.mpg`, `.mpeg`, `.vob` - Video directories: Processes all videos in a folder -- Image directories: `.jpg`, `.jpeg`, `.png`, `.bmp`, `.tiff`, `.webp`, etc. +- Image directories: `.jpg`, `.jpeg`, `.png`, `.bmp`, `.tif`, `.tiff`, `.webp`, `.ppm`, `.pgm`, `.pbm` ## Selection Methods @@ -63,10 +65,10 @@ Divides content into batches and selects the sharpest frame from each batch. --selection-method batched --batch-size 5 --batch-buffer 2 ``` -### Outlier Removal +### Outlier Detection Removes unusually blurry frames by comparing each frame to its neighbors. ```bash ---selection-method outlier-removal --outlier-window-size 15 --outlier-sensitivity 50 +--selection-method outlier-removal --outlier-window-size 15 --outlier-sensitivity 60 ``` ## Command Line Options @@ -79,11 +81,11 @@ Removes unusually blurry frames by comparing each frame to its neighbors. ### Selection Method Parameters - `--num-frames `: Number of frames to select (best-n, default: 300) -- `--min-buffer `: Minimum gap between selected frames (best-n, default: 3) +- `--min-buffer `: Minimum number of intervening frames between selected frames (best-n, default: 3) - `--batch-size `: Frames per batch (batched, default: 5) - `--batch-buffer `: Frames to skip between batches (batched, default: 2) -- `--outlier-window-size `: Neighbor comparison window (outlier-removal, default: 15) -- `--outlier-sensitivity `: Removal aggressiveness 0-100 (outlier-removal, default: 50) +- `--outlier-window-size `: Local comparison window, minimum 5 (outlier-removal, default: 15) +- `--outlier-sensitivity `: Detection sensitivity 0-100 (outlier-removal, default: 60) ## Examples @@ -125,23 +127,28 @@ sharp-frames photos selected --selection-method outlier-removal --outlier-sensit ## Requirements -- Python 3.7 or higher -- Dependencies installed automatically: `opencv-python`, `numpy`, `tqdm`, `textual` -- FFmpeg (for video processing only) +- Python 3.10 or higher +- Dependencies installed automatically: `opencv-python`, `numpy`, `tqdm`, + `textual`, `textual-image` +- FFmpeg and FFprobe (for video processing only) +- FFmpeg `zscale`/`libzimg` support (for HDR-to-SDR processing only) ## How It Works 1. **Validation**: Checks input paths, file formats, and system dependencies 2. **Extraction**: Videos are extracted to frames at specified FPS using FFmpeg -3. **Analysis**: Calculates sharpness scores using Laplacian variance in parallel -4. **Selection**: Applies chosen algorithm to select the best frames/images +3. **Analysis**: Normalizes analysis resolution, lightly denoises each image, + and combines Laplacian variance with Tenengrad focus scoring in parallel. + Unreadable inputs are excluded and reported. +4. **Selection**: Applies the chosen source-aware algorithm to select the best + naturally ordered frames/images. 5. **Output**: Saves selected content with metadata including scores and parameters ## Output - Selected frames/images with descriptive filenames - `selected_metadata.json` with processing details, parameters, and sharpness scores -- Preserves original formats for image directory input +- Transcodes selected images to the configured output format (`jpg` by default) - Automatic output directory creation with permission validation ## Help & Support diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 219f12c..b69d6b8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,53 +1,35 @@ -# Sharp Frames v0.2.0 Release Notes +# Sharp Frames v0.4.0 Release Notes -## New Features +## Highlights -### Textual Interface (Default) -- Replaces command-line prompts with step-by-step wizard -- Real-time input validation and error feedback -- Enhanced error messages with specific guidance +- Explore the complete analyzed frame set in a scrollable timeline — click any bar to preview that frame directly in the terminal. +- iPhone and HDR sources (BT.2020, HLG, PQ) are detected and converted to sRGB automatically. +- Get more reliable selections from resolution-normalized focus scoring, trend-aware outlier detection, and improved temporal distribution. +- Preview and execution now share the same selection rules, so displayed counts and chosen frames agree. -### Video Directory Processing -- Process all videos in a folder with a single command -- Automatically detects and processes supported video formats -- Maintains individual processing settings for each video +## Interface Improvements -### Usage Changes -```bash -# New default behavior -sharp-frames # Launches textual interface - -# Existing functionality unchanged -sharp-frames input.mp4 output/ # Direct processing -sharp-frames video_folder/ output/ # Process all videos in folder -sharp-frames --interactive # Legacy prompts -``` - -## Technical Changes +- Full-frame selection timeline with click-to-inspect, horizontal scrolling, and keyboard navigation (arrows, PgUp/PgDn through selected frames, Ctrl+PgUp/PgDn paging, Home/End). +- Inline frame preview rendered through Sixel or Kitty terminal graphics, with an external-viewer fallback (press `O`). +- Responsive layout keeps the preview and chart visible on short terminals (down to 80×24) while preserving the spacious layout on large ones. +- Start Over returns to the first setup step with a clean configuration, and Cancel exits the application. +- Clearer configuration controls and validation feedback. +- More actionable dependency and processing errors across supported platforms. -### Added -- Path validation with existence/permission checking -- Numeric field validation with range checking -- Thread-safe subprocess management -- UI component testing -- Enhanced error analysis and reporting +## Reliability and Compatibility -### Fixed -- Configuration parameter mapping -- Resource cleanup (temp files, subprocesses) -- Error message specificity +- HDR and wide-gamut color spaces are converted to sRGB/BT.709 through FFmpeg `zscale`, with a clear error for unsupported BT.2020 constant-luminance sources. +- Hardened FFmpeg and FFprobe cancellation, timeouts, output draining, and temporary-directory cleanup. +- Unreadable images are excluded and reported instead of silently receiving valid-looking scores. +- Image exports preserve alpha and handle format conversion, partial failures, metadata accuracy, and case-insensitive filename collisions. +- CI covers Linux, macOS, and Windows on Python 3.10, 3.11, 3.12, and 3.13, plus the minimum supported Textual version. -## Breaking Changes -- `sharp-frames` without arguments now launches UI instead of showing help -- Use `sharp-frames --help` for command-line help +## Upgrade -## Installation ```bash pip install --upgrade sharp-frames # or pipx upgrade sharp-frames ``` -## Dependencies -- Added: `textual>=0.41.0` -- All existing dependencies unchanged \ No newline at end of file +New dependencies (`textual-image`, `Pillow`) install automatically. FFmpeg and FFprobe remain required for video processing and must be available on `PATH`; HDR-to-SDR conversion additionally requires FFmpeg built with `zscale`/`libzimg` support. diff --git a/docs/APPLICATION_AUDIT.md b/docs/APPLICATION_AUDIT.md new file mode 100644 index 0000000..d54bb64 --- /dev/null +++ b/docs/APPLICATION_AUDIT.md @@ -0,0 +1,294 @@ +# Sharp Frames Application Audit + +Date: 2026-07-14 +Baseline: `develop` at `e712afa` +Remediation branch: `codex/deep-application-review` + +## Scope + +This audit covered the direct CLI, Textual UI, extraction and saving pipelines, +all three selection methods, multi-video and image-directory inputs, sharpness +analysis, packaging, dependency health, cancellation, and platform support. + +The audit combined source inspection with focused behavior probes, synthetic +FFmpeg runs, package builds, static analysis, and the full test suite. + +## Remediation Outcome + +All Critical and High findings in this audit are resolved on +`codex/deep-application-review`. + +| Finding | Resolution | +| --- | --- | +| C1 | All non-interactive save paths now fail closed on unapproved output conflicts. | +| C2 | Modern and direct-CLI directory saves transcode to the requested format and deterministically disambiguate colliding stems. | +| H1–H5 | CLI, TUI, previews, and compatibility entry points share canonical, source-aware selection implementations with exact previews. | +| H6 | FFmpeg output is continuously drained; completion requires a clean exit; failure, timeout, and cancellation reject partial output. | +| H7 | Cancellation reaches active extraction and analysis, and temporary directories are cleaned on every tested exit path. | +| H8 | The declared and tested support floor is Python 3.10. | +| H9 | Image-only processing skips video-tool checks; video checks require FFmpeg and FFprobe; HDR and platform requirements are documented. | +| H10 | CLI, TUI, validation, extraction, and documentation use one shared media-format contract. | +| H11 | FFprobe failures preserve actionable stderr and can no longer be masked by a local import error. | +| H12 | The maintained suite is green, dependencies no longer install a stale app revision, and CI covers four Python versions on three operating systems. | + +Final local verification used clean environments with current dependency +resolution on Python 3.10, 3.11, 3.12, and 3.13. Each environment passed all +307 tests. The runtime regressions include real synthetic FFmpeg extraction, +nonzero partial output, overall timeout, cancellation, temporary-directory +cleanup, image-only dependency checks, format discovery, and OS-specific +guidance. The sdist and wheel build cleanly and advertise Python 3.10+ with the +expected runtime dependencies and license metadata. A final dependency audit +reported no known vulnerabilities in the resolved environment. + +## Baseline Verification + +- Wheel build succeeded. +- A synthetic video completed end-to-end extraction with the expected frame + count. +- Color-space tests passed, including real FFmpeg Display P3, BT.2020 SDR, and + HDR filter execution. +- The full suite reported 220 passing and 71 failing tests. +- No GitHub Actions workflow was configured. +- Two Dependabot findings were open: a medium-severity pytest temporary + directory issue and a low-severity Pygments ReDoS issue. + +## Critical Findings + +### C1. Non-interactive saving bypasses overwrite protection + +When the Textual path uses `FrameSaver(show_progress=False)`, an existing +output directory only produces a warning when `force_overwrite` is false. The +save then proceeds and overwrites matching output names. This contradicts the +UI choice and creates a direct data-loss risk. + +Required outcome: existing output files must remain untouched unless overwrite +was explicitly authorized. Non-interactive conflicts must fail safely and +report the conflicting paths. + +### C2. Image-directory output can have false extensions and collisions + +For directory inputs without resizing, the saver constructs a filename using +the configured output format but copies the source bytes unchanged. A PNG can +therefore be written as `image.jpg` while retaining PNG content. Files with the +same stem but different extensions can map to the same destination and +overwrite one another. + +Required outcome: byte copies preserve their real format, requested format +changes perform an actual transcode, and every selected input gets a unique, +deterministic destination. + +## High Findings + +### H1. Best-N is behaviorally different between CLI and TUI + +The Textual `FrameSelector` ranks by sharpness only; its advertised +distribution weighting is absent. The direct CLI uses a separate segmented +implementation. Users can receive materially different selections from the +same frames and parameters depending on entry point. + +Required outcome: one canonical algorithm and identical results across CLI, +TUI, preview, and tests. Sharpness and temporal distribution must be normalized +to comparable scales before weighting. + +### H2. Best-N buffer semantics disagree with the preview + +The preview treats `min_buffer` as the number of intervening frames, while +selection accepts an index distance equal to `min_buffer`. This creates count +and guarantee mismatches. + +Required outcome: define `min_buffer` as intervening frames, require an index +distance of at least `min_buffer + 1`, and calculate preview counts using the +same implementation as execution. + +### H3. Outlier-removal endpoints contradict each other + +At sensitivity 100, the Textual implementation removes every frame, the CLI +keeps every frame, and the preview estimates that roughly 60 percent remain. +At sensitivity 0, execution keeps every frame while preview still removes a +small percentage. + +Required outcome: consistent monotonic semantics, with 0 disabling removal and +100 representing maximum useful aggressiveness without a hard-coded all/none +special case. + +### H4. Outlier preview is not an actual preview + +The Textual preview uses a fixed sensitivity-to-removal-rate lookup and ignores +the scores and window size. It can disagree with execution and show an +incorrect save count and completion message. + +Required outcome: exact preview counts from the canonical selection algorithm. + +### H5. Video-directory selection crosses source boundaries + +All extracted frames are concatenated. Best-N spacing, batched groups, and +outlier neighborhoods can cross from one source video to another. Global +sharpness ranking can also starve videos with lower-resolution or lower-contrast +content. + +Required outcome: group-aware selection. Batches, neighborhoods, and spacing +reset at source boundaries; Best-N allocates a deterministic and fair quota +across sources. + +### H6. FFmpeg extraction can silently accept incomplete output + +The modern extractor pipes stderr without continuously draining it, terminates +after ten seconds without a newly observed file, may terminate when its frame +estimate is reached, and accepts any extraction that produced at least ten +files even if FFmpeg failed or was killed. + +Required outcome: continuously consume process output, support explicit +cancellation and a real overall timeout, wait for a successful FFmpeg exit, +and never infer success from an arbitrary minimum file count. + +### H7. Cancellation does not stop extraction and can leak temporary files + +`TUIProcessor.cancel_processing()` only signals sharpness analysis. Active +FFmpeg extraction continues in a worker thread. Cleanup attempts before +`current_result` is assigned do not know the temporary directory and therefore +leak it on cancellation or intermediate errors. + +Required outcome: cancellation propagates to the extractor and process, every +temporary directory has explicit ownership, and cleanup runs on all exit paths. + +### H8. Declared Python support is inaccurate + +Packaging advertises Python 3.7 and 3.8, while runtime code uses built-in +generic annotations and executor APIs requiring Python 3.9 behavior. + +Required outcome: either restore compatibility or declare and test the real +minimum. The implemented correction is Python 3.10+ with matching classifiers +and CI. Python 3.9 is end-of-life, and the security-fixed pytest 9 line also +requires Python 3.10+. + +### H9. Platform dependency checks and documentation disagree with reality + +The Textual validation path requires FFmpeg even for image-only input. Modern +video processing requires FFprobe although metadata describes it as optional. +HDR conversion requires an FFmpeg build with `zscale`/libzimg but this is not +documented. + +Required outcome: dependency checks are input-type-specific, FFprobe and +zscale requirements are accurately communicated, and errors include +platform-appropriate remediation. + +### H10. Supported formats differ between entry points + +The README, validators, single-file processing, image extractor, and video +directory scanner use different extension sets. Validated WebP or M4V inputs +can later be ignored. + +Required outcome: shared format constants used by detection, validation, +extraction, help, and documentation. + +### H11. FFprobe errors are masked + +`FrameExtractor._get_video_info()` imports `json` inside its try block and +references it in an exception clause. Failures before that import can surface +as `UnboundLocalError` instead of the real FFprobe error. + +Required outcome: preserve the original failure type and actionable stderr. + +### H12. Test and dependency baselines are not release-grade + +The suite had 71 failures, many encoding obsolete or contradictory selection +semantics. `requirements.txt` mixes runtime, documentation, and test packages +and includes an editable install pinned to an old application commit. +`tests/requirements.txt` omits Pillow even though tests import it. With no CI, +these failures cannot prevent regressions. + +Required outcome: one truthful green suite, separated dependency manifests, +and CI across supported Python and operating-system versions. + +## Deferred Selection-Quality Roadmap + +The following quality improvements are important but are deferred until the +Critical and High correctness and safety defects above are closed. + +### Implementation status (2026-07-15) + +| Finding | Current status | +| --- | --- | +| Q1 | Baseline implemented. Modern and direct-CLI analysis now normalize the long edge to 512 pixels before scoring. The synthetic cross-resolution ratio fell from 4.0 to approximately 1.39, guarded by a `< 1.5` regression threshold. A fine-detail probe improved sharp/mild-blur separation from approximately 1.17× at 256 pixels to 1.56× at 512 pixels. | +| Q2 | Baseline implemented. A 5×5 Gaussian denoise pass now feeds an equal-weight, log-normalized Laplacian/Tenengrad score. In the deterministic noise probe, the clean edge scores about 4× above the noisy blurred image. Real labeled-corpus validation remains under Q6. | +| Q3 | Natural, case-insensitive numbered ordering is implemented for image directories, video directories, extracted frame discovery, and the direct CLI. Optional EXIF chronology remains a future enhancement. | +| Q4 | Local outlier comparison now uses the neighbor median and median absolute deviation with a stable zero-MAD fallback. A single extreme high score no longer masks a real blurry frame. Scene-aware windows remain future work. | +| Q5 | Resolved for the modern pipeline. Failed reads are excluded instead of receiving score zero; all-failed inputs stop safely; structured counts and paths are added to metadata; and the Textual selection screen reports exclusions. | +| Q6 | In progress. Deterministic probes now track resolution invariance, noise resistance, chronology, unreadable exclusion, and synthetic outlier precision/recall. A representative real-media corpus, duplicate metric, and cross-camera baseline remain outstanding. | +| Q7 | In progress. The chart now scrolls horizontally across the full timeline with guaranteed bar separation. Source-boundary markers and per-frame selection explanations remain outstanding. | + +The focus-score metadata records +`normalized_laplacian_tenengrad_v1` and the 512-pixel analysis scale so future +quality comparisons can distinguish algorithm versions. + +### Q1. Normalize analysis resolution + +Laplacian variance is strongly resolution-dependent. The same synthetic edge +scored approximately 2032 at 128 pixels and 508 at 512 pixels under the current +half-size analysis. Normalize every analysis image to a defined scale before +comparing focus scores, especially across image directories and source videos. + +### Q2. Reduce sensitivity to noise and compression + +Noise can score as high-frequency detail. In a focused probe, a blurred image +scored about 66, while adding noise raised it to about 820. Add light denoising +and evaluate a combined focus metric such as normalized Laplacian variance plus +Tenengrad. Validate against a labeled sharp/blurred corpus. + +### Q3. Use natural and metadata-aware ordering + +Lexicographic ordering places `frame10` before `frame2` and is case-sensitive +across platforms. Use natural sorting for numbered sequences and consider EXIF +capture time where appropriate. Temporal methods must operate on the intended +chronology. + +### Q4. Make outlier statistics robust + +The global min/max range is distorted by a single extreme noisy or corrupt +frame. Prefer robust scale estimates such as median/MAD or percentile ranges, +and consider scene boundaries so legitimate softer scenes are not compared to +unrelated high-detail scenes. + +### Q5. Treat unreadable images explicitly + +The modern analyzer converts failed reads into score zero while keeping the +frame eligible for later saving. Exclude failed inputs from selection, report +them separately, and expose partial-success information in metadata and UI. + +### Q6. Define selection quality metrics + +Build a repeatable evaluation corpus covering motion blur, defocus, noise, +compression, exposure changes, animation, scene cuts, mixed resolution, HDR, +and multi-camera directories. Track precision/recall for rejected blurry +frames, coverage uniformity, duplicate rate, and per-source representation. + +### Q7. Improve visualization and explainability + +The Textual chart only shows the first portion of long inputs. Downsample the +whole timeline, show source boundaries, and explain why a frame was selected or +rejected. This makes parameter tuning and regression review substantially more +trustworthy. + +## Lower-Priority Engineering Improvements + +- Remove dead and duplicated legacy UI/preview implementations after migration. +- Replace broad `except`/silent-pass blocks with typed errors and structured + logging. +- Avoid import-time FFmpeg subprocess checks, especially for image-only use. +- Use `opencv-python-headless` as an optional server/CLI installation extra. +- Add deterministic metadata schemas and atomic metadata writes. +- Add performance benchmarks for large frame sets and long videos. +- Add linting, type checking, security scanning, and package smoke tests to CI. + +## Exit Criteria for This Remediation + +All Critical and High findings are closed only when: + +1. Each issue has a regression test that failed before the fix. +2. CLI and TUI selection results agree for equivalent input and parameters. +3. All save paths preserve data and honor conflict policy. +4. Synthetic FFmpeg success, failure, timeout, and cancellation paths pass. +5. Image-only processing succeeds without FFmpeg. +6. Supported format and Python-version declarations match tested behavior. +7. The complete maintained test suite is green. +8. A final review finds no remaining Critical or High regression. diff --git a/pyproject.toml b/pyproject.toml index c2e7cfc..b04911f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=77.0.3", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -10,16 +10,15 @@ readme = "README.md" authors = [ {name = "Reflct.app", email = "info@reflct.app"} ] -license = {text = "MIT"} -requires-python = ">=3.7" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: OS Independent", "Topic :: Multimedia :: Video", "Topic :: Multimedia :: Graphics", @@ -30,13 +29,15 @@ dependencies = [ "opencv-python>=4.5.0", "numpy>=1.19.0", "tqdm>=4.64.0", - "textual>=0.41.0", + "textual>=5.0.0", + "textual-image>=0.12.0,<0.13.0", + "Pillow>=9.0.0", ] [project.urls] Homepage = "https://github.com/reflct/sharp-frames-python" Documentation = "https://github.com/reflct/sharp-frames-python#readme" -Changelog = "https://github.com/reflct/sharp-frames-python/blob/main/CHANGELOG.md" +Changelog = "https://github.com/reflct/sharp-frames-python/blob/master/CHANGELOG.md" [project.scripts] sharp-frames = "sharp_frames.sharp_frames:main" @@ -44,7 +45,7 @@ sharp-frames = "sharp_frames.sharp_frames:main" # External dependencies that cannot be installed automatically by pip [tool.sharp-frames.external-dependencies] ffmpeg = {description = "Required for video processing", url = "https://ffmpeg.org/download.html"} -ffprobe = {description = "Recommended for video duration detection (included with FFmpeg)", url = "https://ffmpeg.org/download.html"} +ffprobe = {description = "Required for video stream metadata (included with FFmpeg)", url = "https://ffmpeg.org/download.html"} [tool.setuptools] package-dir = {"" = "."} @@ -53,4 +54,4 @@ package-dir = {"" = "."} version = {attr = "sharp_frames.__version__"} [tool.setuptools.packages.find] -include = ["sharp_frames", "sharp_frames.*"] \ No newline at end of file +include = ["sharp_frames", "sharp_frames.*"] diff --git a/requirements.txt b/requirements.txt index 452c4b7..0336503 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,34 +1,4 @@ -click==8.2.1 -ghp-import==2.1.0 -importlib-metadata==4.13.0 -iniconfig==2.1.0 -Jinja2==3.1.6 -linkify-it-py==2.0.3 -Markdown==3.8.2 -markdown-it-py==2.2.0 -MarkupSafe==3.0.2 -mdit-py-plugins==0.4.2 -mdurl==0.1.2 -mergedeep==1.3.4 -numpy==2.3.1 -opencv-python==4.11.0.86 -packaging==25.0 -pathspec==0.12.1 -platformdirs==4.3.8 -pluggy==1.6.0 -Pygments==2.19.2 -pytest==8.4.1 -pytest-asyncio==1.0.0 -pytest-mock==3.14.1 -python-dateutil==2.9.0.post0 -PyYAML==6.0.2 -pyyaml_env_tag==1.1 -rich==14.0.0 --e git+https://github.com/Reflct/sharp-frames-python.git@ff31636e682a1402f48ab5d0ba9d42e07183c7af#egg=sharp_frames -six==1.17.0 -textual==3.5.0 -tqdm==4.67.1 -typing_extensions==4.14.0 -uc-micro-py==1.0.3 -watchdog==6.0.0 -zipp==3.23.0 +# Local development environment. Runtime dependencies are declared in +# pyproject.toml so this file never installs a stale copy of Sharp Frames. +-e . +-r tests/requirements.txt diff --git a/sharp_frames/__init__.py b/sharp_frames/__init__.py index bc3f10d..b6f2ed6 100644 --- a/sharp_frames/__init__.py +++ b/sharp_frames/__init__.py @@ -1,6 +1,6 @@ """Sharp Frames - Extract, score, and select the best frames from a video, video directory, or image directory.""" -__version__ = "0.3.1" +__version__ = "0.4.0" import subprocess import sys @@ -49,12 +49,12 @@ def _check_ffprobe(): if not _has_ffprobe: ffprobe_warning = ( - "\n⚠️ WARNING: FFprobe not found in PATH. Video duration detection will be limited.\n" + "\n⚠️ WARNING: FFprobe not found in PATH. Video processing will not work.\n" " FFprobe is typically installed with FFmpeg: https://ffmpeg.org/download.html\n" ) print(ffprobe_warning, file=sys.stderr) warnings.warn( - "FFprobe not found in PATH. Video duration detection will be limited.", + "FFprobe not found in PATH. Video processing will not work.", ImportWarning ) @@ -63,4 +63,4 @@ def _check_ffprobe(): select_best_n_frames, select_batched_frames, select_outlier_removal_frames -) \ No newline at end of file +) diff --git a/sharp_frames/focus_scoring.py b/sharp_frames/focus_scoring.py new file mode 100644 index 0000000..22993d3 --- /dev/null +++ b/sharp_frames/focus_scoring.py @@ -0,0 +1,46 @@ +"""Resolution-normalized, noise-resistant focus scoring.""" + +import cv2 +import numpy as np + + +ANALYSIS_LONG_EDGE = 512 +FOCUS_SCORE_METHOD = "normalized_laplacian_tenengrad_v1" +DENOISE_KERNEL = (5, 5) +DENOISE_SIGMA = 1.0 +LAPLACIAN_WEIGHT = 0.5 +TENENGRAD_WEIGHT = 0.5 + + +def normalize_analysis_image(image: np.ndarray) -> np.ndarray: + """Return a grayscale image at the canonical analysis resolution.""" + if image is None or image.size == 0: + raise ValueError("Focus analysis requires a non-empty image") + if image.ndim == 3: + image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + height, width = image.shape + scale = ANALYSIS_LONG_EDGE / max(height, width) + target_size = ( + max(1, round(width * scale)), + max(1, round(height * scale)), + ) + interpolation = cv2.INTER_AREA if scale < 1 else cv2.INTER_CUBIC + return cv2.resize(image, target_size, interpolation=interpolation) + + +def calculate_focus_score(image: np.ndarray) -> float: + """Combine Laplacian variance and Tenengrad after light denoising.""" + normalized = normalize_analysis_image(image) + denoised = cv2.GaussianBlur(normalized, DENOISE_KERNEL, DENOISE_SIGMA) + + laplacian_variance = cv2.Laplacian(denoised, cv2.CV_64F).var() + gradient_x = cv2.Sobel(denoised, cv2.CV_64F, 1, 0, ksize=3) + gradient_y = cv2.Sobel(denoised, cv2.CV_64F, 0, 1, ksize=3) + tenengrad = np.mean((gradient_x * gradient_x) + (gradient_y * gradient_y)) + + combined_log_score = ( + np.log1p(laplacian_variance) * LAPLACIAN_WEIGHT + + np.log1p(tenengrad) * TENENGRAD_WEIGHT + ) + return float(np.expm1(combined_log_score)) diff --git a/sharp_frames/image_output.py b/sharp_frames/image_output.py new file mode 100644 index 0000000..893f9ef --- /dev/null +++ b/sharp_frames/image_output.py @@ -0,0 +1,132 @@ +"""Shared image decoding, resizing, and encoding helpers.""" + +from pathlib import Path + +import cv2 +import numpy as np +from PIL import Image + + +class ImageOutputError(Exception): + """Raised when an image cannot be decoded or encoded.""" + + +def canonical_image_format(path: str) -> str: + """Return a canonical image format name for a path.""" + extension = Path(path).suffix.lower().lstrip(".") + aliases = {"jpeg": "jpg", "tif": "tiff"} + return aliases.get(extension, extension) + + +def image_needs_processing(src_path: str, dst_path: str, width: int) -> bool: + """Return whether an image needs resizing or format conversion.""" + return width > 0 or canonical_image_format(src_path) != canonical_image_format( + dst_path + ) + + +def resize_image(image: np.ndarray, width: int) -> np.ndarray: + """Resize an image to a width while preserving channels and aspect ratio.""" + height = int(image.shape[0] * (width / image.shape[1])) + if height % 2 != 0: + height += 1 + return cv2.resize(image, (width, height), interpolation=cv2.INTER_AREA) + + +def transcode_image(src_path: str, dst_path: str, width: int = 0) -> None: + """Decode, optionally resize, and encode an image without losing alpha.""" + image = cv2.imread(src_path, cv2.IMREAD_UNCHANGED) + if image is None: + raise ImageOutputError(f"Failed to read image: {src_path}") + image = _apply_exif_orientation(image, src_path) + + if width > 0: + image = resize_image(image, width) + + destination_format = canonical_image_format(dst_path) + image = _prepare_for_destination(image, destination_format) + + if not cv2.imwrite(dst_path, image): + raise ImageOutputError( + f"Failed to encode image as {Path(dst_path).suffix}" + ) + + +def _apply_exif_orientation( + image: np.ndarray, source_path: str +) -> np.ndarray: + """Apply EXIF orientation to decoded pixels before metadata is discarded.""" + try: + with Image.open(source_path) as source: + orientation = int(source.getexif().get(274, 1)) + except (OSError, TypeError, ValueError): + orientation = 1 + + if orientation == 2: + return cv2.flip(image, 1) + if orientation == 3: + return cv2.rotate(image, cv2.ROTATE_180) + if orientation == 4: + return cv2.flip(image, 0) + if orientation == 5: + return cv2.transpose(image) + if orientation == 6: + return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE) + if orientation == 7: + return cv2.flip(cv2.transpose(image), -1) + if orientation == 8: + return cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE) + return image + + +def _prepare_for_destination( + image: np.ndarray, destination_format: str +) -> np.ndarray: + """Convert channels and depth to values supported by the encoder.""" + if destination_format == "jpg": + return _to_uint8(_flatten_alpha_on_white(image)) + if destination_format == "png" and image.dtype.type not in {np.uint8, np.uint16}: + return _to_uint8(image) + if destination_format in {"bmp", "webp"} and image.dtype.type is not np.uint8: + return _to_uint8(image) + return image + + +def _to_uint8(image: np.ndarray) -> np.ndarray: + """Scale image data to the eight-bit range required by JPEG.""" + if image.dtype == np.uint8: + return image + if image.dtype == np.bool_: + return image.astype(np.uint8) * 255 + if np.issubdtype(image.dtype, np.integer): + info = np.iinfo(image.dtype) + scaled = ( + (image.astype(np.float32) - float(info.min)) + * (255.0 / float(info.max - info.min)) + ) + return np.clip(scaled, 0, 255).round().astype(np.uint8) + + finite = np.nan_to_num(image.astype(np.float32), nan=0.0) + if finite.size and finite.min() >= 0 and finite.max() <= 1: + finite = finite * 255.0 + return np.clip(finite, 0, 255).round().astype(np.uint8) + + +def _flatten_alpha_on_white(image: np.ndarray) -> np.ndarray: + """Composite alpha-bearing images on white for JPEG output.""" + if image.ndim != 3 or image.shape[2] not in {2, 4}: + return image + + color = image[..., :-1] + alpha = image[..., -1:] + if np.issubdtype(image.dtype, np.integer): + maximum = float(np.iinfo(image.dtype).max) + else: + maximum = 1.0 + + alpha_fraction = alpha.astype(np.float32) / maximum + composited = ( + color.astype(np.float32) * alpha_fraction + + maximum * (1.0 - alpha_fraction) + ) + return np.clip(composited, 0, maximum).astype(image.dtype) diff --git a/sharp_frames/processing/colorspace.py b/sharp_frames/processing/colorspace.py new file mode 100644 index 0000000..6e48cd4 --- /dev/null +++ b/sharp_frames/processing/colorspace.py @@ -0,0 +1,377 @@ +"""Color space detection and FFmpeg filter generation for proper color conversion. + +This module handles detection of video color spaces (especially iPhone HDR/wide gamut) +and generates appropriate FFmpeg filter chains to convert to sRGB/BT.709. +""" + +from dataclasses import dataclass +from typing import Optional, Dict, Any +from enum import Enum +import subprocess +import json +import os + + +__all__ = [ + 'VideoColorInfo', + 'ColorPrimaries', + 'TransferFunction', + 'ColorMatrix', + 'detect_color_space', + 'parse_color_info_from_stream', + 'build_colorspace_filter', + 'get_color_info_description', + 'is_zscale_available', +] + + +class ColorPrimaries(Enum): + """Video color primaries (ITU-T H.273).""" + BT709 = "bt709" # sRGB/Rec.709 + BT2020 = "bt2020" # Wide gamut (HDR) + DISPLAY_P3 = "smpte432" # Display P3 (Apple) + UNKNOWN = "unknown" + + +class TransferFunction(Enum): + """Transfer characteristics (gamma/EOTF).""" + BT709 = "bt709" # SDR + SRGB = "iec61966-2-1" # sRGB + PQ = "smpte2084" # HDR10/Dolby Vision + HLG = "arib-std-b67" # Hybrid Log-Gamma + UNKNOWN = "unknown" + + +class ColorMatrix(Enum): + """Color matrix coefficients.""" + BT709 = "bt709" + BT2020_NCL = "bt2020nc" # Non-constant luminance + BT2020_CL = "bt2020c" # Constant luminance + UNKNOWN = "unknown" + + +@dataclass +class VideoColorInfo: + """Detected color space information from video.""" + color_primaries: ColorPrimaries + transfer_function: TransferFunction + color_matrix: ColorMatrix + is_hdr: bool + max_luminance: Optional[float] = None + + @property + def needs_conversion(self) -> bool: + """Check if video needs color space conversion to sRGB/BT.709.""" + # Convert if not standard BT.709 SDR + if self.is_hdr: + return True + if self.color_primaries not in [ColorPrimaries.BT709, ColorPrimaries.UNKNOWN]: + return True + if self.transfer_function not in [TransferFunction.BT709, TransferFunction.SRGB, TransferFunction.UNKNOWN]: + return True + return False + + @property + def is_wide_gamut_sdr(self) -> bool: + """Check if this is wide gamut SDR (e.g., Display P3 without HDR).""" + return ( + self.color_primaries in [ColorPrimaries.DISPLAY_P3, ColorPrimaries.BT2020] and + not self.is_hdr + ) + + +def check_zscale_available() -> bool: + """Check if FFmpeg has zscale filter available (requires libzimg).""" + ffmpeg_executable = 'ffmpeg.exe' if os.name == 'nt' else 'ffmpeg' + try: + result = subprocess.run( + [ffmpeg_executable, '-filters'], + capture_output=True, + text=True, + timeout=10 + ) + return 'zscale' in result.stdout + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return False + + +# Cache the zscale availability check +_zscale_available: Optional[bool] = None + + +def is_zscale_available() -> bool: + """Check if zscale is available (cached).""" + global _zscale_available + if _zscale_available is None: + _zscale_available = check_zscale_available() + return _zscale_available + + +def detect_color_space(video_path: str) -> VideoColorInfo: + """ + Detect video color space using ffprobe. + + Args: + video_path: Path to the video file + + Returns: + VideoColorInfo with detected or assumed defaults + """ + ffprobe_executable = 'ffprobe.exe' if os.name == 'nt' else 'ffprobe' + + cmd = [ + ffprobe_executable, '-v', 'quiet', + '-print_format', 'json', + '-show_streams', '-select_streams', 'v:0', + os.path.normpath(video_path) + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + return _default_color_info() + + data = json.loads(result.stdout) + streams = data.get('streams', []) + if not streams: + return _default_color_info() + + stream = streams[0] + return parse_color_info_from_stream(stream) + + except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError, OSError): + return _default_color_info() + + +def parse_color_info_from_stream(stream: Dict[str, Any]) -> VideoColorInfo: + """Parse ffprobe stream data into VideoColorInfo. + + This can be used to extract color info from existing ffprobe data + without making an additional subprocess call. + + Args: + stream: A video stream dict from ffprobe JSON output + + Returns: + VideoColorInfo with parsed color space information + """ + # Extract color metadata + color_primaries_str = stream.get('color_primaries', 'unknown') + transfer_str = stream.get('color_transfer', 'unknown') + color_matrix_str = stream.get('color_space', 'unknown') + + # Map to enums + primaries = _map_color_primaries(color_primaries_str) + transfer = _map_transfer_function(transfer_str) + matrix = _map_color_matrix(color_matrix_str) + + # Determine if HDR based on transfer function + is_hdr = transfer in [TransferFunction.PQ, TransferFunction.HLG] + + # Try to get max luminance from side data (for HDR content) + max_luminance = None + side_data = stream.get('side_data_list', []) + for data in side_data: + if data.get('side_data_type') == 'Mastering display metadata': + max_lum_str = data.get('max_luminance', '') + if '/' in str(max_lum_str): + try: + num, denom = str(max_lum_str).split('/') + max_luminance = float(num) / float(denom) + except (ValueError, ZeroDivisionError): + pass + + return VideoColorInfo( + color_primaries=primaries, + transfer_function=transfer, + color_matrix=matrix, + is_hdr=is_hdr, + max_luminance=max_luminance + ) + + +def _map_color_primaries(value: str) -> ColorPrimaries: + """Map ffprobe color_primaries to enum.""" + if not value: + return ColorPrimaries.UNKNOWN + mapping = { + 'bt709': ColorPrimaries.BT709, + 'bt2020': ColorPrimaries.BT2020, + 'smpte432': ColorPrimaries.DISPLAY_P3, + } + return mapping.get(value.lower(), ColorPrimaries.UNKNOWN) + + +def _map_transfer_function(value: str) -> TransferFunction: + """Map ffprobe color_transfer to enum.""" + if not value: + return TransferFunction.UNKNOWN + mapping = { + 'bt709': TransferFunction.BT709, + 'iec61966-2-1': TransferFunction.SRGB, + 'smpte2084': TransferFunction.PQ, + 'arib-std-b67': TransferFunction.HLG, + } + return mapping.get(value.lower(), TransferFunction.UNKNOWN) + + +def _map_color_matrix(value: str) -> ColorMatrix: + """Map ffprobe color_space (matrix) to enum.""" + if not value: + return ColorMatrix.UNKNOWN + mapping = { + 'bt709': ColorMatrix.BT709, + 'bt2020nc': ColorMatrix.BT2020_NCL, + 'bt2020c': ColorMatrix.BT2020_CL, + } + return mapping.get(value.lower(), ColorMatrix.UNKNOWN) + + +def _default_color_info() -> VideoColorInfo: + """Return default color info (assume unknown - no conversion).""" + return VideoColorInfo( + color_primaries=ColorPrimaries.UNKNOWN, + transfer_function=TransferFunction.UNKNOWN, + color_matrix=ColorMatrix.UNKNOWN, + is_hdr=False + ) + + +def build_colorspace_filter( + color_info: VideoColorInfo, width: int = 0 +) -> Optional[str]: + """ + Build FFmpeg filter string for color space conversion to sRGB/BT.709. + + Args: + color_info: Detected color space information + width: Optional output width to apply inside HDR conversion + + Returns: + Filter string to add to -vf, or None if no conversion needed + """ + if color_info.color_matrix == ColorMatrix.BT2020_CL: + raise RuntimeError( + "BT.2020 constant-luminance video is not supported. " + "Convert the source to BT.2020 non-constant-luminance or BT.709 first." + ) + + if not color_info.needs_conversion: + return None + + if color_info.is_hdr: + return _build_hdr_to_sdr_filter(color_info, width) + if color_info.is_wide_gamut_sdr: + return _build_wide_gamut_to_srgb_filter(color_info) + return None + + +def _build_hdr_to_sdr_filter( + color_info: VideoColorInfo, width: int = 0 +) -> str: + """ + Build HDR to SDR tone mapping filter. + + Uses zscale for high-quality conversion if available, + otherwise falls back to basic colorspace filter. + """ + # Determine input transfer function for zscale + if color_info.transfer_function == TransferFunction.PQ: + transfer_in = "smpte2084" + elif color_info.transfer_function == TransferFunction.HLG: + transfer_in = "arib-std-b67" + else: + transfer_in = "smpte2084" # Default to PQ for unknown HDR + + # Determine input primaries + if color_info.color_primaries == ColorPrimaries.BT2020: + primaries_in = "bt2020" + elif color_info.color_primaries == ColorPrimaries.DISPLAY_P3: + primaries_in = "smpte432" + else: + primaries_in = "bt2020" # Default to BT.2020 for HDR + + # Determine input matrix + matrix_in = "bt2020nc" + resize_options = ( + f":w={width}:h=-2:f=lanczos" if width > 0 else "" + ) + + if is_zscale_available(): + # 4:2:0 requires even dimensions; preserve explicit odd widths with 4:4:4. + output_pixel_format = ( + "yuv444p" if width > 0 and width % 2 else "yuv420p" + ) + # Full HDR to SDR pipeline with tone mapping + # Explicitly specify input parameters for reliable conversion + filter_chain = ( + f"zscale=tin={transfer_in}:min={matrix_in}:pin={primaries_in}" + f"{resize_options}:t=linear:npl=100," # Resize and linearize + "format=gbrpf32le," # High precision intermediate + "zscale=p=bt709," # Convert primaries to BT.709 + "tonemap=hable:desat=0," # Hable tone mapping (filmic) + "zscale=t=bt709:m=bt709:r=tv," # Apply BT.709 transfer/matrix + f"format={output_pixel_format}" + ) + else: + raise RuntimeError( + "HDR-to-SDR conversion requires FFmpeg's zscale filter. " + "Install an FFmpeg build with libzimg support." + ) + + return filter_chain + + +def _build_wide_gamut_to_srgb_filter(color_info: VideoColorInfo) -> str: + """ + Build wide gamut SDR (Display P3/BT.2020) to sRGB/BT.709 filter. + """ + input_primaries = { + ColorPrimaries.DISPLAY_P3: "smpte432", + ColorPrimaries.BT2020: "bt2020", + }.get(color_info.color_primaries, "bt709") + input_transfer = { + TransferFunction.SRGB: "iec61966-2-1", + TransferFunction.BT709: "bt709", + }.get(color_info.transfer_function, "bt709") + default_matrix = ( + "bt2020nc" + if color_info.color_primaries == ColorPrimaries.BT2020 + else "bt709" + ) + input_matrix = { + ColorMatrix.BT709: "bt709", + ColorMatrix.BT2020_NCL: "bt2020nc", + ColorMatrix.BT2020_CL: "bt2020c", + }.get(color_info.color_matrix, default_matrix) + + return ( + "colorspace=all=bt709:" + f"ispace={input_matrix}:" + f"iprimaries={input_primaries}:" + f"itrc={input_transfer}:fast=0" + ) + + +def get_color_info_description(color_info: VideoColorInfo) -> str: + """Get a human-readable description of the color space.""" + parts = [] + + if color_info.is_hdr: + if color_info.transfer_function == TransferFunction.PQ: + parts.append("HDR10/Dolby Vision (PQ)") + elif color_info.transfer_function == TransferFunction.HLG: + parts.append("HLG HDR") + else: + parts.append("HDR") + else: + parts.append("SDR") + + if color_info.color_primaries == ColorPrimaries.DISPLAY_P3: + parts.append("Display P3") + elif color_info.color_primaries == ColorPrimaries.BT2020: + parts.append("BT.2020") + elif color_info.color_primaries == ColorPrimaries.BT709: + parts.append("BT.709") + + return " / ".join(parts) if parts else "Unknown" diff --git a/sharp_frames/processing/frame_extractor.py b/sharp_frames/processing/frame_extractor.py index 3e82dd7..3862afd 100644 --- a/sharp_frames/processing/frame_extractor.py +++ b/sharp_frames/processing/frame_extractor.py @@ -6,11 +6,23 @@ import tempfile import subprocess import shutil -from typing import List, Dict, Any, Optional +import json +import threading +import time +from collections import deque +from typing import List, Dict, Any, Optional, TYPE_CHECKING from pathlib import Path from ..models.frame_data import FrameData, ExtractionResult -from ..video_utils import get_video_files_in_directory +from ..video_utils import ( + SUPPORTED_IMAGE_EXTENSIONS, + get_ffmpeg_installation_hint, + get_video_files_in_directory, + natural_path_key, +) + +if TYPE_CHECKING: + from .colorspace import VideoColorInfo class FrameExtractor: @@ -18,10 +30,27 @@ class FrameExtractor: def __init__(self): """Initialize FrameExtractor.""" - self.SUPPORTED_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff'} + self.SUPPORTED_IMAGE_EXTENSIONS = SUPPORTED_IMAGE_EXTENSIONS + self.progress_callback = None + self._cancellation_event = threading.Event() + self._process_lock = threading.Lock() + self._active_process: Optional[subprocess.Popen] = None + + def cancel_processing(self) -> None: + """Request cancellation without blocking the caller.""" + self._cancellation_event.set() + + def reset_cancellation(self) -> None: + """Prepare this extractor for an explicitly requested new operation.""" + with self._process_lock: + if self._active_process is not None and self._active_process.poll() is None: + raise RuntimeError("Cannot reset cancellation while extraction is active") + self._cancellation_event.clear() def extract_frames(self, config: Dict[str, Any], progress_callback=None) -> ExtractionResult: """Extract/load frames based on input type.""" + if self._cancellation_event.is_set(): + raise RuntimeError("Frame extraction cancelled") input_type = config.get('input_type') self.progress_callback = progress_callback @@ -67,21 +96,32 @@ def _load_images(self, image_directory: str) -> ExtractionResult: def _extract_video_frames(self, config: Dict[str, Any]) -> ExtractionResult: """Extract frames from single video file.""" + from .colorspace import get_color_info_description + video_path = config['input_path'] fps = config.get('fps', 10) output_format = config.get('output_format', 'jpg') width = config.get('width', 0) - + # Create temporary directory for extraction temp_dir = self._create_temp_directory() - + try: - # Extract video info + # Extract video info (includes color space data) video_info = self._get_video_info(video_path) duration = self._extract_duration_from_info(video_info) - - # Perform FFmpeg extraction - if not self._run_ffmpeg_extraction(video_path, temp_dir, fps, output_format, width, duration): + + # Extract color space from existing video_info (no extra ffprobe call) + color_info = self._extract_color_info_from_video_info(video_info) + if color_info.needs_conversion: + color_desc = get_color_info_description(color_info) + self._notify_progress( + "extraction", 0, 0, + f"Detected {color_desc}, will convert to sRGB", + ) + + # Perform FFmpeg extraction with color space handling + if not self._run_ffmpeg_extraction(video_path, temp_dir, fps, output_format, width, duration, color_info): raise RuntimeError("Frame extraction failed") # Get extracted frame paths @@ -124,7 +164,6 @@ def _extract_video_directory_frames(self, config: Dict[str, Any]) -> ExtractionR video_directory = config['input_path'] fps = config.get('fps', 10) output_format = config.get('output_format', 'jpg') - width = config.get('width', 0) # Get video files video_files = get_video_files_in_directory(video_directory) @@ -145,8 +184,9 @@ def _extract_video_directory_frames(self, config: Dict[str, Any]) -> ExtractionR successful_videos = 0 for video_index, video_path in enumerate(video_files): - video_name = f"video_{video_index + 1:03d}" - + if self._cancellation_event.is_set(): + raise RuntimeError("Frame extraction cancelled") + try: # Extract frames from this video video_frames = self._extract_single_video( @@ -162,8 +202,14 @@ def _extract_video_directory_frames(self, config: Dict[str, Any]) -> ExtractionR successful_videos += 1 except Exception as e: + if self._cancellation_event.is_set(): + raise RuntimeError("Frame extraction cancelled") from e print(f"Warning: Failed to extract frames from {os.path.basename(video_path)}: {e}") continue + + if not all_frames: + shutil.rmtree(temp_dir, ignore_errors=True) + temp_dir = None metadata = { 'video_count': len(video_files), @@ -192,17 +238,20 @@ def _extract_single_video(self, video_path: str, video_index: int, temp_dir: str video_name = f"video_{video_index + 1:03d}" video_temp_dir = os.path.join(temp_dir, video_name) os.makedirs(video_temp_dir, exist_ok=True) - + fps = config.get('fps', 10) output_format = config.get('output_format', 'jpg') width = config.get('width', 0) - - # Get video info + + # Get video info (includes color space data) video_info = self._get_video_info(video_path) duration = self._extract_duration_from_info(video_info) - - # Extract frames - if not self._run_ffmpeg_extraction(video_path, video_temp_dir, fps, output_format, width, duration): + + # Extract color space from existing video_info (no extra ffprobe call) + color_info = self._extract_color_info_from_video_info(video_info) + + # Extract frames with color space handling + if not self._run_ffmpeg_extraction(video_path, video_temp_dir, fps, output_format, width, duration, color_info): raise RuntimeError(f"Failed to extract frames from {video_path}") # Get extracted frame files @@ -256,8 +305,7 @@ def _filter_image_files(self, directory: str) -> List[str]: except OSError as e: raise FileNotFoundError(f"Could not scan directory {directory}: {e}") - # Sort for consistent ordering - image_files.sort() + image_files.sort(key=natural_path_key) return image_files def _get_image_output_name(self, image_path: str) -> str: @@ -275,21 +323,25 @@ def _get_video_info(self, video_path: str) -> Dict[str, Any]: ffprobe_executable = 'ffprobe.exe' if os.name == 'nt' else 'ffprobe' cmd = [ - ffprobe_executable, '-v', 'quiet', '-print_format', 'json', + ffprobe_executable, '-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', os.path.normpath(video_path) ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - raise RuntimeError(f"FFprobe failed: {result.stderr}") + detail = result.stderr.strip() or ( + f"exit code {result.returncode} while probing {video_path}" + ) + raise RuntimeError(f"FFprobe failed: {detail}") - import json return json.loads(result.stdout) except subprocess.TimeoutExpired: raise RuntimeError(f"FFprobe timeout for {video_path}") except FileNotFoundError: - raise RuntimeError("FFprobe not found. Please install FFmpeg.") + raise RuntimeError( + f"FFprobe not found. {get_ffmpeg_installation_hint()}" + ) except json.JSONDecodeError as e: raise RuntimeError(f"Invalid JSON from FFprobe: {e}") @@ -298,30 +350,76 @@ def _extract_duration_from_info(self, video_info: Dict[str, Any]) -> Optional[fl try: format_info = video_info.get('format', {}) duration_str = format_info.get('duration') - return float(duration_str) if duration_str else None + if duration_str: + return float(duration_str) + for stream in video_info.get('streams', []): + if stream.get('codec_type') == 'video' and stream.get('duration'): + return float(stream['duration']) + return None except (ValueError, TypeError): return None + + def _extract_color_info_from_video_info(self, video_info: Dict[str, Any]) -> 'VideoColorInfo': + """Extract color space info from existing video_info (avoids extra ffprobe call).""" + from .colorspace import parse_color_info_from_stream, VideoColorInfo, ColorPrimaries, TransferFunction, ColorMatrix + + streams = video_info.get('streams', []) + # Find the first video stream + for stream in streams: + if stream.get('codec_type') == 'video': + return parse_color_info_from_stream(stream) + + # No video stream found, return default + return VideoColorInfo( + color_primaries=ColorPrimaries.UNKNOWN, + transfer_function=TransferFunction.UNKNOWN, + color_matrix=ColorMatrix.UNKNOWN, + is_hdr=False + ) - def _run_ffmpeg_extraction(self, video_path: str, output_dir: str, fps: int, - output_format: str, width: int, duration: Optional[float] = None) -> bool: - """Run FFmpeg to extract frames from video with progress monitoring.""" + @staticmethod + def _build_video_filters( + fps: int, + width: int, + color_info: Optional['VideoColorInfo'] = None, + ) -> List[str]: + """Build an efficient, color-correct FFmpeg filter sequence.""" + from .colorspace import build_colorspace_filter + + filters = [f"fps={fps}"] + resize_consumed = False + + if color_info is not None: + colorspace_filter = build_colorspace_filter( + color_info, + width=width if color_info.is_hdr else 0, + ) + if colorspace_filter: + filters.append(colorspace_filter) + resize_consumed = color_info.is_hdr and width > 0 + + if width > 0 and not resize_consumed: + filters.append(f"scale={width}:-1:flags=lanczos") + + return filters + + def _run_ffmpeg_extraction(self, video_path: str, output_dir: str, fps: int, + output_format: str, width: int, duration: Optional[float] = None, + color_info: Optional['VideoColorInfo'] = None) -> bool: + """Run FFmpeg to extract frames from video with progress monitoring and color space conversion.""" output_pattern = os.path.join(output_dir, f"frame_%05d.{output_format}") - - # Build video filters - vf_filters = [f"fps={fps}"] - if width > 0: - # Use lanczos scaling for high-quality downsampling - vf_filters.append(f"scale={width}:-1:flags=lanczos") - - vf_string = ",".join(vf_filters) + vf_string = ",".join(self._build_video_filters(fps, width, color_info)) # Use proper executable name based on platform ffmpeg_executable = 'ffmpeg.exe' if os.name == 'nt' else 'ffmpeg' # Build FFmpeg command - normalize paths for Windows cmd = [ - ffmpeg_executable, - '-hwaccel', 'auto', # Auto-detect and use available hardware acceleration + ffmpeg_executable, + '-hide_banner', + '-loglevel', 'error', + '-nostats', + '-progress', 'pipe:1', '-i', os.path.normpath(video_path), '-vf', vf_string, ] @@ -335,139 +433,185 @@ def _run_ffmpeg_extraction(self, video_path: str, output_dir: str, fps: int, os.path.normpath(output_pattern) ]) + estimated_total = int(duration * fps) if duration and fps else 0 + self._report_extraction_started(fps, estimated_total) + timeout = max(300, int(duration * 10)) if duration else 3600 + stderr_lines = deque(maxlen=500) + process = None + progress_thread = None + stderr_thread = None + try: - # Estimate total frames if duration is available - estimated_total = 0 - if duration and fps: - estimated_total = int(duration * fps) - if self.progress_callback: - self.progress_callback("extraction", 0, estimated_total, f"Extracting frames at {fps}fps") - else: - if self.progress_callback: - self.progress_callback("extraction", 0, 0, "Extracting frames (unknown total)") - - # Set timeout based on duration if available - timeout = 3600 # 1 hour default - if duration: - # Rough estimation: 10 seconds per minute of video - timeout = max(300, int(duration * 10)) - - # Start FFmpeg process for progress monitoring - import threading - import time - - # Windows-specific process creation flags - creation_flags = 0 - if os.name == 'nt': - # On Windows, create new process group and hide console window - creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW - - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - creationflags=creation_flags if os.name == 'nt' else 0 + process = self._start_ffmpeg_process(cmd) + with self._process_lock: + self._active_process = process + + progress_thread = threading.Thread( + target=self._read_ffmpeg_progress, + args=(process.stdout, estimated_total), + name="sharp-frames-ffmpeg-progress", + daemon=True, ) - - # Monitor progress by counting extracted files - last_file_count = 0 - start_time = time.time() - last_progress_time = start_time # Track when we last saw progress - stall_timeout = 10.0 # Consider stalled if no progress for 10 seconds - - while process.poll() is None: - current_time = time.time() - elapsed = current_time - start_time - - # Check for timeout - if elapsed > timeout: - process.kill() - raise subprocess.TimeoutExpired(cmd, timeout) - - # Count extracted files - if os.path.exists(output_dir): - try: - file_count = len([f for f in os.listdir(output_dir) if f.startswith('frame_')]) - - if file_count > last_file_count: - # We have new frames, update progress time - last_progress_time = current_time - - if self.progress_callback: - if estimated_total: - self.progress_callback("extraction", file_count, estimated_total, - f"Extracted {file_count}/{estimated_total} frames") - else: - self.progress_callback("extraction", file_count, 0, f"Extracted {file_count} frames") - last_file_count = file_count - - # Check if we've reached the expected total - if estimated_total > 0 and file_count >= estimated_total: - process.terminate() - break - - # Check for stalled extraction (prevents hanging on Windows) - if current_time - last_progress_time > stall_timeout: - process.terminate() - break - except Exception: - pass # Continue if file counting fails - - time.sleep(0.1) # Small delay to avoid excessive polling - - # Ensure process is terminated and wait for it to finish - if process.poll() is None: - process.terminate() - try: - # Wait up to 5 seconds for graceful termination - stdout, stderr = process.communicate(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - stdout, stderr = process.communicate() - else: - # Process already finished, get results - stdout, stderr = process.communicate() - - # Check if extraction was successful based on frame count - # Don't rely on return code since we may have terminated the process - if os.path.exists(output_dir): - actual_frame_count = len([f for f in os.listdir(output_dir) if f.startswith('frame_')]) - - # Consider successful if we have frames and either: - # 1. We reached the expected count, or - # 2. We have a reasonable number of frames (at least 10) - success = actual_frame_count > 0 and ( - (estimated_total > 0 and actual_frame_count >= estimated_total * 0.95) or # Allow 5% tolerance - actual_frame_count >= 10 - ) - - if not success: - print(f"FFmpeg extraction incomplete: {actual_frame_count} frames") - if stderr: - print(f"FFmpeg stderr: {stderr}") - return False - else: + stderr_thread = threading.Thread( + target=self._drain_stream, + args=(process.stderr, stderr_lines), + name="sharp-frames-ffmpeg-stderr", + daemon=True, + ) + progress_thread.start() + stderr_thread.start() + + return_code = self._wait_for_process(process, cmd, timeout) + progress_thread.join(timeout=2) + stderr_thread.join(timeout=2) + stderr = "".join(stderr_lines).strip() + + if self._cancellation_event.is_set(): + print(f"FFmpeg extraction cancelled for {video_path}") return False - - # Final progress update - if os.path.exists(output_dir): - final_frame_count = len([f for f in os.listdir(output_dir) if f.startswith('frame_')]) - if self.progress_callback: - self.progress_callback("extraction", final_frame_count, final_frame_count, - f"Extraction complete: {final_frame_count} frames") - + if return_code != 0: + print(f"FFmpeg extraction failed with exit code {return_code}") + if stderr: + print(f"FFmpeg stderr: {stderr}") + return False + + final_frame_count = len(self._get_extracted_frame_files(output_dir)) + if final_frame_count == 0: + print("FFmpeg completed successfully but produced no frames") + return False + + self._notify_progress( + "extraction", + final_frame_count, + final_frame_count, + f"Extraction complete: {final_frame_count} frames", + ) return True - - except subprocess.TimeoutExpired as e: + + except subprocess.TimeoutExpired: + if process is not None: + self._terminate_process(process) print(f"FFmpeg extraction timeout for {video_path}") return False - except FileNotFoundError as e: - print("FFmpeg not found. Please install FFmpeg.") + except FileNotFoundError: + print(f"FFmpeg not found. {get_ffmpeg_installation_hint()}") return False except Exception as e: + if process is not None: + self._terminate_process(process) print(f"Unexpected error during frame extraction: {e}") return False + finally: + if process is not None and process.poll() is None: + self._terminate_process(process) + if progress_thread is not None: + progress_thread.join(timeout=2) + if stderr_thread is not None: + stderr_thread.join(timeout=2) + with self._process_lock: + if self._active_process is process: + self._active_process = None + + def _start_ffmpeg_process(self, command: List[str]) -> subprocess.Popen: + """Start FFmpeg with pipes that are drained by dedicated reader threads.""" + creation_flags = 0 + if os.name == 'nt': + creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW + return subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + creationflags=creation_flags, + ) + + def _wait_for_process(self, process: subprocess.Popen, command: List[str], timeout: int) -> int: + """Wait for FFmpeg while honoring cancellation and a whole-job timeout.""" + deadline = time.monotonic() + timeout + while True: + if self._cancellation_event.is_set(): + self._terminate_process(process) + return process.returncode if process.returncode is not None else -1 + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(command, timeout) + try: + return process.wait(timeout=min(0.2, remaining)) + except subprocess.TimeoutExpired: + continue + + @staticmethod + def _terminate_process(process: subprocess.Popen) -> None: + """Terminate a child process, escalating to kill if it does not exit.""" + if process.poll() is not None: + return + try: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + # A pathological platform/process wrapper may not report + # exit even after a force kill. Cleanup remains best-effort + # and must not mask the original cancellation or timeout. + return + except (OSError, ProcessLookupError): + # Another thread may have completed termination concurrently. + return + + def _read_ffmpeg_progress(self, stream, estimated_total: int) -> None: + """Read FFmpeg's machine-readable progress stream until EOF.""" + if stream is None: + return + for line in iter(stream.readline, ''): + key, separator, value = line.strip().partition('=') + if separator and key == 'frame': + try: + frame_count = int(value) + except ValueError: + continue + total = estimated_total or 0 + description = ( + f"Extracted {frame_count}/{total} frames" + if total + else f"Extracted {frame_count} frames" + ) + self._notify_progress( + "extraction", frame_count, total, description + ) + + @staticmethod + def _drain_stream(stream, destination) -> None: + """Continuously drain a text stream to prevent pipe backpressure.""" + if stream is None: + return + for line in iter(stream.readline, ''): + destination.append(line) + + def _report_extraction_started(self, fps: int, estimated_total: int) -> None: + if estimated_total: + message = f"Extracting frames at {fps}fps" + else: + message = "Extracting frames (unknown total)" + self._notify_progress("extraction", 0, estimated_total, message) + + def _notify_progress( + self, phase: str, current: int, total: int, description: str + ) -> None: + """Report progress without allowing a UI callback to break extraction.""" + callback = self.progress_callback + if callback is None: + return + try: + callback(phase, current, total, description) + except Exception as exc: + print(f"Warning: Progress callback failed and was disabled: {exc}") + if self.progress_callback is callback: + self.progress_callback = None def _get_extracted_frame_files(self, temp_dir: str) -> List[str]: """Get list of extracted frame files from temp directory.""" @@ -480,10 +624,9 @@ def _get_extracted_frame_files(self, temp_dir: str) -> List[str]: except OSError: return [] - # Sort by filename to maintain frame order - frame_files.sort() + frame_files.sort(key=natural_path_key) return frame_files def _get_supported_image_extensions(self) -> set: """Get supported image file extensions.""" - return self.SUPPORTED_IMAGE_EXTENSIONS \ No newline at end of file + return self.SUPPORTED_IMAGE_EXTENSIONS diff --git a/sharp_frames/processing/frame_saver.py b/sharp_frames/processing/frame_saver.py index 3332fef..e709d5b 100644 --- a/sharp_frames/processing/frame_saver.py +++ b/sharp_frames/processing/frame_saver.py @@ -8,19 +8,16 @@ from contextlib import nullcontext from typing import Any, Dict, List, Optional -import cv2 from tqdm import tqdm from ..models.frame_data import FrameData - - -class ImageProcessingError(Exception): - """Custom exception for image processing errors.""" - pass +from ..image_output import image_needs_processing, transcode_image class FrameSaver: """Handles saving selected frames to disk with proper naming conventions.""" + + METADATA_FILENAME = "selected_metadata.json" def __init__(self, show_progress: bool = True): """Initialize FrameSaver. @@ -60,10 +57,11 @@ def save_frames(self, selected_frames: List[FrameData], config: Dict[str, Any]) return True output_dir = config['output_dir'] - output_format = config.get('output_format', self.DEFAULT_OUTPUT_FORMAT) + output_format = self._normalize_output_format( + config.get('output_format', self.DEFAULT_OUTPUT_FORMAT) + ) width = config.get('width', 0) input_type = config.get('input_type', 'video') - input_path = config.get('input_path', '') force_overwrite = config.get('force_overwrite', False) @@ -77,23 +75,30 @@ def save_frames(self, selected_frames: List[FrameData], config: Dict[str, Any]) print(f"Error creating output directory: {e}") return False - # Check for overwrite if needed - if not force_overwrite: - self._check_output_directory_overwrite(output_dir) + output_filenames = self._plan_output_filenames( + selected_frames, input_type, output_format + ) + + # Check only paths this run intends to create. Unrelated files (for + # example Finder's .DS_Store) are not overwrite risks. + if not force_overwrite and not self._check_output_directory_overwrite( + output_dir, output_filenames + ): + return False success_count = 0 metadata_list = [] + saved_frames = [] with self._get_progress_bar(len(selected_frames), "Saving frames") as progress_bar: - for i, frame in enumerate(selected_frames): + for frame, filename in zip(selected_frames, output_filenames): try: - # Determine output filename based on input type and frame data - filename = self._get_output_filename(frame, i, input_type, output_format) dst_path = os.path.join(output_dir, filename) # Save the frame (with optional resizing) if self._save_single_frame(frame.path, dst_path, width, input_type): success_count += 1 + saved_frames.append(frame) # Add to metadata metadata_list.append({ @@ -114,10 +119,57 @@ def save_frames(self, selected_frames: List[FrameData], config: Dict[str, Any]) self._update_progress(progress_bar) # Save metadata - self._save_metadata(output_dir, metadata_list, config, selected_frames) + metadata_config = config.copy() + metadata_config['output_format'] = output_format + metadata_saved = self._save_metadata( + output_dir, + metadata_list, + metadata_config, + selected_frames, + saved_frames, + ) print(f"Successfully saved {success_count}/{len(selected_frames)} frames to {output_dir}") - return success_count == len(selected_frames) + return success_count == len(selected_frames) and metadata_saved + + def _normalize_output_format(self, output_format: str) -> str: + """Return a normalized filename extension without a leading dot.""" + normalized = str(output_format).strip().lower().lstrip('.') + return normalized or self.DEFAULT_OUTPUT_FORMAT + + def _plan_output_filenames( + self, + selected_frames: List[FrameData], + input_type: str, + output_format: str, + ) -> List[str]: + """Build output names and disambiguate colliding image-directory stems.""" + filenames = [ + self._get_output_filename(frame, index, input_type, output_format) + for index, frame in enumerate(selected_frames) + ] + if input_type != 'directory': + return filenames + + return self._make_filenames_unique(filenames) + + def _make_filenames_unique(self, filenames: List[str]) -> List[str]: + """Add stable numeric suffixes to names that collide case-insensitively.""" + used_names = set() + unique_names = [] + + for filename in filenames: + stem, extension = os.path.splitext(filename) + candidate = filename + suffix = 2 + while candidate.casefold() in used_names: + candidate = f"{stem}_{suffix}{extension}" + suffix += 1 + + used_names.add(candidate.casefold()) + unique_names.append(candidate) + + return unique_names def _get_output_filename(self, frame: FrameData, sequence_index: int, input_type: str, output_format: str) -> str: @@ -176,33 +228,28 @@ def _save_single_frame(self, src_path: str, dst_path: str, width: int, input_typ src_path = os.path.normpath(src_path) dst_path = os.path.normpath(dst_path) - # Handle resizing for directory input (video frames already resized during extraction) - if width > 0 and input_type == 'directory': - # Load and resize image - img = cv2.imread(src_path) - if img is None: - raise ImageProcessingError(f"Failed to read image for resizing: {src_path}") - - # Calculate height to maintain aspect ratio - height = int(img.shape[0] * (width / img.shape[1])) - # Ensure height is even for video compatibility - if height % 2 != 0: - height += 1 - - # Resize and save - resized_img = cv2.resize(img, (width, height), interpolation=cv2.INTER_AREA) - return cv2.imwrite(dst_path, resized_img) - else: - # Normal copy for non-resized frames or video frames - shutil.copy2(src_path, dst_path) + # Video frames already have the requested encoding and size from extraction. + if input_type == 'directory' and image_needs_processing( + src_path, dst_path, width + ): + transcode_image(src_path, dst_path, width) return True + + shutil.copy2(src_path, dst_path) + return True except Exception as e: print(f"Error saving {src_path} to {dst_path}: {e}") return False - - def _save_metadata(self, output_dir: str, metadata_list: List[Dict[str, Any]], - config: Dict[str, Any], selected_frames: List[FrameData]): + + def _save_metadata( + self, + output_dir: str, + metadata_list: List[Dict[str, Any]], + config: Dict[str, Any], + selected_frames: List[FrameData], + saved_frames: List[FrameData], + ) -> bool: """ Save metadata about selected frames. @@ -212,7 +259,7 @@ def _save_metadata(self, output_dir: str, metadata_list: List[Dict[str, Any]], config: Configuration dictionary selected_frames: List of selected frames """ - metadata_path = os.path.join(output_dir, "selected_metadata.json") + metadata_path = os.path.join(output_dir, self.METADATA_FILENAME) try: # Create comprehensive metadata @@ -221,10 +268,12 @@ def _save_metadata(self, output_dir: str, metadata_list: List[Dict[str, Any]], "input_type": config.get('input_type', 'video'), "output_directory": output_dir, "total_selected": len(selected_frames), + "total_saved": len(saved_frames), + "total_failed": len(selected_frames) - len(saved_frames), "output_format": config.get('output_format', self.DEFAULT_OUTPUT_FORMAT), "resize_width": config.get('width', 0) if config.get('width', 0) > 0 else None, "processing_timestamp": self._get_current_timestamp(), - "selection_summary": self._create_selection_summary(selected_frames), + "selection_summary": self._create_selection_summary(saved_frames), "selected_frames": metadata_list } @@ -233,13 +282,15 @@ def _save_metadata(self, output_dir: str, metadata_list: List[Dict[str, Any]], metadata['selection_method'] = config['selection_method'] metadata.update(self._get_method_params_for_metadata(config)) - with open(metadata_path, 'w') as f: + with open(metadata_path, 'w', encoding='utf-8') as f: json.dump(metadata, f, indent=2) print(f"Metadata saved to {metadata_path}") + return True except Exception as e: print(f"Warning: Failed to save metadata: {e}") + return False def _create_selection_summary(self, selected_frames: List[FrameData]) -> Dict[str, Any]: """Create summary statistics for selected frames.""" @@ -291,7 +342,7 @@ def _get_method_params_for_metadata(self, config: Dict[str, Any]) -> Dict[str, A }) elif selection_method == 'outlier_removal': params.update({ - "outlier_sensitivity": config.get('outlier_sensitivity', 50), + "outlier_sensitivity": config.get('outlier_sensitivity', 60), "outlier_window_size": config.get('outlier_window_size', 15) }) @@ -302,38 +353,62 @@ def _get_current_timestamp(self) -> str: import datetime return datetime.datetime.now().isoformat() - def _check_output_directory_overwrite(self, output_dir: str): - """Check if output directory contains files and warn user.""" + def _check_output_directory_overwrite( + self, output_dir: str, output_filenames: List[str] + ) -> bool: + """Return whether planned outputs can be written without overwriting.""" if not os.path.exists(output_dir): - return # Directory doesn't exist, no overwrite concern + return True try: - existing_files = [f for f in os.listdir(output_dir) - if os.path.isfile(os.path.join(output_dir, f))] + planned_names = { + filename.casefold() + for filename in [*output_filenames, self.METADATA_FILENAME] + } + conflicting_entries = [ + name + for name in os.listdir(output_dir) + if name.casefold() in planned_names + ] - if existing_files: + if conflicting_entries: # In non-interactive mode (TUI/thread context), just warn without prompting if not self.show_progress: # show_progress=False indicates non-interactive context - print(f"Warning: Output directory '{output_dir}' contains {len(existing_files)} files that may be overwritten.") - return + conflicting_paths = [ + os.path.join(output_dir, filename) + for filename in sorted(conflicting_entries) + ] + print( + f"Error: {len(conflicting_entries)} planned output path(s) " + f"already exist in '{output_dir}'. No files were written. " + "Choose another directory or enable force overwrite." + ) + print("Conflicting files: " + ", ".join(conflicting_paths)) + return False # Interactive mode - prompt user - print(f"Warning: Output directory '{output_dir}' contains {len(existing_files)} files.") - print("Existing files may be overwritten.") + print( + f"Warning: {len(conflicting_entries)} planned output path(s) " + f"already exist in '{output_dir}'." + ) + print("Those existing outputs may be overwritten.") while True: response = input("Continue anyway? (y/n): ").strip().lower() if response in ['y', 'yes']: print("Continuing with existing output directory...") - break + return True elif response in ['n', 'no']: print("Operation cancelled. Please specify a different output directory or use --force-overwrite.") raise SystemExit(1) else: print("Please enter 'y' or 'n'.") + + return True except OSError as e: - print(f"Warning: Could not check output directory: {e}") + print(f"Error: Could not safely inspect output directory: {e}") + return False def cleanup_temp_directory(self, temp_dir: Optional[str]): """Clean up temporary directory if it exists.""" @@ -342,4 +417,4 @@ def cleanup_temp_directory(self, temp_dir: Optional[str]): shutil.rmtree(temp_dir) print(f"Cleaned up temporary directory: {temp_dir}") except OSError as e: - print(f"Warning: Could not clean up temporary directory {temp_dir}: {e}") \ No newline at end of file + print(f"Warning: Could not clean up temporary directory {temp_dir}: {e}") diff --git a/sharp_frames/processing/frame_selector.py b/sharp_frames/processing/frame_selector.py index 09b881d..d3b8d9e 100644 --- a/sharp_frames/processing/frame_selector.py +++ b/sharp_frames/processing/frame_selector.py @@ -1,391 +1,113 @@ -""" -Frame selection component for Sharp Frames. -""" +"""Frame selection component for Sharp Frames.""" -from typing import List, Dict, Any, Set, Tuple -from tqdm import tqdm -from contextlib import nullcontext +from typing import Any, Dict, List from ..models.frame_data import FrameData +from ..selection_methods import ( + OUTLIER_DEFAULT_SENSITIVITY, + OUTLIER_DEFAULT_WINDOW_SIZE, + select_batched_frames_core, + select_best_n_frames_core, + select_outlier_removal_frames_core, +) class FrameSelector: - """Handles frame selection logic with preview capabilities.""" - + """Apply the canonical selection algorithms to :class:`FrameData` objects.""" + + BEST_N_SHARPNESS_WEIGHT = 0.7 + BEST_N_DISTRIBUTION_WEIGHT = 0.3 + OUTLIER_MIN_NEIGHBORS = 3 + OUTLIER_THRESHOLD_DIVISOR = 4.0 + def __init__(self, show_progress: bool = True): - """ - Initialize FrameSelector. - - Args: - show_progress: Whether to show progress bars (should be False when running in threads) - """ self.show_progress = show_progress - - # Constants for best-n selection - self.BEST_N_SHARPNESS_WEIGHT = 0.7 - self.BEST_N_DISTRIBUTION_WEIGHT = 0.3 - - # Constants for outlier removal - self.OUTLIER_MIN_NEIGHBORS = 3 - self.OUTLIER_THRESHOLD_DIVISOR = 4 - - # Constants for outlier removal preview calculation - # Maps sensitivity ranges to estimated removal rates - self.OUTLIER_REMOVAL_RATES = { - 90: 0.40, # Very aggressive removal - 80: 0.30, # Aggressive removal - 70: 0.25, # High removal - 60: 0.20, # Moderate-high removal - 50: 0.15, # Moderate removal - 40: 0.10, # Low-moderate removal - 30: 0.07, # Low removal - 20: 0.05, # Very low removal - 10: 0.03, # Minimal removal - 0: 0.01 # Barely any removal - } - - def _get_progress_bar(self, total: int, desc: str): - """Get a progress bar or null context based on show_progress setting.""" - if self.show_progress: - return tqdm(total=total, desc=desc, leave=False) - else: - return nullcontext() - - def _update_progress(self, progress_bar, n=1): - """Update progress bar if it exists.""" - if progress_bar and hasattr(progress_bar, 'update'): - progress_bar.update(n) - - def preview_selection(self, frames: List[FrameData], method: str, **params) -> int: - """ - Calculate selection count without modifying data. - Optimized for speed to enable real-time preview updates. - - Args: - frames: List of FrameData objects - method: Selection method ('best_n', 'batched', 'outlier_removal') - **params: Method-specific parameters - - Returns: - Number of frames that would be selected - """ - if not frames: - return 0 - - if method == 'best_n': - n = params.get('n', 300) - min_buffer = params.get('min_buffer', 3) - - # With min_buffer constraint, we might not be able to select all n frames - # Quick estimate: if frames are perfectly spaced, we need n + (n-1)*min_buffer positions - if min_buffer > 0: - required_positions = n + (n - 1) * min_buffer - if required_positions > len(frames): - # Calculate maximum possible frames with this buffer - max_possible = (len(frames) + min_buffer) // (min_buffer + 1) - return min(n, max_possible) - - return min(n, len(frames)) - - elif method == 'batched': - batch_size = params.get('batch_size', 5) - batch_buffer = params.get('batch_buffer', 2) - - # Calculate how many batches we can create with the given parameters - step_size = batch_size + batch_buffer - if step_size <= 0: - return 0 - return (len(frames) + step_size - 1) // step_size if step_size > 0 else 0 - - elif method == 'outlier_removal': - outlier_sensitivity = params.get('outlier_sensitivity', 50) - outlier_window_size = params.get('outlier_window_size', 15) - - # Fast preview calculation for outlier removal - return self._preview_outlier_removal_count(frames, outlier_sensitivity, outlier_window_size) - - else: - raise ValueError(f"Unsupported selection method: {method}") - - def select_frames(self, frames: List[FrameData], method: str, **params) -> List[FrameData]: - """ - Apply selection method and return selected frames. - - Args: - frames: List of FrameData objects - method: Selection method ('best_n', 'batched', 'outlier_removal') - **params: Method-specific parameters - - Returns: - List of selected FrameData objects - """ + + def preview_selection(self, frames: List[FrameData], method: str, **params: Any) -> int: + """Return the exact count produced by selection with the supplied parameters.""" + return len(self.select_frames(frames, method, **params)) + + def select_frames( + self, frames: List[FrameData], method: str, **params: Any + ) -> List[FrameData]: + """Apply a public selection method while preserving original frame objects.""" if not frames: return [] - - if method == 'best_n': - n = params.get('n', 300) - min_buffer = params.get('min_buffer', 3) - result = self._select_best_n_frames(frames, n, min_buffer) - - elif method == 'batched': - batch_size = params.get('batch_size', 5) - batch_buffer = params.get('batch_buffer', 2) - result = self._select_batched_frames(frames, batch_size, batch_buffer) - - elif method == 'outlier_removal': - outlier_sensitivity = params.get('outlier_sensitivity', 50) - outlier_window_size = params.get('outlier_window_size', 15) - result = self._select_outlier_removal_frames(frames, outlier_sensitivity, outlier_window_size) - - else: - raise ValueError(f"Unsupported selection method: {method}") - - return result - - def _select_best_n_frames(self, frames: List[FrameData], n: int, min_buffer: int) -> List[FrameData]: - """Select the best N frames based on weighted scoring combining sharpness and distribution.""" - if n <= 0: - return [] - - n = min(n, len(frames)) - min_gap = min_buffer - - # Convert to dict format for compatibility with existing algorithm - frames_dict = self._frames_to_dict(frames) - - # Calculate weighted scores for all frames - weighted_scores = self._calculate_weighted_scores(frames_dict) - - # Apply the best-n selection algorithm - selected_frames = [] - selected_indices = set() - - with self._get_progress_bar(n, "Selecting frames (best-n)") as progress_bar: - # Initial segment selection - selected_frames, selected_indices = self._select_initial_segments( - frames_dict, weighted_scores, n, min_gap, progress_bar + + if method in {"best_n", "best-n"}: + return self._select_best_n_frames( + frames, + params.get("n", 300), + params.get("min_buffer", 3), ) - - # Fill remaining slots if needed - if len(selected_frames) < n: - self._fill_remaining_slots( - frames_dict, weighted_scores, n, min_gap, selected_frames, - selected_indices, progress_bar - ) - - # Convert back to FrameData objects - selected_frame_data = [] - for frame_dict in selected_frames: - original_frame = frames[frame_dict['index']] - selected_frame_data.append(original_frame) - - # Sort by index to maintain frame order - return sorted(selected_frame_data, key=lambda f: f.index) - - def _select_batched_frames(self, frames: List[FrameData], batch_size: int, batch_buffer: int) -> List[FrameData]: - """Select frames using legacy batched method - process consecutive groups with gaps.""" - if batch_size <= 0 or not frames: - return [] - - selected_frames = [] - step_size = batch_size + batch_buffer - total_batches = (len(frames) + step_size - 1) // step_size if step_size > 0 else 0 - - with self._get_progress_bar(total_batches, "Selecting batches") as progress_bar: - i = 0 - while i < len(frames): - # Take a batch of consecutive frames - batch = frames[i:i + batch_size] - if not batch: - break - - # Select frame with highest sharpness in this batch - best_frame = max(batch, key=lambda f: f.sharpness_score) - selected_frames.append(best_frame) - - # Move to next batch position (skip batch_buffer frames) - i += step_size - self._update_progress(progress_bar) - - return selected_frames - - def _select_outlier_removal_frames(self, frames: List[FrameData], outlier_sensitivity: int, outlier_window_size: int) -> List[FrameData]: - """Select frames by removing outliers using legacy parameters.""" - if not frames: - return [] - - selected_frames = [] - all_scores = [frame.sharpness_score for frame in frames] - - if not all_scores: - return frames - - global_min = min(all_scores) - global_max = max(all_scores) - global_range = global_max - global_min - - with self._get_progress_bar(len(frames), "Filtering outliers") as progress_bar: - for i, frame in enumerate(frames): - is_outlier = self._is_frame_outlier( - i, frames, global_range, outlier_sensitivity, outlier_window_size - ) - - if not is_outlier: - selected_frames.append(frame) - - self._update_progress(progress_bar) - - return selected_frames - - def _preview_outlier_removal_count(self, frames: List[FrameData], outlier_sensitivity: int, outlier_window_size: int) -> int: - """Fast preview calculation for outlier removal using legacy parameters.""" - if not frames: - return 0 - - # Find the appropriate removal rate based on sensitivity - removal_rate = self._get_removal_rate_for_sensitivity(outlier_sensitivity) - - estimated_selected = int(len(frames) * (1.0 - removal_rate)) - return max(1, estimated_selected) # Always select at least 1 frame - - def _get_removal_rate_for_sensitivity(self, sensitivity: int) -> float: - """Get removal rate for a given sensitivity value using constant lookup.""" - # Find the appropriate threshold - for threshold in sorted(self.OUTLIER_REMOVAL_RATES.keys(), reverse=True): - if sensitivity >= threshold: - return self.OUTLIER_REMOVAL_RATES[threshold] - - # Fallback to minimal removal - return self.OUTLIER_REMOVAL_RATES[0] - - def _calculate_weighted_scores(self, frames_dict: List[Dict[str, Any]]) -> List[float]: - """Calculate weighted scores combining sharpness and distribution.""" - weighted_scores = [] - - for frame in frames_dict: - sharpness_score = frame.get('sharpnessScore', 0) - - # For initial calculation, use only sharpness score - # Distribution scoring is applied during selection process - weighted_score = sharpness_score * self.BEST_N_SHARPNESS_WEIGHT - weighted_scores.append(weighted_score) - - return weighted_scores - - def _select_initial_segments(self, frames_dict: List[Dict[str, Any]], weighted_scores: List[float], - n: int, min_gap: int, progress_bar) -> Tuple[List[Dict[str, Any]], Set[int]]: - """First pass: Select best frames from initial segments.""" - selected_frames = [] - selected_indices = set() - - if n <= 0 or not frames_dict: - return selected_frames, selected_indices - - # Sort frames by weighted score (highest first) - sorted_frames = sorted( - zip(frames_dict, weighted_scores), - key=lambda x: x[1], - reverse=True + if method == "batched": + return self._select_batched_frames( + frames, + params.get("batch_size", 5), + params.get("batch_buffer", 2), + ) + if method in {"outlier_removal", "outlier-removal"}: + return self._select_outlier_removal_frames( + frames, + params.get("outlier_sensitivity", OUTLIER_DEFAULT_SENSITIVITY), + params.get("outlier_window_size", OUTLIER_DEFAULT_WINDOW_SIZE), + ) + raise ValueError(f"Unsupported selection method: {method}") + + def _select_best_n_frames( + self, frames: List[FrameData], n: int, min_buffer: int + ) -> List[FrameData]: + frame_dicts = self._frames_to_dict(frames) + selected = select_best_n_frames_core( + frame_dicts, + n, + min_buffer, + self.BEST_N_SHARPNESS_WEIGHT, + self.BEST_N_DISTRIBUTION_WEIGHT, + ) + return self._restore_frame_objects(frames, selected) + + def _select_batched_frames( + self, frames: List[FrameData], batch_size: int, batch_buffer: int + ) -> List[FrameData]: + selected = select_batched_frames_core( + self._frames_to_dict(frames), batch_size, batch_buffer + ) + return self._restore_frame_objects(frames, selected) + + def _select_outlier_removal_frames( + self, + frames: List[FrameData], + outlier_sensitivity: int, + outlier_window_size: int, + ) -> List[FrameData]: + assessed = select_outlier_removal_frames_core( + self._frames_to_dict(frames), + outlier_window_size, + outlier_sensitivity, + self.OUTLIER_MIN_NEIGHBORS, + self.OUTLIER_THRESHOLD_DIVISOR, ) - - # Select frames ensuring minimum gap - for frame_data, _ in sorted_frames: - if len(selected_frames) >= n: - break - - frame_index = frame_data['index'] - - if self._is_gap_sufficient(frame_index, selected_indices, min_gap): - selected_frames.append(frame_data) - selected_indices.add(frame_index) - self._update_progress(progress_bar) - - return selected_frames, selected_indices - - def _fill_remaining_slots(self, frames_dict: List[Dict[str, Any]], weighted_scores: List[float], - n: int, min_gap: int, selected_frames: List[Dict[str, Any]], - selected_indices: Set[int], progress_bar: tqdm): - """Fill remaining slots with best available frames.""" - remaining_needed = n - len(selected_frames) - - if remaining_needed <= 0: - return - - # Create list of unselected frames with their scores - unselected_frames = [] - for i, frame_data in enumerate(frames_dict): - if frame_data['index'] not in selected_indices: - unselected_frames.append((frame_data, weighted_scores[i])) - - # Sort by score - unselected_frames.sort(key=lambda x: x[1], reverse=True) - - # Try to fill remaining slots - for frame_data, _ in unselected_frames: - if len(selected_frames) >= n: - break - - frame_index = frame_data['index'] - - # For remaining slots, use more lenient gap requirement - relaxed_gap = max(1, min_gap // 2) - if self._is_gap_sufficient(frame_index, selected_indices, relaxed_gap): - selected_frames.append(frame_data) - selected_indices.add(frame_index) - self._update_progress(progress_bar) - - def _is_gap_sufficient(self, frame_index: int, selected_indices: Set[int], min_gap: int) -> bool: - """Check if a frame index maintains the minimum gap with selected indices.""" - if not selected_indices: - return True - - return all(abs(frame_index - selected_index) >= min_gap - for selected_index in selected_indices) - - def _is_frame_outlier(self, index: int, frames: List[FrameData], - global_range: float, sensitivity: int, window_size: int) -> bool: - """Determine if a frame is an outlier based on its neighbors.""" - if sensitivity <= 0: - return False - if sensitivity >= 100: - return True - - # Ensure window size is odd for symmetry - actual_window_size = window_size if window_size % 2 != 0 else window_size + 1 - half_window = actual_window_size // 2 - - window_start = max(0, index - half_window) - window_end = min(len(frames), index + half_window + 1) - - neighbor_indices = list(range(window_start, index)) + list(range(index + 1, window_end)) - - if len(neighbor_indices) < self.OUTLIER_MIN_NEIGHBORS: - return False - - neighbor_scores = [frames[idx].sharpness_score for idx in neighbor_indices] - window_avg = sum(neighbor_scores) / len(neighbor_scores) - current_score = frames[index].sharpness_score - - if global_range == 0: - return False - - absolute_diff = window_avg - current_score - percent_of_range = (absolute_diff / global_range) * 100 if global_range > 0 else 0 - - # Calculate threshold based on sensitivity - threshold = (100 - sensitivity) / self.OUTLIER_THRESHOLD_DIVISOR - - return current_score < window_avg and percent_of_range > threshold - - def _frames_to_dict(self, frames: List[FrameData]) -> List[Dict[str, Any]]: - """Convert FrameData objects to dictionary format for algorithm compatibility.""" - frames_dict = [] - for frame in frames: - frame_dict = { - 'id': f"frame_{frame.index:05d}", - 'path': frame.path, - 'index': frame.index, - 'sharpnessScore': frame.sharpness_score + selected = [frame for frame in assessed if frame["selected"]] + return self._restore_frame_objects(frames, selected) + + @staticmethod + def _restore_frame_objects( + frames: List[FrameData], selected: List[Dict[str, Any]] + ) -> List[FrameData]: + return [frames[frame["_selection_position"]] for frame in selected] + + @staticmethod + def _frames_to_dict(frames: List[FrameData]) -> List[Dict[str, Any]]: + return [ + { + "id": f"frame_{frame.index:05d}", + "path": frame.path, + "index": frame.index, + "sharpnessScore": frame.sharpness_score, + "source_video": frame.source_video, + "source_index": frame.source_index, + "_selection_position": position, } - frames_dict.append(frame_dict) - return frames_dict \ No newline at end of file + for position, frame in enumerate(frames) + ] diff --git a/sharp_frames/processing/minimal_progress.py b/sharp_frames/processing/minimal_progress.py index daf4243..6cd7df0 100644 --- a/sharp_frames/processing/minimal_progress.py +++ b/sharp_frames/processing/minimal_progress.py @@ -44,8 +44,17 @@ def _check_output_dir_overwrite(self): existing_files = os.listdir(self.output_dir) if existing_files and not self.force_overwrite: - print(f"Warning: Output directory '{self.output_dir}' already contains {len(existing_files)} files.") - print("Files may be overwritten. Use force overwrite option in configuration to suppress this warning.") + conflicting_paths = [ + os.path.join(self.output_dir, filename) + for filename in sorted(existing_files) + ] + print( + f"Error: Output directory '{self.output_dir}' already contains " + f"{len(existing_files)} file(s). No files were written." + ) + print("Conflicting files: " + ", ".join(conflicting_paths)) + print("Choose an empty directory or enable force overwrite.") + raise SystemExit(1) elif existing_files and self.force_overwrite: print(f"Output directory '{self.output_dir}' contains {len(existing_files)} files. Overwriting without confirmation (force overwrite enabled).") @@ -60,11 +69,12 @@ def _check_dependencies(self, check_ffmpeg: bool = True) -> bool: print("Error: FFmpeg is not installed or not in PATH. Required for video input.") return False - # Check for FFprobe (warning only if missing) + # FFprobe supplies required stream and color metadata. try: subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, text=True) except (subprocess.SubprocessError, FileNotFoundError): - print("Warning: FFprobe is not installed or not in PATH. Video duration cannot be determined.") + print("Error: FFprobe is not installed or not in PATH. Required for video input.") + return False # Always check for OpenCV (needed for sharpness calculation) try: @@ -79,19 +89,18 @@ def _check_dependencies(self, check_ffmpeg: bool = True) -> bool: return True - def _build_ffmpeg_command(self, output_pattern: str) -> List[str]: - """Build the FFmpeg command for frame extraction.""" - # Build the video filters string - vf_filters = [] - vf_filters.append(f"fps={self.fps}") - - # Add scaling filter if width is specified - if self.width > 0: - vf_filters.append(f"scale={self.width}:-2") # -2 maintains aspect ratio and ensures even height - - # Join all filters with commas - vf_string = ",".join(vf_filters) - + def _build_ffmpeg_command(self, output_pattern: str, color_info=None) -> List[str]: + """Build the FFmpeg command for frame extraction with color space conversion.""" + from .frame_extractor import FrameExtractor + + vf_string = ",".join( + FrameExtractor._build_video_filters( + self.fps, + self.width, + color_info, + ) + ) + command = [ "ffmpeg", "-i", self.input_path, @@ -102,7 +111,7 @@ def _build_ffmpeg_command(self, output_pattern: str) -> List[str]: "-loglevel", "warning", # Show errors and warnings output_pattern ] - + return command def _estimate_total_frames(self, duration: Optional[float]) -> Optional[int]: @@ -234,12 +243,31 @@ def _finalize_extraction(self, process: subprocess.Popen, stderr_queue: queue.Qu return True - def _extract_frames(self, duration: float = None) -> bool: - """Override to add real-time progress tracking to frame extraction with proper cleanup.""" + def _extract_frames(self, duration: float = None, color_info=None) -> bool: + """Extract through the canonical FFmpeg runner with UI-safe progress.""" + from .frame_extractor import FrameExtractor + + extractor = FrameExtractor() + extractor.progress_callback = self._update_progress + success = extractor._run_ffmpeg_extraction( + self.input_path, + self.temp_dir, + self.fps, + self.output_format, + self.width, + duration, + color_info, + ) + if not success: + raise RuntimeError(f"Frame extraction failed for {self.input_path}") + return True + + def _extract_frames_legacy(self, duration: float = None, color_info=None) -> bool: + """Deprecated pre-canonical FFmpeg runner retained for compatibility tests.""" output_pattern = os.path.join(self.temp_dir, f"frame_%05d.{self.output_format}") - + # Build command and estimate progress - command = self._build_ffmpeg_command(output_pattern) + command = self._build_ffmpeg_command(output_pattern, color_info) estimated_total_frames = self._estimate_total_frames(duration) # Print the FFmpeg command for debugging @@ -433,19 +461,22 @@ def update(self, increment=1): return sorted(selected_frames, key=lambda f: f["index"]) - def _save_frames(self, selected_frames: List[Dict[str, Any]], progress_bar=None) -> None: + def _save_frames(self, selected_frames: List[Dict[str, Any]], progress_bar=None) -> bool: """Override to add progress tracking and avoid tqdm.""" if not selected_frames: - return + return True total_frames = len(selected_frames) self._update_progress("saving", 0, total_frames, "Starting to save frames") # Call parent method but pass None to avoid tqdm - super()._save_frames(selected_frames, None) - - # Final progress update - self._update_progress("saving", total_frames, total_frames, f"Saved {total_frames} frames") + success = super()._save_frames(selected_frames, None) + if success: + self._update_progress( + "saving", total_frames, total_frames, + f"Saved {total_frames} frames", + ) + return success def run(self): """Override the parent run() method to avoid tqdm usage in UI context.""" @@ -480,7 +511,9 @@ def run(self): # Save Phase - Use our UI-safe save method instead of tqdm print(f"Saving {len(selected_frames_data)} selected frames/images...") - self._save_frames(selected_frames_data, None) + if not self._save_frames(selected_frames_data, None): + print("One or more selected items could not be saved.") + return False print(f"Successfully processed. Selected items saved to: {self.output_dir}") self._update_progress("complete", 1, 1, "Processing complete") @@ -502,4 +535,4 @@ def run(self): shutil.rmtree(self.temp_dir) print(f"Cleaned up temporary directory: {self.temp_dir}") except Exception as e: - print(f"Warning: Could not clean up temporary directory: {str(e)}") \ No newline at end of file + print(f"Warning: Could not clean up temporary directory: {str(e)}") diff --git a/sharp_frames/processing/sharpness_analyzer.py b/sharp_frames/processing/sharpness_analyzer.py index e9045e9..1a7d094 100644 --- a/sharp_frames/processing/sharpness_analyzer.py +++ b/sharp_frames/processing/sharpness_analyzer.py @@ -6,12 +6,16 @@ import concurrent.futures import os import sys -import time import threading from multiprocessing import cpu_count from typing import List, Callable, Optional from tqdm import tqdm +from ..focus_scoring import ( + ANALYSIS_LONG_EDGE, + FOCUS_SCORE_METHOD, + calculate_focus_score, +) from ..models.frame_data import FrameData, ExtractionResult @@ -21,7 +25,7 @@ class ImageProcessingError(Exception): class SharpnessAnalyzer: - """Calculates sharpness scores for frames using parallel processing.""" + """Calculates normalized focus scores for frames using parallel processing.""" def __init__(self, max_workers: Optional[int] = None): """ @@ -45,6 +49,10 @@ def __init__(self, max_workers: Optional[int] = None): def cancel_processing(self): """Cancel ongoing sharpness analysis.""" self._cancellation_event.set() + + def reset_cancellation(self): + """Prepare the analyzer for an explicitly requested new operation.""" + self._cancellation_event.clear() def calculate_sharpness(self, extraction_result: ExtractionResult, progress_callback=None) -> ExtractionResult: """ @@ -77,23 +85,40 @@ def calculate_sharpness(self, extraction_result: ExtractionResult, progress_call progress_callback=callback ) - # Update frame data with sharpness scores + # Exclude failed reads rather than treating them as valid zero-score frames. updated_frames = [] + unreadable_paths = [] for frame, score in zip(extraction_result.frames, sharpness_scores): + if score is None: + unreadable_paths.append(frame.path) + continue updated_frame = self._update_frame_with_sharpness(frame, score) updated_frames.append(updated_frame) + + if not updated_frames and not self._cancellation_event.is_set(): + raise ImageProcessingError("No readable images were available for analysis") + + metadata = dict(extraction_result.metadata) + metadata["sharpness_analysis"] = { + "method": FOCUS_SCORE_METHOD, + "analysis_long_edge": ANALYSIS_LONG_EDGE, + "input_count": len(extraction_result.frames), + "analyzed_count": len(updated_frames), + "unreadable_count": len(unreadable_paths), + "unreadable_paths": unreadable_paths, + } # Create updated extraction result return ExtractionResult( frames=updated_frames, - metadata=extraction_result.metadata, + metadata=metadata, temp_dir=extraction_result.temp_dir, input_type=extraction_result.input_type ) def _calculate_sharpness_parallel(self, frame_paths: List[str], progress_callback: Optional[Callable] = None, - chunk_size: int = 100) -> List[float]: + chunk_size: int = 100) -> List[Optional[float]]: """ Calculate sharpness scores for multiple frames using parallel processing. @@ -103,7 +128,7 @@ def _calculate_sharpness_parallel(self, frame_paths: List[str], chunk_size: Size of processing chunks for memory efficiency Returns: - List of sharpness scores corresponding to frame_paths + Scores corresponding to ``frame_paths``; failed reads are ``None`` """ scores = [] total_frames = len(frame_paths) @@ -127,9 +152,9 @@ def chunk_progress_callback(phase, chunk_completed, chunk_total, description): return scores def _process_chunk_parallel(self, frame_paths: List[str], - progress_callback: Optional[Callable] = None) -> List[float]: + progress_callback: Optional[Callable] = None) -> List[Optional[float]]: """Process a chunk of frames in parallel with Windows-specific error handling.""" - scores = [0.0] * len(frame_paths) # Initialize with default scores + scores: List[Optional[float]] = [None] * len(frame_paths) num_workers = min(self.max_workers, len(frame_paths)) if frame_paths else 1 # Track progress across the chunk @@ -182,10 +207,10 @@ def _process_chunk_parallel(self, frame_paths: List[str], except concurrent.futures.TimeoutError: print(f"Warning: Timeout processing {path}") - scores[idx] = 0.0 + scores[idx] = None except Exception as e: print(f"Warning: Failed to process {path}: {e}") - scores[idx] = 0.0 # Use default score for failed frames + scores[idx] = None # Update progress after each frame with timeout protection completed_count += 1 @@ -210,13 +235,12 @@ def _process_chunk_parallel(self, frame_paths: List[str], if self._is_windows and len(frame_paths) <= 10: print("Attempting fallback to sequential processing on Windows...") return self._process_chunk_sequential(frame_paths, progress_callback) - # Return zeros for all frames on complete failure - return [0.0] * len(frame_paths) + return [None] * len(frame_paths) return scores def _process_chunk_sequential(self, frame_paths: List[str], - progress_callback: Optional[Callable] = None) -> List[float]: + progress_callback: Optional[Callable] = None) -> List[Optional[float]]: """Fallback sequential processing for Windows when threading fails.""" scores = [] total_frames = len(frame_paths) @@ -231,7 +255,7 @@ def _process_chunk_sequential(self, frame_paths: List[str], scores.append(score) except Exception as e: print(f"Warning: Failed to process {path}: {e}") - scores.append(0.0) + scores.append(None) # Update progress if progress_callback: @@ -241,21 +265,21 @@ def _process_chunk_sequential(self, frame_paths: List[str], except Exception as e: print(f"Warning: Progress callback failed: {e}") - # Pad with zeros if processing was cancelled early + # Unprocessed entries remain explicit failures. while len(scores) < total_frames: - scores.append(0.0) + scores.append(None) return scores def _calculate_single_frame_sharpness(self, frame_path: str) -> float: """ - Calculate sharpness score for a single frame using Laplacian variance. + Calculate a normalized Laplacian/Tenengrad focus score for one frame. Args: frame_path: Path to the frame image file Returns: - Sharpness score (Laplacian variance) + Resolution-normalized focus score Raises: ImageProcessingError: If frame processing fails @@ -270,12 +294,7 @@ def _calculate_single_frame_sharpness(self, frame_path: str) -> float: if img_gray is None: raise ImageProcessingError(f"Failed to read image: {frame_path}") - # Calculate Laplacian variance on resized image for efficiency - height, width = img_gray.shape - img_half = cv2.resize(img_gray, (width // 2, height // 2), interpolation=cv2.INTER_AREA) - - score = self._calculate_laplacian_variance(img_half) - return float(score) + return calculate_focus_score(img_gray) except cv2.error as e: raise ImageProcessingError(f"OpenCV error processing {frame_path}: {str(e)}") from e @@ -339,4 +358,4 @@ def progress_callback(increment: int, total: int): def _progress_callback(self, current: int, total: int): """Default progress callback - can be overridden for custom progress tracking.""" - pass \ No newline at end of file + pass diff --git a/sharp_frames/processing/tui_processor.py b/sharp_frames/processing/tui_processor.py index 32ccb0e..d7f14e4 100644 --- a/sharp_frames/processing/tui_processor.py +++ b/sharp_frames/processing/tui_processor.py @@ -3,7 +3,6 @@ """ import shutil -import os from typing import Dict, Any, Optional from ..models.frame_data import ExtractionResult @@ -28,6 +27,8 @@ def __init__(self): def cancel_processing(self): """Cancel ongoing processing operations.""" self._cancelled = True + if hasattr(self.extractor, 'cancel_processing'): + self.extractor.cancel_processing() # Cancel sharpness analyzer if hasattr(self.analyzer, 'cancel_processing'): self.analyzer.cancel_processing() @@ -64,17 +65,21 @@ def extract_and_analyze(self, config: Dict[str, Any], progress_callback=None) -> # Extract frames print("Extracting frames...") extraction_result = self.extractor.extract_frames(config, progress_callback) + # Own the temporary directory as soon as extraction returns. This keeps + # cancellation/error cleanup reliable before analysis completes. + self.current_result = extraction_result # Check for cancellation after extraction if self._cancelled: print("Processing cancelled after frame extraction") - if extraction_result.temp_dir: - self.cleanup_temp_directory() + self.cleanup_temp_directory() + self.current_result = None return ExtractionResult(frames=[], metadata={}, temp_dir=None, input_type=config.get('input_type', 'unknown')) if not extraction_result.frames: print("No frames were extracted.") - self.current_result = extraction_result + self.cleanup_temp_directory() + self.current_result = None return extraction_result print(f"Extracted {len(extraction_result.frames)} frames") @@ -89,8 +94,8 @@ def extract_and_analyze(self, config: Dict[str, Any], progress_callback=None) -> # Check for cancellation before sharpness analysis if self._cancelled: print("Processing cancelled before sharpness analysis") - if extraction_result.temp_dir: - self.cleanup_temp_directory() + self.cleanup_temp_directory() + self.current_result = None return ExtractionResult(frames=[], metadata={}, temp_dir=None, input_type=config.get('input_type', 'unknown')) # Calculate sharpness scores @@ -100,9 +105,17 @@ def extract_and_analyze(self, config: Dict[str, Any], progress_callback=None) -> except Exception as e: print(f"Error during sharpness analysis: {e}") # Clean up and re-raise - if extraction_result.temp_dir: - self.cleanup_temp_directory() + self.cleanup_temp_directory() raise e + + if self._cancelled: + print("Processing cancelled during sharpness analysis") + self.cleanup_temp_directory() + self.current_result = None + return ExtractionResult( + frames=[], metadata={}, temp_dir=None, + input_type=config.get('input_type', 'unknown'), + ) print(f"Analysis complete. Average sharpness: {analyzed_result.average_sharpness:.2f}") print(f"Sharpness range: {analyzed_result.sharpness_range[0]:.2f} - {analyzed_result.sharpness_range[1]:.2f}") @@ -114,8 +127,13 @@ def extract_and_analyze(self, config: Dict[str, Any], progress_callback=None) -> except Exception as e: print(f"Error during extraction and analysis: {e}") # Clean up any temp directories on failure - if hasattr(self, 'current_result') and self.current_result and self.current_result.temp_dir: - self.cleanup_temp_directory() + self.cleanup_temp_directory() + self.current_result = None + if self._cancelled: + return ExtractionResult( + frames=[], metadata={}, temp_dir=None, + input_type=config.get('input_type', 'unknown'), + ) raise e def preview_selection(self, method: str, **params) -> int: @@ -248,6 +266,11 @@ def reset_current_result(self): # Clean up temp directory before reset self.cleanup_temp_directory() self.current_result = None + self._cancelled = False + if hasattr(self.extractor, 'reset_cancellation'): + self.extractor.reset_cancellation() + if hasattr(self.analyzer, 'reset_cancellation'): + self.analyzer.reset_cancellation() def has_current_result(self) -> bool: """Check if there is a current extraction result.""" @@ -316,15 +339,15 @@ def validate_selection_parameters(self, method: str, **params) -> tuple[bool, st return False, "Parameter 'batch_buffer' must be a non-negative integer" elif method == 'outlier_removal': - outlier_sensitivity = params.get('outlier_sensitivity', 50) + outlier_sensitivity = params.get('outlier_sensitivity', 60) if not isinstance(outlier_sensitivity, int) or not (0 <= outlier_sensitivity <= 100): return False, "Parameter 'outlier_sensitivity' must be an integer between 0 and 100" outlier_window_size = params.get('outlier_window_size', 15) - if not isinstance(outlier_window_size, int) or outlier_window_size <= 0: - return False, "Parameter 'outlier_window_size' must be a positive integer" + if not isinstance(outlier_window_size, int) or outlier_window_size < 5: + return False, "Parameter 'outlier_window_size' must be at least 5" else: return False, f"Unknown selection method: {method}" - return True, "" \ No newline at end of file + return True, "" diff --git a/sharp_frames/selection_methods.py b/sharp_frames/selection_methods.py index 8f22520..64bc004 100644 --- a/sharp_frames/selection_methods.py +++ b/sharp_frames/selection_methods.py @@ -1,251 +1,364 @@ -# selection_methods.py +"""Canonical frame-selection algorithms shared by the CLI and TUI.""" + +from collections import OrderedDict +from math import expm1, log1p +from statistics import median +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple -from typing import List, Dict, Any, Tuple, Set from tqdm import tqdm -# === Helper Functions === - -def _is_gap_sufficient(frame_index: int, selected_indices: Set[int], min_gap: int) -> bool: - """Check if a frame index maintains the minimum gap with selected indices.""" - if not selected_indices: - return True # No selected indices yet, any gap is sufficient - return all(abs(frame_index - selected_index) >= min_gap for selected_index in selected_indices) - -def _calculate_distribution_score(frame_index: int, total_frames: int, - selected_indices: Set[int], min_gap: int, - distribution_weight: float) -> float: - """Calculate a score representing how well a frame is distributed among others.""" - # Calculate distance from nearest selected frame (capped by min_gap) - nearest_selected_distance = min_gap # Default to max distance if no selected indices yet - if selected_indices: # Avoid calculation if set is empty - nearest_selected_distance = min(abs(frame_index - sel_idx) for sel_idx in selected_indices) - - # Normalize distance score: 1 if gap >= min_gap, 0 if gap == 0 - distance_score = min(1.0, nearest_selected_distance / min_gap) if min_gap > 0 else 1.0 - - # Calculate ideal position score - num_selected_or_one = max(len(selected_indices), 1) - if total_frames <= 0 or num_selected_or_one <= 0: - position_score = 1.0 # Avoid division by zero if total_frames or num_selected is 0 - else: - segment_size = total_frames / num_selected_or_one - if segment_size <= 0: - position_score = 1.0 # Avoid division by zero if segment_size is 0 - else: - ideal_position = round(frame_index / segment_size) * segment_size - dist_from_ideal = abs(frame_index - ideal_position) - # Normalize: 1 at ideal pos, 0 at midpoint between ideal positions (segment_size / 2 away) - position_score = max(0.0, 1.0 - (dist_from_ideal / (segment_size / 2))) if segment_size > 0 else 1.0 - - # Combine scores using weights (sharpness weight is implicitly 1.0 - distribution_weight) - return (distance_score * distribution_weight) + (position_score * (1.0 - distribution_weight)) - -# === Best-N Selection === - -def _select_initial_segments(frames: List[Dict[str, Any]], n: int, min_gap: int, - progress_bar: tqdm) -> Tuple[List[Dict[str, Any]], Set[int]]: - """First pass of best-n: Select best frame from initial segments.""" - selected_frames = [] - selected_indices = set() - if n <= 0 or not frames: return selected_frames, selected_indices # Handle edge cases - - # Prevent division by zero if n is zero - if n == 0: return selected_frames, selected_indices - - segment_size = max(1, len(frames) // n) - num_segments = (len(frames) + segment_size - 1) // segment_size # Ensure all frames are covered - - for i in range(num_segments): - if len(selected_frames) >= n: - break - segment_start = i * segment_size - segment_end = min(segment_start + segment_size, len(frames)) - segment = frames[segment_start:segment_end] +Frame = Dict[str, Any] +PositionedFrame = Tuple[int, Frame] - if not segment: continue # Skip empty segments +OUTLIER_DEFAULT_WINDOW_SIZE = 15 +OUTLIER_DEFAULT_SENSITIVITY = 60 +OUTLIER_MIN_WINDOW_SIZE = 5 +OUTLIER_MIN_RELATIVE_DROP = 0.005 +OUTLIER_MAX_RELATIVE_DROP = 0.08 +OUTLIER_MIN_ROBUST_THRESHOLD = 1.5 - # Find valid frames in the segment respecting the minimum gap - valid_frames = [ - frame for frame in segment - if _is_gap_sufficient(frame["index"], selected_indices, min_gap) - ] - if valid_frames: - best_frame = max(valid_frames, key=lambda f: f.get("sharpnessScore", 0)) - selected_frames.append(best_frame) - selected_indices.add(best_frame["index"]) - progress_bar.update(1) +def _source_key(frame: Frame) -> Optional[str]: + """Return a stable source identifier, if the frame belongs to a video.""" + return frame.get("source_video") or frame.get("sourceVideo") - return selected_frames, selected_indices -def _fill_remaining_slots(frames: List[Dict[str, Any]], n: int, min_gap: int, - selected_frames: List[Dict[str, Any]], selected_indices: Set[int], - progress_bar: tqdm, sharpness_weight: float, distribution_weight: float): - """Second pass of best-n: Fill remaining slots using composite score.""" - current_selected_indices = set(selected_indices) # Use the indices passed in +def _group_by_source(frames: Sequence[Frame]) -> List[List[PositionedFrame]]: + """Group video frames without allowing selection windows to cross sources.""" + groups: "OrderedDict[Optional[str], List[PositionedFrame]]" = OrderedDict() + for position, frame in enumerate(frames): + groups.setdefault(_source_key(frame), []).append((position, frame)) + return list(groups.values()) - while len(selected_frames) < n: - best_candidate = None - best_composite_score = -1 - potential_candidates = [f for f in frames if f["index"] not in current_selected_indices] +def _selection_capacity(frame_count: int, min_buffer: int) -> int: + """Maximum selections when ``min_buffer`` frames must remain between picks.""" + if frame_count <= 0: + return 0 + minimum_distance = max(0, min_buffer) + 1 + return ((frame_count - 1) // minimum_distance) + 1 - if not potential_candidates: - break # No more frames left to consider - for frame in potential_candidates: - frame_index = frame["index"] - - if not _is_gap_sufficient(frame_index, current_selected_indices, min_gap): +def _allocate_fair_quotas(capacities: Sequence[int], requested: int) -> List[int]: + """Allocate picks round-robin so no source can monopolize Best-N.""" + quotas = [0] * len(capacities) + remaining = min(max(0, requested), sum(capacities)) + while remaining: + allocated_this_round = False + for group_index, capacity in enumerate(capacities): + if remaining == 0: + break + if quotas[group_index] >= capacity: continue + quotas[group_index] += 1 + remaining -= 1 + allocated_this_round = True + if not allocated_this_round: + break + return quotas + + +def _normalise_scores(group: Sequence[PositionedFrame]) -> List[float]: + scores = [float(frame.get("sharpnessScore", 0) or 0) for _, frame in group] + low = min(scores) + high = max(scores) + if high == low: + return [1.0] * len(scores) + score_range = high - low + return [(score - low) / score_range for score in scores] + + +def _normalise_weights(sharpness_weight: float, distribution_weight: float) -> Tuple[float, float]: + sharpness = max(0.0, float(sharpness_weight)) + distribution = max(0.0, float(distribution_weight)) + total = sharpness + distribution + if total == 0: + return 0.7, 0.3 + return sharpness / total, distribution / total + + +def _segment_bounds(slot: int, quota: int, frame_count: int) -> Tuple[int, int]: + start = (slot * frame_count) // quota + end = max(start, (((slot + 1) * frame_count) // quota) - 1) + return start, min(end, frame_count - 1) + + +def _select_best_from_group( + group: Sequence[PositionedFrame], + quota: int, + min_buffer: int, + sharpness_weight: float, + distribution_weight: float, +) -> List[PositionedFrame]: + """Pick the sharpest well-positioned frame from each feasible timeline segment.""" + if quota <= 0 or not group: + return [] - distribution_score = _calculate_distribution_score( - frame_index, len(frames), current_selected_indices, min_gap, distribution_weight + frame_count = len(group) + quota = min(quota, _selection_capacity(frame_count, min_buffer)) + minimum_distance = max(0, min_buffer) + 1 + sharpness_scores = _normalise_scores(group) + sharpness_weight, distribution_weight = _normalise_weights( + sharpness_weight, distribution_weight + ) + + selected: List[PositionedFrame] = [] + previous_position: Optional[int] = None + for slot in range(quota): + segment_start, segment_end = _segment_bounds(slot, quota, frame_count) + remaining_slots = quota - slot - 1 + feasible_start = 0 if previous_position is None else previous_position + minimum_distance + feasible_end = frame_count - 1 - (remaining_slots * minimum_distance) + + candidate_start = max(segment_start, feasible_start) + candidate_end = min(segment_end, feasible_end) + if candidate_start > candidate_end: + candidate_start, candidate_end = feasible_start, feasible_end + + ideal_position = (segment_start + segment_end) / 2 + distribution_scale = max(1.0, (segment_end - segment_start + 1) / 2) + + def composite_score(position: int) -> Tuple[float, float, int]: + distribution_score = max( + 0.0, 1.0 - (abs(position - ideal_position) / distribution_scale) ) - sharpness_score = frame.get("sharpnessScore", 0) - - composite_score = ( - (sharpness_score * sharpness_weight) + - (distribution_score * distribution_weight) + total_score = ( + sharpness_scores[position] * sharpness_weight + + distribution_score * distribution_weight ) + raw_sharpness = float(group[position][1].get("sharpnessScore", 0) or 0) + return total_score, raw_sharpness, -position - if composite_score > best_composite_score: - best_composite_score = composite_score - best_candidate = frame - - if best_candidate: - selected_frames.append(best_candidate) - current_selected_indices.add(best_candidate["index"]) - progress_bar.update(1) - else: - break # No more valid frames could be found in this pass - -def select_best_n_frames(frames: List[Dict[str, Any]], num_frames: int, min_buffer: int, - sharpness_weight: float, distribution_weight: float) -> List[Dict[str, Any]]: - """Select the best N frames based on sharpness and distribution.""" - if not frames: - return [] + best_position = max(range(candidate_start, candidate_end + 1), key=composite_score) + selected.append(group[best_position]) + previous_position = best_position - n = min(num_frames, len(frames)) - min_gap = min_buffer + return selected - with tqdm(total=n, desc="Selecting frames (best-n)") as progress_bar: - selected_frames, selected_indices = _select_initial_segments( - frames, n, min_gap, progress_bar - ) - if len(selected_frames) < n: - _fill_remaining_slots( - frames, n, min_gap, selected_frames, selected_indices, progress_bar, - sharpness_weight, distribution_weight - ) +def select_best_n_frames_core( + frames: Sequence[Frame], + num_frames: int, + min_buffer: int, + sharpness_weight: float = 0.7, + distribution_weight: float = 0.3, +) -> List[Frame]: + """Pure Best-N implementation used by every application surface.""" + if not frames or num_frames <= 0: + return [] - progress_bar.n = len(selected_frames) - return sorted(selected_frames, key=lambda f: f["index"]) + groups = _group_by_source(frames) + capacities = [_selection_capacity(len(group), min_buffer) for group in groups] + quotas = _allocate_fair_quotas(capacities, num_frames) + selected: List[PositionedFrame] = [] + for group, quota in zip(groups, quotas): + selected.extend( + _select_best_from_group( + group, + quota, + min_buffer, + sharpness_weight, + distribution_weight, + ) + ) + return [frame for _, frame in sorted(selected, key=lambda item: item[0])] -# === Batched Selection === -def select_batched_frames(frames: List[Dict[str, Any]], batch_size: int, batch_buffer: int) -> List[Dict[str, Any]]: - """Select frames using batch selection method""" +def select_batched_frames_core( + frames: Sequence[Frame], batch_size: int, batch_buffer: int +) -> List[Frame]: + """Select the sharpest frame per source-local batch.""" if not frames or batch_size <= 0: return [] + step_size = batch_size + max(0, batch_buffer) + selected: List[PositionedFrame] = [] + for group in _group_by_source(frames): + for start in range(0, len(group), step_size): + batch = group[start : start + batch_size] + if batch: + selected.append( + max( + batch, + key=lambda item: ( + float(item[1].get("sharpnessScore", 0) or 0), + -item[0], + ), + ) + ) + return [frame for _, frame in sorted(selected, key=lambda item: item[0])] + + +def _outlier_positions( + group: Sequence[PositionedFrame], + window_size: int, + sensitivity: int, + min_neighbors: int, + threshold_divisor: float, +) -> Set[int]: + if sensitivity <= 0 or not group: + return set() + + raw_scores = [ + max(0.0, float(frame.get("sharpnessScore", 0) or 0)) + for _, frame in group + ] + scores = [log1p(score) for score in raw_scores] + dip_magnitudes = [0.0] * len(scores) + relative_drops = [0.0] * len(scores) + for position in range(1, len(scores) - 1): + # A local dip must sit below both adjacent frames. Using the lower + # neighbor as the baseline prevents one unusually sharp neighbor from + # making an otherwise ordinary frame look like an outlier. + expected_score = min(scores[position - 1], scores[position + 1]) + dip_magnitudes[position] = max(0.0, expected_score - scores[position]) + expected_raw_score = expm1(expected_score) + if expected_raw_score > 0: + relative_drops[position] = max( + 0.0, + (expected_raw_score - raw_scores[position]) / expected_raw_score, + ) - selected_frames = [] - step_size = batch_size + batch_buffer - total_batches = (len(frames) + step_size - 1) // step_size if step_size > 0 else 0 - - with tqdm(total=total_batches, desc="Selecting batches") as progress_bar: - i = 0 - while i < len(frames): - batch = frames[i : i + batch_size] - if not batch: - break - - best_frame = max(batch, key=lambda f: f.get("sharpnessScore", 0)) - selected_frames.append(best_frame) - - i += step_size - progress_bar.update(1) - - print(f"Batch selection: Selected {len(selected_frames)} frames") - return selected_frames - -# === Outlier Removal Selection === - -def _is_frame_outlier(index: int, frames: List[Dict[str, Any]], - global_range: float, sensitivity: int, - window_size: int, min_neighbors: int, threshold_divisor: float) -> bool: - """Determine if a frame at a given index is an outlier based on its neighbors.""" - if sensitivity <= 0: - return False # Sensitivity 0 means nothing is an outlier - if sensitivity >= 100: - return True # Sensitivity 100 means everything potentially is (handled by caller range check) - - # Ensure window size is odd for symmetry, adjust if even - actual_window_size = window_size if window_size % 2 != 0 else window_size + 1 + actual_window_size = max(OUTLIER_MIN_WINDOW_SIZE, window_size) + if actual_window_size % 2 == 0: + actual_window_size += 1 half_window = actual_window_size // 2 - - window_start = max(0, index - half_window) - window_end = min(len(frames), index + half_window + 1) - - neighbor_indices = list(range(window_start, index)) + list(range(index + 1, window_end)) - - if len(neighbor_indices) < min_neighbors: - return False - - neighbor_scores = [frames[idx].get("sharpnessScore", 0) for idx in neighbor_indices] - window_avg = sum(neighbor_scores) / len(neighbor_scores) - current_score = frames[index].get("sharpnessScore", 0) - - if global_range == 0: - return False - - absolute_diff = window_avg - current_score - percent_of_range = (absolute_diff / global_range) * 100 if global_range > 0 else 0 - - # Sensitivity 0 = threshold 25, 50 = threshold 12.5, 100 = threshold 0 - threshold = (100 - sensitivity) / threshold_divisor - - is_outlier = (current_score < window_avg and percent_of_range > threshold) - return is_outlier - -def select_outlier_removal_frames(frames: List[Dict[str, Any]], window_size: int, sensitivity: int, - min_neighbors: int, threshold_divisor: float) -> List[Dict[str, Any]]: - """Flags frames considered outliers based on local sharpness comparison.""" - if not frames: - return [] - - # Create a list of frame data including the 'selected' flag - result_frames = [frame.copy() for frame in frames] - for frame_data in result_frames: - frame_data["selected"] = True # Start with all selected - - all_scores = [frame.get("sharpnessScore", 0) for frame in frames] - if not all_scores: return [] - - global_min = min(all_scores) - global_max = max(all_scores) - global_range = global_max - global_min - - if global_range == 0 or sensitivity >= 100 or sensitivity <= 0: - if sensitivity >= 100: print("Outlier sensitivity >= 100, selecting all frames.") - elif sensitivity <= 0: print("Outlier sensitivity <= 0, selecting all frames.") - else: print("All frames have identical scores, skipping outlier analysis.") - return result_frames # Return all frames marked as selected - - num_outliers = 0 - with tqdm(total=len(frames), desc="Analyzing for outliers") as progress_bar: - for i in range(len(frames)): - if _is_frame_outlier(i, frames, global_range, sensitivity, - window_size, min_neighbors, threshold_divisor): - result_frames[i]["selected"] = False - num_outliers += 1 - progress_bar.update(1) - - selected_count = len(frames) - num_outliers - print(f"Outlier removal: Marked {num_outliers} outliers. Keeping {selected_count} frames.") - - # Return the list containing *all* original frames, each with its 'selected' status - # The caller will filter based on this status. - return result_frames \ No newline at end of file + maximum_threshold = threshold_divisor if threshold_divisor > 0 else 4.0 + sensitivity_ratio = min(100, sensitivity) / 100 + robust_threshold = OUTLIER_MIN_ROBUST_THRESHOLD + ( + (maximum_threshold - OUTLIER_MIN_ROBUST_THRESHOLD) + * (1 - sensitivity_ratio) + ) + minimum_relative_drop = OUTLIER_MAX_RELATIVE_DROP - ( + (OUTLIER_MAX_RELATIVE_DROP - OUTLIER_MIN_RELATIVE_DROP) + * sensitivity_ratio + ) + outliers: Set[int] = set() + + for position, dip_magnitude in enumerate(dip_magnitudes): + if position == 0 or position == len(scores) - 1: + continue + window_start = max(0, position - half_window) + window_end = min(len(group), position + half_window + 1) + neighbor_dips = ( + dip_magnitudes[window_start:position] + + dip_magnitudes[position + 1 : window_end] + ) + required_neighbors = max(2, min_neighbors) + if len(neighbor_dips) < required_neighbors: + continue + neighbor_median = median(neighbor_dips) + absolute_deviations = [ + abs(neighbor_dip - neighbor_median) for neighbor_dip in neighbor_dips + ] + median_absolute_deviation = median(absolute_deviations) + robust_scale = median_absolute_deviation * 1.4826 + if robust_scale == 0: + robust_scale = max(abs(neighbor_median) * 0.01, 1e-9) + + excess_dip = dip_magnitude - neighbor_median + robust_excess = excess_dip / robust_scale + if ( + excess_dip > 0 + and relative_drops[position] >= minimum_relative_drop + and robust_excess > robust_threshold + ): + outliers.add(position) + return outliers + + +def select_outlier_removal_frames_core( + frames: Sequence[Frame], + window_size: int, + sensitivity: int, + min_neighbors: int = 3, + threshold_divisor: float = 4.0, +) -> List[Frame]: + """Return copies of all frames with an exact source-local ``selected`` flag.""" + result = [dict(frame, selected=True) for frame in frames] + for group in _group_by_source(frames): + for group_position in _outlier_positions( + group, window_size, sensitivity, min_neighbors, threshold_divisor + ): + input_position = group[group_position][0] + result[input_position]["selected"] = False + return result + + +def select_best_n_frames( + frames: List[Frame], + num_frames: int, + min_buffer: int, + sharpness_weight: float, + distribution_weight: float, +) -> List[Frame]: + """Select Best-N frames and display CLI progress.""" + selected = select_best_n_frames_core( + frames, num_frames, min_buffer, sharpness_weight, distribution_weight + ) + with tqdm(total=len(selected), desc="Selecting frames (best-n)") as progress_bar: + progress_bar.update(len(selected)) + return selected + + +def select_batched_frames( + frames: List[Frame], batch_size: int, batch_buffer: int +) -> List[Frame]: + """Select batched frames and display CLI progress.""" + selected = select_batched_frames_core(frames, batch_size, batch_buffer) + with tqdm(total=len(selected), desc="Selecting batches") as progress_bar: + progress_bar.update(len(selected)) + print(f"Batch selection: Selected {len(selected)} frames") + return selected + + +def select_outlier_removal_frames( + frames: List[Frame], + window_size: int, + sensitivity: int, + min_neighbors: int, + threshold_divisor: float, +) -> List[Frame]: + """Flag local low-sharpness outliers and display CLI progress.""" + result = select_outlier_removal_frames_core( + frames, window_size, sensitivity, min_neighbors, threshold_divisor + ) + with tqdm(total=len(result), desc="Analyzing for outliers") as progress_bar: + progress_bar.update(len(result)) + selected_count = sum(frame["selected"] for frame in result) + print( + f"Outlier detection: Marked {len(result) - selected_count} outliers. " + f"Keeping {selected_count} frames." + ) + return result + + +# Compatibility hooks used by the legacy minimal-progress UI. They now delegate to +# the same canonical Best-N implementation, so that UI no longer has separate rules. +def _select_initial_segments( + frames: List[Frame], n: int, min_gap: int, progress_bar: Any +) -> Tuple[List[Frame], Set[int]]: + selected = select_best_n_frames_core(frames, n, min_gap, 0.7, 0.3) + if progress_bar is not None: + progress_bar.update(len(selected)) + return selected, {frame["index"] for frame in selected} + + +def _fill_remaining_slots( + frames: List[Frame], + n: int, + min_gap: int, + selected_frames: List[Frame], + selected_indices: Set[int], + progress_bar: Any, + sharpness_weight: float, + distribution_weight: float, +) -> None: + """Compatibility no-op; the canonical first pass fills every feasible slot.""" + return None + + +def _is_gap_sufficient(frame_index: int, selected_indices: Set[int], min_buffer: int) -> bool: + """Compatibility helper using intervening-frame buffer semantics.""" + minimum_distance = max(0, min_buffer) + 1 + return all(abs(frame_index - selected) >= minimum_distance for selected in selected_indices) diff --git a/sharp_frames/selection_preview.py b/sharp_frames/selection_preview.py index 93638d2..82f27af 100644 --- a/sharp_frames/selection_preview.py +++ b/sharp_frames/selection_preview.py @@ -1,389 +1,157 @@ -""" -Selection preview functions for Sharp Frames TUI. -Optimized for speed to enable real-time preview updates. -""" - -from typing import List, Dict, Any -import time -import statistics - - -def get_selection_count(frames_with_scores: List[Dict[str, Any]], method: str, **params) -> int: - """ - Calculate how many frames would be selected without actually selecting them. - Optimized for speed to enable real-time preview updates (<100ms target). - - Args: - frames_with_scores: List of frame dictionaries with sharpness scores - method: Selection method ('best-n', 'batched', 'outlier-removal') - **params: Method-specific parameters - - Returns: - Number of frames that would be selected - - Raises: - ValueError: For unsupported selection methods - """ +"""Exact previews backed by the canonical frame-selection algorithms.""" + +from collections import Counter +from typing import Any, Dict, List, Sequence, Tuple + +from .selection_methods import ( + OUTLIER_DEFAULT_SENSITIVITY, + OUTLIER_DEFAULT_WINDOW_SIZE, + select_batched_frames_core, + select_best_n_frames_core, + select_outlier_removal_frames_core, +) + + +Frame = Dict[str, Any] + + +def _select_for_preview( + frames: Sequence[Frame], method: str, **params: Any +) -> List[Frame]: + if method in {"best-n", "best_n"}: + return select_best_n_frames_core( + frames, + params.get("n", 300), + params.get("min_buffer", 3), + params.get("sharpness_weight", 0.7), + params.get("distribution_weight", 0.3), + ) + if method == "batched": + return select_batched_frames_core( + frames, + params.get("batch_size", 5), + params.get("batch_buffer", 2), + ) + if method in {"outlier-removal", "outlier_removal"}: + assessed = select_outlier_removal_frames_core( + frames, + params.get("outlier_window_size", OUTLIER_DEFAULT_WINDOW_SIZE), + params.get("outlier_sensitivity", OUTLIER_DEFAULT_SENSITIVITY), + params.get("min_neighbors", 3), + params.get("threshold_divisor", 4.0), + ) + return [frame for frame in assessed if frame["selected"]] + raise ValueError(f"Unsupported selection method: {method}") + + +def get_selection_count( + frames_with_scores: List[Frame], method: str, **params: Any +) -> int: + """Return the exact number of frames that execution will select.""" if not frames_with_scores: return 0 - - total_frames = len(frames_with_scores) - - if method == 'best-n': - n = params.get('n', 300) - min_buffer = params.get('min_buffer', 3) - - # With min_buffer constraint, we might not be able to select all n frames - if min_buffer > 0: - required_positions = n + (n - 1) * min_buffer - if required_positions > total_frames: - # Calculate maximum possible frames with this buffer - max_possible = (total_frames + min_buffer) // (min_buffer + 1) - return min(n, max_possible) - - return min(max(0, n), total_frames) - - elif method == 'batched': - batch_size = params.get('batch_size', 5) - batch_buffer = params.get('batch_buffer', 2) - - # Calculate how many batches can be created - step_size = batch_size + batch_buffer - if step_size <= 0: - return 0 - return (total_frames + step_size - 1) // step_size if step_size > 0 else 0 - - elif method == 'outlier-removal': - outlier_sensitivity = params.get('outlier_sensitivity', 50) - return _preview_outlier_removal_count_fast(frames_with_scores, outlier_sensitivity) - - else: - raise ValueError(f"Unsupported selection method: {method}") + return len(_select_for_preview(frames_with_scores, method, **params)) -def get_selection_preview(frames_with_scores: List[Dict[str, Any]], method: str, **params) -> Dict[str, Any]: - """ - Return detailed preview information including count, distribution, and statistics. - - Args: - frames_with_scores: List of frame dictionaries with sharpness scores - method: Selection method ('best-n', 'batched', 'outlier-removal') - **params: Method-specific parameters - - Returns: - Dictionary containing: - - count: number of frames that would be selected - - distribution: histogram of selected frames across timeline - - statistics: min/max/avg sharpness of selection - """ +def get_selection_preview( + frames_with_scores: List[Frame], method: str, **params: Any +) -> Dict[str, Any]: + """Return exact selection count, timeline distribution, and score statistics.""" if not frames_with_scores: - return { - 'count': 0, - 'distribution': [], - 'statistics': {'min_sharpness': 0, 'max_sharpness': 0, 'avg_sharpness': 0} - } - - # Get basic count - count = get_selection_count(frames_with_scores, method, **params) - - # Calculate detailed preview based on method - if method == 'best-n': - return _preview_best_n_detailed(frames_with_scores, count, **params) - elif method == 'batched': - return _preview_batched_detailed(frames_with_scores, count, **params) - elif method == 'outlier-removal': - return _preview_outlier_removal_detailed(frames_with_scores, **params) - else: - return { - 'count': count, - 'distribution': [], - 'statistics': {'min_sharpness': 0, 'max_sharpness': 0, 'avg_sharpness': 0} - } - - -# === Fast Preview Implementations === - -def _preview_outlier_removal_count_fast(frames_with_scores: List[Dict[str, Any]], outlier_sensitivity: int) -> int: - """Fast heuristic-based preview for outlier removal using legacy sensitivity parameter.""" - total_frames = len(frames_with_scores) - - # Use more granular heuristic for responsive preview - # Estimate based on sensitivity: higher sensitivity = more aggressive removal - if outlier_sensitivity >= 90: - removal_rate = 0.40 # Remove ~40% of frames - elif outlier_sensitivity >= 80: - removal_rate = 0.30 # Remove ~30% of frames - elif outlier_sensitivity >= 70: - removal_rate = 0.25 # Remove ~25% of frames - elif outlier_sensitivity >= 60: - removal_rate = 0.20 # Remove ~20% of frames - elif outlier_sensitivity >= 50: - removal_rate = 0.15 # Remove ~15% of frames - elif outlier_sensitivity >= 40: - removal_rate = 0.10 # Remove ~10% of frames - elif outlier_sensitivity >= 30: - removal_rate = 0.07 # Remove ~7% of frames - elif outlier_sensitivity >= 20: - removal_rate = 0.05 # Remove ~5% of frames - elif outlier_sensitivity >= 10: - removal_rate = 0.03 # Remove ~3% of frames - else: - removal_rate = 0.01 # Remove ~1% of frames - - estimated_selected = int(total_frames * (1.0 - removal_rate)) - return max(1, min(estimated_selected, total_frames)) - - -def _preview_best_n_detailed(frames_with_scores: List[Dict[str, Any]], count: int, **params) -> Dict[str, Any]: - """Detailed preview for best-n selection.""" - if count == 0: - return { - 'count': 0, - 'distribution': [], - 'statistics': {'min_sharpness': 0, 'max_sharpness': 0, 'avg_sharpness': 0} - } - - # Sort by sharpness score to simulate selection - sorted_frames = sorted(frames_with_scores, key=lambda x: x.get('sharpnessScore', 0), reverse=True) - selected_frames = sorted_frames[:count] - - # Calculate statistics - selected_scores = [frame.get('sharpnessScore', 0) for frame in selected_frames] - statistics_data = { - 'min_sharpness': min(selected_scores) if selected_scores else 0, - 'max_sharpness': max(selected_scores) if selected_scores else 0, - 'avg_sharpness': sum(selected_scores) / len(selected_scores) if selected_scores else 0 - } - - # Calculate distribution (histogram across timeline) - distribution = _calculate_timeline_distribution(frames_with_scores, selected_frames) - + return _empty_preview() + + positioned_frames = [ + dict(frame, _preview_position=position) + for position, frame in enumerate(frames_with_scores) + ] + selected = _select_for_preview(positioned_frames, method, **params) + scores = [float(frame.get("sharpnessScore", 0) or 0) for frame in selected] return { - 'count': count, - 'distribution': distribution, - 'statistics': statistics_data + "count": len(selected), + "distribution": _calculate_timeline_distribution(positioned_frames, selected), + "statistics": { + "min_sharpness": min(scores) if scores else 0, + "max_sharpness": max(scores) if scores else 0, + "avg_sharpness": sum(scores) / len(scores) if scores else 0, + }, } -def _preview_batched_detailed(frames_with_scores: List[Dict[str, Any]], count: int, **params) -> Dict[str, Any]: - """Detailed preview for batched selection using legacy parameters.""" - if count == 0: - return { - 'count': 0, - 'distribution': [], - 'statistics': {'min_sharpness': 0, 'max_sharpness': 0, 'avg_sharpness': 0} - } - - total_frames = len(frames_with_scores) - batch_size = params.get('batch_size', 5) - batch_buffer = params.get('batch_buffer', 2) - - # Simulate legacy batched selection - step_size = batch_size + batch_buffer - selected_frames = [] - - i = 0 - while i < total_frames: - # Take a batch of consecutive frames - batch = frames_with_scores[i:i + batch_size] - if not batch: - break - - # Select best frame from this batch - best_frame = max(batch, key=lambda x: x.get('sharpnessScore', 0)) - selected_frames.append(best_frame) - - # Move to next batch position (skip batch_buffer frames) - i += step_size - - # Calculate statistics - selected_scores = [frame.get('sharpnessScore', 0) for frame in selected_frames] - statistics_data = { - 'min_sharpness': min(selected_scores) if selected_scores else 0, - 'max_sharpness': max(selected_scores) if selected_scores else 0, - 'avg_sharpness': sum(selected_scores) / len(selected_scores) if selected_scores else 0 - } - - # For batched selection, distribution is predictable (one per batch) - distribution = [1] * len(selected_frames) - +def _empty_preview() -> Dict[str, Any]: return { - 'count': len(selected_frames), - 'distribution': distribution, - 'statistics': statistics_data + "count": 0, + "distribution": [], + "statistics": { + "min_sharpness": 0, + "max_sharpness": 0, + "avg_sharpness": 0, + }, } -def _preview_outlier_removal_detailed(frames_with_scores: List[Dict[str, Any]], **params) -> Dict[str, Any]: - """Detailed preview for outlier removal selection using legacy parameters.""" - outlier_sensitivity = params.get('outlier_sensitivity', 50) - outlier_window_size = params.get('outlier_window_size', 15) - - # For detailed preview, we need to do actual outlier calculation - # But optimize for common cases - total_frames = len(frames_with_scores) - - if total_frames <= 10: - # For small datasets, just return all frames - count = total_frames - selected_frames = frames_with_scores - else: - # Use fast outlier detection for preview - count = _preview_outlier_removal_count_fast(frames_with_scores, outlier_sensitivity) - - # For statistics, estimate based on removing lowest scores - all_scores = [frame.get('sharpnessScore', 0) for frame in frames_with_scores] - sorted_scores = sorted(all_scores, reverse=True) - estimated_selected_scores = sorted_scores[:count] - - # Create mock selected frames for statistics - selected_frames = [ - {'sharpnessScore': score} for score in estimated_selected_scores - ] - - # Calculate statistics - selected_scores = [frame.get('sharpnessScore', 0) for frame in selected_frames] - statistics_data = { - 'min_sharpness': min(selected_scores) if selected_scores else 0, - 'max_sharpness': max(selected_scores) if selected_scores else 0, - 'avg_sharpness': sum(selected_scores) / len(selected_scores) if selected_scores else 0 - } - - # Distribution for outlier removal is less predictable, use uniform approximation - distribution = _create_uniform_distribution(count, 10) # 10 bins - - return { - 'count': count, - 'distribution': distribution, - 'statistics': statistics_data - } - +def _frame_identity(frame: Frame) -> Tuple[Any, ...]: + return ( + frame.get("_preview_position"), + frame.get("source_video") or frame.get("sourceVideo"), + frame.get("source_index"), + frame.get("id"), + frame.get("path"), + frame.get("index"), + ) -# === Helper Functions === -def _calculate_timeline_distribution(all_frames: List[Dict[str, Any]], - selected_frames: List[Dict[str, Any]], - num_bins: int = 10) -> List[int]: - """Calculate distribution of selected frames across timeline.""" - if not selected_frames or not all_frames: - return [0] * num_bins - - total_frames = len(all_frames) - bin_size = total_frames / num_bins +def _calculate_timeline_distribution( + all_frames: List[Frame], selected_frames: List[Frame], num_bins: int = 10 +) -> List[int]: + """Count the exact selected positions in timeline bins.""" + if num_bins <= 0: + return [] distribution = [0] * num_bins - - # Create mapping of frame to index - frame_to_index = {} - for i, frame in enumerate(all_frames): - frame_id = frame.get('id', '') or frame.get('path', '') or str(i) - frame_to_index[frame_id] = i - - # Count selected frames in each bin - for frame in selected_frames: - frame_id = frame.get('id', '') or frame.get('path', '') - if frame_id in frame_to_index: - frame_index = frame_to_index[frame_id] - bin_index = min(int(frame_index / bin_size), num_bins - 1) - distribution[bin_index] += 1 - + if not all_frames or not selected_frames: + return distribution + + selected_identities = Counter(_frame_identity(frame) for frame in selected_frames) + bin_size = len(all_frames) / num_bins + for position, frame in enumerate(all_frames): + identity = _frame_identity(frame) + if not selected_identities[identity]: + continue + selected_identities[identity] -= 1 + bin_index = min(int(position / bin_size), num_bins - 1) + distribution[bin_index] += 1 return distribution -def _create_uniform_distribution(count: int, num_bins: int) -> List[int]: - """Create approximately uniform distribution across bins.""" - if count == 0 or num_bins == 0: - return [0] * max(1, num_bins) - - base_count = count // num_bins - remainder = count % num_bins - - distribution = [base_count] * num_bins - - # Distribute remainder across first bins - for i in range(remainder): - distribution[i] += 1 - - return distribution - - -# === Performance Monitoring === - -def _measure_performance(func, *args, **kwargs): - """Measure function execution time for performance monitoring.""" - start_time = time.time() - result = func(*args, **kwargs) - end_time = time.time() - elapsed = end_time - start_time - - # Log performance warning if preview takes too long - if elapsed > 0.1: # 100ms threshold - print(f"Warning: Selection preview took {elapsed:.3f}s, target is <0.1s") - - return result - - -# === Caching Support === - -_preview_cache = {} +_preview_cache: Dict[str, Dict[str, Any]] = {} _cache_max_size = 100 -def _calculate_cache_key(frames_with_scores: List[Dict[str, Any]], method: str, **params) -> str: - """Calculate cache key for preview results.""" - # Use frame count and method as key components for fast caching - frame_count = len(frames_with_scores) - - # Include first and last frame sharpness for basic content identification - if frames_with_scores: - first_score = frames_with_scores[0].get('sharpnessScore', 0) - last_score = frames_with_scores[-1].get('sharpnessScore', 0) if len(frames_with_scores) > 1 else first_score - else: - first_score = last_score = 0 - - # Create key from method and key parameters - param_str = "" - if method == 'best-n': - param_str = f"n={params.get('n', 300)}" - elif method == 'batched': - param_str = f"batch_size={params.get('batch_size', 5)}_batch_buffer={params.get('batch_buffer', 2)}" - elif method == 'outlier-removal': - param_str = f"outlier_sensitivity={params.get('outlier_sensitivity', 50)}_outlier_window_size={params.get('outlier_window_size', 15)}" - - return f"{method}_{frame_count}_{first_score:.2f}_{last_score:.2f}_{param_str}" - - -def _get_cached_preview(cache_key: str) -> Dict[str, Any]: - """Get cached preview result if available.""" - return _preview_cache.get(cache_key) - - -def _cache_preview_result(cache_key: str, result: Dict[str, Any]): - """Cache preview result with size limit.""" - global _preview_cache - - # Simple cache size management - if len(_preview_cache) >= _cache_max_size: - # Remove oldest entries (simple FIFO) - keys_to_remove = list(_preview_cache.keys())[:_cache_max_size // 2] - for key in keys_to_remove: - del _preview_cache[key] - - _preview_cache[cache_key] = result - - -def _calculate_cached_preview(frames_with_scores: List[Dict[str, Any]], method: str, **params) -> Dict[str, Any]: - """Calculate preview with caching support.""" - cache_key = _calculate_cache_key(frames_with_scores, method, **params) - - # Check cache first - cached_result = _get_cached_preview(cache_key) - if cached_result: - return cached_result - - # Calculate new result +def _calculate_cache_key( + frames_with_scores: List[Frame], method: str, **params: Any +) -> str: + """Include every algorithm input so cached previews cannot become stale.""" + frame_state = tuple( + ( + _frame_identity(frame), + float(frame.get("sharpnessScore", 0) or 0), + ) + for frame in frames_with_scores + ) + parameter_state = tuple(sorted(params.items())) + return repr((method, frame_state, parameter_state)) + + +def _calculate_cached_preview( + frames_with_scores: List[Frame], method: str, **params: Any +) -> Dict[str, Any]: + """Return an exact preview from a bounded in-process cache.""" + key = _calculate_cache_key(frames_with_scores, method, **params) + cached = _preview_cache.get(key) + if cached is not None: + return cached result = get_selection_preview(frames_with_scores, method, **params) - - # Cache result - _cache_preview_result(cache_key, result) - - return result \ No newline at end of file + if len(_preview_cache) >= _cache_max_size: + for stale_key in list(_preview_cache)[: _cache_max_size // 2]: + del _preview_cache[stale_key] + _preview_cache[key] = result + return result diff --git a/sharp_frames/sharp_frames.py b/sharp_frames/sharp_frames.py index ce132f2..57f9c62 100644 --- a/sharp_frames/sharp_frames.py +++ b/sharp_frames/sharp_frames.py @@ -7,7 +7,11 @@ from .sharp_frames_processor import SharpFrames, ImageProcessingError # Import video directory utilities -from .video_utils import get_video_files_in_directory, detect_input_type +from .video_utils import ( + detect_input_type, + get_video_files_in_directory, + is_video_file, +) # Helper functions for interactive mode def get_valid_file_path(prompt: str, must_exist: bool = True) -> str: @@ -27,7 +31,12 @@ def get_valid_file_path(prompt: str, must_exist: bool = True) -> str: if must_exist and not os.path.isfile(path): print(f"Error: File '{path}' not found. Please enter a valid file path.") continue - + if must_exist and not is_video_file(path): + print( + f"Error: File '{path}' is not a recognizable supported video." + ) + continue + return path def get_valid_dir_path(prompt: str, create_if_missing: bool = True, check_emptiness: bool = True) -> str: @@ -169,9 +178,9 @@ def main(): parser.add_argument("--batch-buffer", type=int, default=2, help="Number of frames to skip between batches (default: 2)") parser.add_argument("--outlier-window-size", type=int, default=15, - help="Number of neighboring frames to compare for outlier detection (default: 15)") - parser.add_argument("--outlier-sensitivity", type=int, default=50, - help="Sensitivity of outlier detection, 0-100 (default: 50)") + help="Local comparison window for outlier detection, minimum 5 (default: 15)") + parser.add_argument("--outlier-sensitivity", type=int, default=60, + help="Sensitivity of outlier detection, 0-100 (default: 60)") parser.add_argument("--width", type=int, default=0, help="Width to resize output images (height will be adjusted proportionally, 0 for no resizing)") parser.add_argument("--interactive", action="store_true", help="Run in legacy terminal prompt mode") @@ -210,8 +219,12 @@ def main(): print(f"Error: Input path not found: {args.input_path}") return 1 - input_type = detect_input_type(args.input_path) - + try: + input_type = detect_input_type(args.input_path) + except ValueError as exc: + print(f"Error: {exc}") + return 1 + # Handle input type specific logic if input_type == "directory": print("Input path is a directory. Processing images.") @@ -323,7 +336,7 @@ def run_interactive_mode(): batch_size = 5 batch_buffer = 2 outlier_window_size = 15 - outlier_sensitivity = 50 + outlier_sensitivity = 60 # Get method-specific parameters if selection_method == "best-n": @@ -333,8 +346,8 @@ def run_interactive_mode(): batch_size = get_valid_int("Enter batch size", min_value=1, default=5) batch_buffer = get_valid_int("Enter batch buffer (frames/images to skip between batches)", min_value=0, default=2) elif selection_method == "outlier-removal": - outlier_window_size = get_valid_int("Enter window size for comparison", min_value=3, max_value=30, default=15) - outlier_sensitivity = get_valid_int("Enter sensitivity (0-100, higher is more aggressive)", min_value=0, max_value=100, default=50) + outlier_window_size = get_valid_int("Enter window size for comparison", min_value=5, max_value=31, default=15) + outlier_sensitivity = get_valid_int("Enter sensitivity (0-100, higher is more aggressive)", min_value=0, max_value=100, default=60) output_format = get_choice( "Choose output format for saved images (or first 3 letters)", @@ -400,4 +413,4 @@ def run_interactive_mode(): return 0 if success else 1 if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/sharp_frames/sharp_frames_processor.py b/sharp_frames/sharp_frames_processor.py index 675a137..38e0842 100644 --- a/sharp_frames/sharp_frames_processor.py +++ b/sharp_frames/sharp_frames_processor.py @@ -15,6 +15,12 @@ # Add tqdm for progress visualization (mandatory dependency) from tqdm import tqdm +from .focus_scoring import calculate_focus_score +from .image_output import ( + canonical_image_format, + transcode_image, +) + # Import selection strategy functions from .selection_methods import ( select_best_n_frames, @@ -23,7 +29,11 @@ ) # Import video directory utilities -from .video_utils import get_video_files_in_directory +from .video_utils import ( + SUPPORTED_IMAGE_EXTENSIONS, + get_video_files_in_directory, + natural_path_key, +) # Define a custom exception for image processing errors class ImageProcessingError(Exception): @@ -50,7 +60,7 @@ def __init__(self, # --- Parameters for 'outlier-removal' selection --- outlier_window_size: int = 15, - outlier_sensitivity: int = 50, + outlier_sensitivity: int = 60, # --- Parameters for image resizing --- width: int = 0): @@ -58,8 +68,7 @@ def __init__(self, # --- Constants --- # Filename format for output files self.OUTPUT_FILENAME_FORMAT = "frame_{seq:05d}.{ext}" - # Supported image extensions for directory input - self.SUPPORTED_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png'} + self.SUPPORTED_IMAGE_EXTENSIONS = SUPPORTED_IMAGE_EXTENSIONS # Weights for composite score calculation in 'best-n' selection self.BEST_N_SHARPNESS_WEIGHT = 0.7 self.BEST_N_DISTRIBUTION_WEIGHT = 0.3 @@ -127,9 +136,16 @@ def _load_input_frames(self) -> Tuple[List[str], bool]: if duration: print(f"Video duration: {self._format_duration(duration)}") + # Extract color space from existing video_info (no extra ffprobe call) + from .processing.colorspace import get_color_info_description + color_info = self._extract_color_info_from_video_info(video_info) + if color_info.needs_conversion: + print(f"Detected color space: {get_color_info_description(color_info)}") + print("Will convert to sRGB/BT.709") + print(f"Extracting frames at {self.fps} fps...") # Extract frames relies on self.temp_dir being set - self._extract_frames(duration) + self._extract_frames(duration, color_info) # Get paths from temp directory frame_paths = self._get_frame_paths() @@ -186,7 +202,7 @@ def _analyze_and_select_frames(self, frame_paths: List[str]) -> List[Dict[str, A self.batch_buffer ) elif self.selection_method == "outlier-removal": - # Outlier removal returns all frames with a 'selected' flag + # Outlier detection returns all frames with a 'selected' flag all_frames_data = select_outlier_removal_frames( frames_with_scores, self.outlier_window_size, @@ -241,7 +257,9 @@ def run(self): # --- Save Phase --- print(f"Saving {len(selected_frames_data)} selected frames/images...") with tqdm(total=len(selected_frames_data), desc="Saving selected items") as progress_bar: - self._save_frames(selected_frames_data, progress_bar) + if not self._save_frames(selected_frames_data, progress_bar): + print("One or more selected items could not be saved.") + return False print(f"Successfully processed. Selected items saved to: {self.output_dir}") return True @@ -295,17 +313,24 @@ def _load_frames_from_video_directory(self) -> List[str]: # Extract video info and frames for this video print("Extracting video information...") + video_info = None + color_info = None try: video_info = self._get_video_info() duration = self._extract_duration(video_info) if duration: print(f"Video duration: {self._format_duration(duration)}") + # Extract color space from existing video_info (no extra ffprobe call) + color_info = self._extract_color_info_from_video_info(video_info) except Exception as e: print(f"Warning: Could not extract video info for {os.path.basename(video_path)}: {e}") duration = None - + # Fallback to separate color detection if video_info failed + from .processing.colorspace import detect_color_space + color_info = detect_color_space(video_path) + print(f"Extracting frames at {self.fps} fps...") - if self._extract_frames(duration): + if self._extract_frames(duration, color_info): # Get frame paths from this video video_frame_paths = self._get_frame_paths() print(f"Extracted {len(video_frame_paths)} frames from {os.path.basename(video_path)}") @@ -371,8 +396,8 @@ def _check_dependencies(self, check_ffmpeg: bool = True) -> bool: subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, text=True) progress_bar.update(1) except (subprocess.SubprocessError, FileNotFoundError): - # This is only a warning as duration extraction is a nice-to-have - print("Warning: FFprobe is not installed or not in PATH. Video duration cannot be determined.") + print("Error: FFprobe is not installed or not in PATH. Required for video input.") + return False # Always check for OpenCV (needed for sharpness calculation) # A simple check if cv2 was imported successfully is enough here @@ -408,50 +433,82 @@ def _extract_duration(self, video_info: Dict[str, Any]) -> float: return None def _get_video_info(self) -> Dict[str, Any]: - """Get video metadata using FFmpeg""" - # Try using ffprobe for more detailed info + """Get video metadata using FFmpeg including color space info.""" + # Try using ffprobe for more detailed info including color metadata probe_command = [ - "ffprobe", + "ffprobe", "-v", "error", "-show_entries", "format=duration", "-select_streams", "v:0", - "-show_entries", "stream=width,height,avg_frame_rate,duration", + "-show_entries", "stream=width,height,avg_frame_rate,duration,color_primaries,color_transfer,color_space", "-of", "json", self.input_path ] - + try: probe_result = subprocess.run( - probe_command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + probe_command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, check=True, text=True ) - + video_info = json.loads(probe_result.stdout) return video_info except subprocess.CalledProcessError: # Fallback if ffprobe fails return {"error": "Failed to get video info"} + + def _extract_color_info_from_video_info(self, video_info: Dict[str, Any]): + """Extract color space info from existing video_info (avoids extra ffprobe call).""" + from .processing.colorspace import parse_color_info_from_stream, VideoColorInfo, ColorPrimaries, TransferFunction, ColorMatrix + + streams = video_info.get('streams', []) + if streams: + return parse_color_info_from_stream(streams[0]) + + # No stream found, return default + return VideoColorInfo( + color_primaries=ColorPrimaries.UNKNOWN, + transfer_function=TransferFunction.UNKNOWN, + color_matrix=ColorMatrix.UNKNOWN, + is_hdr=False + ) - def _extract_frames(self, duration: float = None) -> bool: - """Extract frames from video using FFmpeg""" + def _extract_frames(self, duration: float = None, color_info=None) -> bool: + """Extract through the canonical, cancellation-safe FFmpeg runner.""" + from .processing.frame_extractor import FrameExtractor + + extractor = FrameExtractor() + success = extractor._run_ffmpeg_extraction( + self.input_path, + self.temp_dir, + self.fps, + self.output_format, + self.width, + duration, + color_info, + ) + if not success: + raise RuntimeError(f"Frame extraction failed for {self.input_path}") + return True + + def _extract_frames_legacy(self, duration: float = None, color_info=None) -> bool: + """Deprecated pre-canonical FFmpeg runner retained for compatibility tests.""" + from .processing.frame_extractor import FrameExtractor + output_pattern = os.path.join(self.temp_dir, f"frame_%05d.{self.output_format}") - + # Set a timeout threshold for the process in case it hangs process_timeout_seconds = 3600 # 1 hour timeout for FFmpeg process - - # Build the video filters string - vf_filters = [] - vf_filters.append(f"fps={self.fps}") - - # Add scaling filter if width is specified - if self.width > 0: - vf_filters.append(f"scale={self.width}:-2") # -2 maintains aspect ratio and ensures even height - - # Join all filters with commas - vf_string = ",".join(vf_filters) + vf_string = ",".join( + FrameExtractor._build_video_filters( + self.fps, + self.width, + color_info, + ) + ) command = [ "ffmpeg", @@ -646,8 +703,7 @@ def _get_image_paths_from_dir(self) -> List[str]: print(f"Error scanning directory {self.input_path}: {str(e)}") raise - # Sort paths alphabetically for consistent ordering - image_paths.sort() + image_paths.sort(key=natural_path_key) if not image_paths: print(f"Warning: No supported image files ({supported_extensions_str}) found in {self.input_path}.") @@ -695,6 +751,17 @@ def _calculate_sharpness(self, frame_paths: List[str]) -> List[Dict[str, Any]]: "index": idx, "sharpnessScore": score } + if self.input_type == "video_directory": + frame_data["source_video"] = os.path.basename( + os.path.dirname(path) + ) + try: + local_number = int( + os.path.splitext(frame_id)[0].rsplit("_", 1)[1] + ) + frame_data["source_index"] = local_number - 1 + except (IndexError, ValueError): + frame_data["source_index"] = None frames_data.append(frame_data) except ImageProcessingError as e: # Log specific image processing errors and continue @@ -732,92 +799,96 @@ def _process_image(path: str) -> float: # Raise custom exception if image reading fails raise ImageProcessingError(f"Failed to read image: {path}") - height, width = img_gray.shape - # Use INTER_AREA for downscaling - generally preferred - img_half = cv2.resize(img_gray, (width // 2, height // 2), interpolation=cv2.INTER_AREA) - - # Calculate Laplacian variance - score = float(cv2.Laplacian(img_half, cv2.CV_64F).var()) - - return score + return calculate_focus_score(img_gray) except cv2.error as e: # Wrap OpenCV errors raise ImageProcessingError(f"OpenCV error processing {path}: {str(e)}") from e - def _save_frames(self, selected_frames: List[Dict[str, Any]], progress_bar=None) -> None: - """Save selected frames/images to output directory.""" + def _save_frames(self, selected_frames: List[Dict[str, Any]], progress_bar=None) -> bool: + """Save every selected item and return whether the operation fully succeeded.""" + output_format = str(self.output_format).strip().lower().lstrip('.') or "jpg" + output_format = {'jpeg': 'jpg', 'tif': 'tiff'}.get( + output_format, output_format + ) + if self.input_type == "directory": + output_names = self._plan_directory_output_names( + selected_frames, output_format + ) + else: + output_names = [ + self.OUTPUT_FILENAME_FORMAT.format(seq=index + 1, ext=output_format) + for index in range(len(selected_frames)) + ] + metadata_list = [] - for i, frame_data in enumerate(selected_frames): + failed_count = 0 + for frame_data, filename in zip(selected_frames, output_names): src_path = frame_data["path"] original_id = frame_data["id"] original_index = frame_data["index"] sharpness_score = frame_data["sharpnessScore"] - - # Use original filename for directory input, sequential naming for video input - if self.input_type == "directory": - filename = original_id # Use the original filename directly - else: - # Use the defined constant format string for video frames and video directory - filename = self.OUTPUT_FILENAME_FORMAT.format( - seq=i+1, - ext=self.output_format - ) dst_path = os.path.join(self.output_dir, filename) try: - # If width is set and we're in directory mode (video modes already handle this during extraction) - if self.width > 0 and self.input_type == "directory": - # Load the image with OpenCV - img = cv2.imread(src_path) - if img is None: - raise ImageProcessingError(f"Failed to read image for resizing: {src_path}") - - # Calculate height to maintain aspect ratio - height = int(img.shape[0] * (self.width / img.shape[1])) - # Ensure height is even - if height % 2 != 0: - height += 1 - - # Resize the image - resized_img = cv2.resize(img, (self.width, height), interpolation=cv2.INTER_AREA) - - # Save the resized image - cv2.imwrite(dst_path, resized_img) + if self.input_type == "directory" and ( + self.width > 0 + or canonical_image_format(src_path) != output_format + ): + transcode_image(src_path, dst_path, self.width) else: - # Normal copy for non-resized or video frames shutil.copy2(src_path, dst_path) except Exception as e: - print(f"Error saving {src_path} to {dst_path}: {e}") - # Optionally skip this frame and continue, or re-raise - continue - - metadata_list.append({ - "output_filename": filename, - "original_id": original_id, # Original filename or frame ID - "original_index": original_index, - "sharpness_score": sharpness_score - }) - - if progress_bar: - progress_bar.update(1) + failed_count += 1 + print(f"Error saving {src_path} to {dst_path}: {e}") + else: + metadata_list.append({ + "output_filename": filename, + "original_id": original_id, + "original_index": original_index, + "sharpness_score": sharpness_score + }) + finally: + if progress_bar: + progress_bar.update(1) - # Save metadata about the selected files metadata_path = os.path.join(self.output_dir, "selected_metadata.json") try: - with open(metadata_path, "w") as f: + with open(metadata_path, "w", encoding="utf-8") as f: json.dump({ "input_path": self.input_path, "input_type": self.input_type, - "total_selected": len(metadata_list), + "total_selected": len(selected_frames), + "total_saved": len(metadata_list), + "total_failed": failed_count, + "output_format": output_format, "selection_method": self.selection_method, - # Include resize width in metadata if set "resize_width": self.width if self.width > 0 else None, - # Include method-specific params in metadata **self._get_method_params_for_metadata(), "selected_items": metadata_list }, f, indent=2) except Exception as e: - print(f"Error writing metadata file {metadata_path}: {str(e)}") + print(f"Error writing metadata file {metadata_path}: {str(e)}") + return False + + return failed_count == 0 + + @staticmethod + def _plan_directory_output_names( + selected_frames: List[Dict[str, Any]], output_format: str + ) -> List[str]: + """Create deterministic, case-insensitively unique encoded filenames.""" + used_names = set() + output_names = [] + for frame_data in selected_frames: + stem = os.path.splitext(os.path.basename(frame_data["id"]))[0] + candidate = f"{stem}.{output_format}" + suffix = 2 + while candidate.casefold() in used_names: + candidate = f"{stem}_{suffix}.{output_format}" + suffix += 1 + used_names.add(candidate.casefold()) + output_names.append(candidate) + return output_names def _get_method_params_for_metadata(self) -> Dict[str, Any]: """Returns parameters relevant to the current selection method for metadata.""" @@ -835,4 +906,4 @@ def _get_method_params_for_metadata(self) -> Dict[str, Any]: elif self.selection_method == "outlier-removal": params["outlier_window_size"] = self.outlier_window_size params["outlier_sensitivity"] = self.outlier_sensitivity - return params \ No newline at end of file + return params diff --git a/sharp_frames/ui/app.py b/sharp_frames/ui/app.py index 94285ce..6a17b51 100644 --- a/sharp_frames/ui/app.py +++ b/sharp_frames/ui/app.py @@ -2,12 +2,16 @@ Main Sharp Frames Textual application. """ +import asyncio import signal import os import time import re +from collections import deque + from textual.app import App from textual.events import Key, Paste +from textual.notifications import Notification, SeverityLevel from textual.widgets import Input from .screens import ConfigurationForm @@ -36,12 +40,95 @@ def __init__(self, **kwargs): self._escape_count = 0 self._last_action_time = 0 self._original_signal_handlers = {} + self._signal_shutdown_task = None super().__init__(**kwargs) + self._limited_notifications: dict[str, deque[Notification]] = {} + + def notify_limited( + self, + message: str, + *, + channel: str, + limit: int = 3, + title: str = "", + severity: SeverityLevel = "information", + timeout: float | None = None, + markup: bool = True, + ) -> None: + """Show a notification while retaining only the newest channel items.""" + if limit < 1: + raise ValueError("Notification limit must be at least one") + if timeout is None: + timeout = self.NOTIFICATION_TIMEOUT + + active = deque( + notification + for notification in self._limited_notifications.get(channel, ()) + if not notification.has_expired + and notification in self._notifications + ) + while len(active) >= limit: + self._unnotify(active.popleft(), refresh=False) + + notification = Notification( + message, + title, + severity, + timeout, + markup=markup, + ) + active.append(notification) + self._limited_notifications[channel] = active + self._notifications.add(notification) + self._refresh_notifications() + def _begin_signal_shutdown(self, screen) -> None: + """Wait briefly for cooperative cleanup, then exit after SIGTERM.""" + if ( + self._signal_shutdown_task is not None + and not self._signal_shutdown_task.done() + ): + return + self._signal_shutdown_task = asyncio.create_task( + self._exit_after_cleanup(screen) + ) + + async def _exit_after_cleanup(self, screen) -> None: + """Exit after active processing finishes, with a bounded fallback.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + 15 + while loop.time() < deadline: + phase_active = ( + hasattr(screen, "phase_1_complete") + and not screen.phase_1_complete + ) + final_task = getattr(screen, "_final_processing_task", None) + final_active = final_task is not None and not final_task.done() + if not phase_active and not final_active: + break + await asyncio.sleep(0.1) + self.exit(result="terminated") + def setup_signal_handlers(self): """Setup signal handlers for cross-platform compatibility.""" def signal_handler(signum, frame): - self.log.info(f"Received signal {signum} in main app, ignoring to prevent premature exit") + self.log.info(f"Received signal {signum}; cancelling active processing") + current_screen = self.screen_stack[-1] if self.screen_stack else None + if current_screen and hasattr(current_screen, 'action_cancel'): + current_screen.action_cancel() + if signum == getattr(signal, "SIGTERM", None): + self.call_later( + self._begin_signal_shutdown, + current_screen, + ) + else: + self.exit( + result=( + "terminated" + if signum == getattr(signal, "SIGTERM", None) + else "cancelled" + ) + ) # Handle common signals - only use signals available on current platform signals_to_handle = [] @@ -51,12 +138,6 @@ def signal_handler(signum, frame): if hasattr(signal, signal_attr): signals_to_handle.append(getattr(signal, signal_attr)) - # Unix/Linux specific signals (not available on Windows) - if os.name == 'posix': - for signal_attr in ['SIGUSR1', 'SIGUSR2', 'SIGHUP', 'SIGPIPE']: - if hasattr(signal, signal_attr): - signals_to_handle.append(getattr(signal, signal_attr)) - for sig in signals_to_handle: try: self._original_signal_handlers[sig] = signal.signal(sig, signal_handler) @@ -134,7 +215,7 @@ def action_cancel(self) -> None: # If we just had escape sequences recently, this is likely spurious if current_time - self._last_escape_time < 2.0: # Within 2 seconds of escape detection - self.log.info(f"Blocking cancel action - likely triggered by spurious escape sequence") + self.log.info("Blocking cancel action - likely triggered by spurious escape sequence") return self._last_action_time = current_time @@ -215,10 +296,6 @@ def _get_target_input_for_step(self, config_screen: ConfigurationForm, file_path """Determine which input field should receive the file path.""" current_step = config_screen.get_current_step_name() - # Check what type of path this is (Windows-safe) - is_directory = os.path.isdir(file_path) if os.path.exists(file_path) else file_path.endswith(('/', '\\')) - is_video = any(file_path.lower().endswith(ext) for ext in ['.mp4', '.avi', '.mov', '.mkv', '.webm', '.m4v']) - # Normalize path separators for consistent handling file_path = os.path.normpath(file_path) diff --git a/sharp_frames/ui/components/raster_preview.py b/sharp_frames/ui/components/raster_preview.py new file mode 100644 index 0000000..f244de7 --- /dev/null +++ b/sharp_frames/ui/components/raster_preview.py @@ -0,0 +1,129 @@ +"""Raster-only inline frame preview with a terminal capability fallback.""" + +from pathlib import Path +from typing import Optional, Type + +from textual.app import ComposeResult +from textual.containers import Container +from textual.css.query import NoMatches +from textual.widget import Widget +from textual.widgets import Static + + +def _detect_raster_backend() -> tuple[Optional[Type[Widget]], Optional[str]]: + """Return a real raster widget supported by the active terminal. + + ``textual-image`` also offers character-cell renderers. Those are + deliberately excluded: an inline preview is only considered available + when the terminal reports Sixel or Kitty graphics support. + """ + try: + from textual_image.renderable import Image as DetectedRenderable + from textual_image.renderable.sixel import Image as SixelRenderable + from textual_image.renderable.tgp import Image as TGPRenderable + except (ImportError, OSError): + return None, None + + try: + if DetectedRenderable is SixelRenderable: + from textual_image.widget import SixelImage + + return SixelImage, "Sixel" + if DetectedRenderable is TGPRenderable: + from textual_image.widget import TGPImage + + return TGPImage, "Kitty graphics" + except Exception: + # Terminal capability probing is best effort. A failed query must not + # prevent the application from starting or its external fallback from + # working. + return None, None + return None, None + + +RASTER_WIDGET_TYPE, RASTER_BACKEND_NAME = _detect_raster_backend() +_DEFAULT_BACKEND = object() + + +class RasterImagePreview(Widget): + """Display a source image through a terminal raster graphics protocol.""" + + can_focus = False + + def __init__( + self, + *, + backend_type=_DEFAULT_BACKEND, + backend_name: Optional[str] = None, + **kwargs, + ) -> None: + super().__init__(**kwargs) + if backend_type is _DEFAULT_BACKEND: + self._backend_type = RASTER_WIDGET_TYPE + self.backend_name = RASTER_BACKEND_NAME + else: + self._backend_type = backend_type + self.backend_name = backend_name + self.current_path: Optional[Path] = None + self.display = False + + if self._backend_type is None: + self.add_class("-unsupported") + + @property + def raster_supported(self) -> bool: + """Whether the terminal has a usable raster graphics protocol.""" + return self._backend_type is not None + + def compose(self) -> ComposeResult: + """Create either the raster canvas or a compact fallback notice.""" + yield Static("", classes="frame-preview-title") + if self._backend_type is not None: + with Container(classes="frame-preview-canvas"): + yield self._backend_type(id="frame_preview_image") + else: + yield Static( + "Inline raster graphics are unavailable in this terminal. " + "Press O to open the original image in your default viewer.", + classes="frame-preview-fallback", + ) + + def show_frame(self, path: str, frame_number: int, score: float) -> bool: + """Show an original frame, scaled only by the raster display backend.""" + image_path = Path(path).expanduser() + if not image_path.is_file(): + raise FileNotFoundError( + f"Frame image is no longer available: {image_path}" + ) + + self.current_path = image_path + self.display = True + self.query_one(".frame-preview-title", Static).update( + f"Frame {frame_number:,} · sharpness {score:.2f} · " + "Press O to open" + ) + + if self._backend_type is None: + return False + + raster_widget = self.query_one("#frame_preview_image") + raster_widget.image = image_path + return True + + def clear(self) -> None: + """Stop displaying a frame so deleted temp files are never re-opened. + + The raster backend keeps the image *path* and lazily re-opens it on + every repaint. When the extraction temp directory is removed after a + save, a stale path would crash Textual's render loop, so the reference + must be dropped before that cleanup runs. + """ + self.current_path = None + self.display = False + if self._backend_type is None: + return + try: + raster_widget = self.query_one("#frame_preview_image") + except NoMatches: + return + raster_widget.image = None diff --git a/sharp_frames/ui/components/step_handlers.py b/sharp_frames/ui/components/step_handlers.py index b55a811..0692e8a 100644 --- a/sharp_frames/ui/components/step_handlers.py +++ b/sharp_frames/ui/components/step_handlers.py @@ -9,6 +9,7 @@ from textual.widgets import Label, Input, Select, RadioSet, RadioButton, Checkbox, Static from ..constants import UIElementIds, InputTypes +from ..keyboard import OptionSelect from .validators import ( IntRangeValidator, VideoFileValidator, @@ -158,17 +159,22 @@ def validate(self, screen) -> bool: return False input_type = screen.config_data.get("input_type", "video") - + if input_type == "video": if not os.path.isfile(expanded_path): screen.query_one("#step-description").update("Path must be a video file") return False - # Could add video format validation here + from ...video_utils import is_video_file + if not is_video_file(expanded_path): + screen.query_one("#step-description").update( + "File is not a recognizable supported video" + ) + return False else: # directory types if not os.path.isdir(expanded_path): screen.query_one("#step-description").update("Path must be a directory") return False - + return True except Exception as e: screen.query_one("#step-description").update(f"Validation error: {str(e)}") @@ -350,7 +356,14 @@ def render(self, screen, container: Container) -> None: ] options = [(desc, fmt) for fmt, desc in formats] - container.mount(Select(options, value="jpg", id="format-select", classes="field-select")) + container.mount( + OptionSelect( + options, + value="jpg", + id="format-select", + classes="field-select", + ) + ) def validate(self, screen) -> bool: """Validate output format.""" @@ -592,4 +605,4 @@ def validate_width(width_str: str) -> tuple[bool, Optional[int]]: return False, None return True, width except ValueError: - return False, None \ No newline at end of file + return False, None diff --git a/sharp_frames/ui/components/validators.py b/sharp_frames/ui/components/validators.py index b1741a3..89df374 100644 --- a/sharp_frames/ui/components/validators.py +++ b/sharp_frames/ui/components/validators.py @@ -3,12 +3,19 @@ """ import os -from typing import Optional, Set, List +from typing import Optional, List from pathlib import Path from textual.widgets import Input from textual.validation import ValidationResult, Validator from ..utils.path_sanitizer import PathSanitizer +from ...video_utils import ( + AMBIGUOUS_VIDEO_EXTENSIONS, + SUPPORTED_IMAGE_EXTENSIONS, + SUPPORTED_VIDEO_EXTENSIONS, + get_video_files_in_directory, + is_video_file, +) class PathValidator(Validator): @@ -60,11 +67,7 @@ def get_sanitization_changes(self) -> list: class VideoFileValidator(Validator): """Validator specifically for video files with format checking.""" - # Common video file extensions - SUPPORTED_VIDEO_EXTENSIONS = { - '.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm', - '.m4v', '.3gp', '.ogv', '.ts', '.mts', '.m2ts' - } + SUPPORTED_VIDEO_EXTENSIONS = SUPPORTED_VIDEO_EXTENSIONS def __init__(self, must_exist: bool = True): self.must_exist = must_exist @@ -107,6 +110,14 @@ def validate(self, value: str) -> ValidationResult: return self.failure(f"Video file is very small ({file_size} bytes) - may be corrupted") except (OSError, PermissionError): return self.failure("Cannot access video file - check permissions") + + if ( + path.suffix.lower() in AMBIGUOUS_VIDEO_EXTENSIONS + and not is_video_file(str(path)) + ): + return self.failure( + "The .ts file does not contain a recognizable MPEG transport stream" + ) return self.success() @@ -165,18 +176,11 @@ def validate(self, value: str) -> ValidationResult: return self.success() def _find_video_files(self, directory: Path) -> List[Path]: - """Find video files in the given directory.""" - video_extensions = VideoFileValidator.SUPPORTED_VIDEO_EXTENSIONS - video_files = [] - - try: - for file_path in directory.iterdir(): - if file_path.is_file() and file_path.suffix.lower() in video_extensions: - video_files.append(file_path) - except (OSError, PermissionError): - pass - - return video_files + """Find video files using the shared media classifier.""" + return [ + Path(path) + for path in get_video_files_in_directory(str(directory)) + ] def get_sanitized_value(self) -> str: """Get the last sanitized path value.""" @@ -190,11 +194,7 @@ def get_sanitization_changes(self) -> list: class ImageDirectoryValidator(Validator): """Validator for directories containing image files.""" - # Common image file extensions - SUPPORTED_IMAGE_EXTENSIONS = { - '.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', - '.webp', '.gif', '.ppm', '.pgm', '.pbm' - } + SUPPORTED_IMAGE_EXTENSIONS = SUPPORTED_IMAGE_EXTENSIONS def __init__(self, must_exist: bool = True, min_images: int = 1): self.must_exist = must_exist @@ -405,4 +405,4 @@ def create_input_validator(input_type: str, must_exist: bool = True): elif input_type == "directory": return ImageDirectoryValidator(must_exist=must_exist) else: - return PathValidator(must_exist=must_exist) \ No newline at end of file + return PathValidator(must_exist=must_exist) diff --git a/sharp_frames/ui/keyboard.py b/sharp_frames/ui/keyboard.py new file mode 100644 index 0000000..176abb3 --- /dev/null +++ b/sharp_frames/ui/keyboard.py @@ -0,0 +1,40 @@ +"""Shared keyboard behavior for interactive option controls.""" + +from textual.actions import SkipAction +from textual.widget import Widget +from textual.widgets import Checkbox, OptionList, RadioButton, RadioSet, Select + + +class OptionSelect(Select): + """A small option menu where Space selects instead of type-searching.""" + + def on_mount(self) -> None: + """Keep Space available to the screen-level selection binding.""" + overlay = self.query_one(OptionList) + if hasattr(overlay, "_type_to_search"): + overlay._type_to_search = False + + +def select_focused_option(focused: Widget | None) -> None: + """Select or toggle the option represented by the focused control. + + Raising ``SkipAction`` lets unrelated controls, such as text inputs and + buttons, keep their normal Space-key behavior. + """ + if isinstance(focused, Select): + focused.action_show_overlay() + return + + if isinstance(focused, OptionList): + focused.action_select() + return + + if isinstance(focused, (Checkbox, RadioButton)): + focused.toggle() + return + + if isinstance(focused, RadioSet): + focused.action_toggle_button() + return + + raise SkipAction diff --git a/sharp_frames/ui/screens/configuration.py b/sharp_frames/ui/screens/configuration.py index 1096f6a..5ca3715 100644 --- a/sharp_frames/ui/screens/configuration.py +++ b/sharp_frames/ui/screens/configuration.py @@ -3,22 +3,18 @@ Removes selection method configuration (moved to post-extraction SelectionScreen). """ -import os -from typing import Dict, Any - +from rich.text import Text from textual.app import ComposeResult from textual.containers import Container, Horizontal -from textual.widgets import ( - Header, Footer, Button, Input, Select, RadioSet, RadioButton, - Checkbox, Label, Static -) +from textual.css.query import NoMatches +from textual.widgets import Header, Footer, Button, Input, Label, Static from textual.screen import Screen from textual.binding import Binding -from textual.events import Key from ..utils import sanitize_path_input +from ..keyboard import select_focused_option -from ..constants import UIElementIds, InputTypes +from ..constants import UIElementIds from ..components.step_handlers import ( InputTypeStepHandler, InputPathStepHandler, @@ -32,6 +28,147 @@ from ..components.validators import ValidationHelpers +class AsciiTitleShimmer(Static): + """Run a single diagonal colour shimmer across the full ASCII title.""" + + INITIAL_DELAY_SECONDS = 0.16 + FRAME_INTERVAL_SECONDS = 0.045 + _ROW_OFFSET = 2 + _POSITION_STEP = 5 + _OUTER_BAND_WIDTH = 3 + _EDGE_COLOR = "#55A9FF" + _MID_COLOR = "#8DCCFF" + _CORE_COLOR = "#D6EEFF" + + def __init__(self, title_markup: str) -> None: + base_text = Text.from_markup(title_markup) + title_rows = self._title_rows(base_text.plain) + self.frames = self._build_frames( + base_text, + title_rows, + ) + super().__init__(self.frames[0], id="ascii-title", classes="title") + + @staticmethod + def _title_rows(plain_title: str) -> list[tuple[int, str]]: + """Return global offsets and visible content for the six logo rows.""" + rows: list[tuple[int, str]] = [] + offset = 0 + for line in plain_title.splitlines(keepends=True): + visible_line = line.rstrip("\r\n") + if visible_line.strip(): + rows.append((offset, visible_line)) + offset += len(line) + return rows + + @classmethod + def _build_frames( + cls, + base_text: Text, + title_rows: list[tuple[int, str]], + ) -> tuple[Text, ...]: + """Build fixed-layout colour frames for one diagonal shimmer pass.""" + title_width = max(len(row) for _, row in title_rows) + final_position = ( + title_width - 1 + + (len(title_rows) - 1) * cls._ROW_OFFSET + + cls._OUTER_BAND_WIDTH + ) + positions = range( + -cls._OUTER_BAND_WIDTH, + final_position + cls._POSITION_STEP, + cls._POSITION_STEP, + ) + shimmer_frames = tuple( + cls._build_shimmer_frame( + base_text, + title_rows, + position, + ) + for position in positions + ) + return (base_text.copy(), *shimmer_frames, base_text.copy()) + + @classmethod + def _build_shimmer_frame( + cls, + base_text: Text, + title_rows: list[tuple[int, str]], + position: int, + ) -> Text: + """Overlay one three-tone diagonal band without changing any glyphs.""" + frame = base_text.copy() + for row_index, (row_offset, row) in enumerate(title_rows): + for column in range(len(row)): + if row[column].isspace(): + continue + diagonal_position = column + row_index * cls._ROW_OFFSET + distance = abs(diagonal_position - position) + color = cls._color_for_distance(distance) + if color is not None: + character_offset = row_offset + column + frame.stylize( + color, + character_offset, + character_offset + 1, + ) + return frame + + @classmethod + def _color_for_distance(cls, distance: int) -> str | None: + """Return a soft edge, mid tone, or pale core for the shimmer band.""" + if distance == 0: + return cls._CORE_COLOR + if distance == 1: + return cls._MID_COLOR + if distance <= cls._OUTER_BAND_WIDTH: + return cls._EDGE_COLOR + return None + + def on_mount(self) -> None: + """Schedule one pass, respecting Textual's reduced-animation setting.""" + if self.app.animation_level == "none": + return + + self.set_timer( + self.INITIAL_DELAY_SECONDS, + self._start_shimmer, + name="title-shimmer-start", + ) + + def _start_shimmer(self) -> None: + """Advance frames on one interval timer so they can never reorder. + + Independent per-frame timers whose deadlines sit closer together + than the platform timer resolution (notably Windows) may fire out + of order, ending the pass on a shimmer frame instead of the title. + """ + self._show_frame(1) + self._next_frame_index = 2 + self._shimmer_timer = self.set_interval( + self.FRAME_INTERVAL_SECONDS, + self._advance_frame, + name="title-shimmer", + ) + + def _advance_frame(self) -> None: + """Show the next frame and stop the interval after the last one. + + A tick that was already queued when the timer stopped may still + invoke this callback once more, so it no-ops after the last frame. + """ + if self._next_frame_index > len(self.frames) - 1: + return + self._show_frame(self._next_frame_index) + if self._next_frame_index >= len(self.frames) - 1: + self._shimmer_timer.stop() + self._next_frame_index += 1 + + def _show_frame(self, frame_index: int) -> None: + """Swap colour spans without recalculating the stable title layout.""" + self.update(self.frames[frame_index], layout=False) + + class ConfigurationForm(Screen): """Configuration form for Sharp Frames processing (selection method removed).""" @@ -39,7 +176,16 @@ class ConfigurationForm(Screen): Binding("escape", "cancel", "Cancel"), Binding("ctrl+c", "cancel", "Cancel"), Binding("f1", "help", "Help", show=True), - Binding("enter", "next_step", "Next", show=False), + Binding( + "enter", "next_step", "Next", show=False, priority=True + ), + Binding( + "space", + "select_current_option", + "Select", + show=False, + priority=True, + ), ] def __init__(self): @@ -91,18 +237,22 @@ def compose(self) -> ComposeResult: ███████[#2575E6]║[/#2575E6]██[#2575E6]║[/#2575E6] ██[#2575E6]║[/#2575E6]██[#2575E6]║[/#2575E6] ██[#2575E6]║[/#2575E6]██[#2575E6]║[/#2575E6] ██[#2575E6]║[/#2575E6]██[#2575E6]║[/#2575E6] ██[#2575E6]║[/#2575E6] ██[#2575E6]║[/#2575E6] ██[#2575E6]║[/#2575E6]██[#2575E6]║[/#2575E6] ██[#2575E6]║[/#2575E6]██[#2575E6]║[/#2575E6] [#2575E6]╚═╝[/#2575E6] ██[#2575E6]║[/#2575E6]███████[#2575E6]╗[/#2575E6]███████[#2575E6]║[/#2575E6] [#2575E6]╚══════╝╚═╝[/#2575E6] [#2575E6]╚═╝╚═╝[/#2575E6] [#2575E6]╚═╝╚═╝[/#2575E6] [#2575E6]╚═╝╚═╝[/#2575E6] [#2575E6]╚═╝[/#2575E6] [#2575E6]╚═╝[/#2575E6] [#2575E6]╚═╝╚═╝[/#2575E6] [#2575E6]╚═╝╚═╝[/#2575E6] [#2575E6]╚═╝╚══════╝╚══════╝[/#2575E6] """ - yield Static(ascii_title, classes="title") - yield Static("", id="step-info", classes="step-info") - yield Static("", id="step-description", classes="step-description") - - with Container(id="main-container"): - yield Container(id="step-container") - - with Horizontal(classes="buttons"): - yield Button("Back", variant="default", id="back-btn", disabled=True) - yield Button("Next", variant="primary", id="next-btn") - yield Button("Cancel", variant="default", id="cancel-btn") - + # The whole form block (logo, step info, inputs, and its buttons) is + # vertically centred as one unit, with the CTAs sitting under the + # controls rather than docked to the bottom of the screen. + with Container(id="form-sequence"): + with Container(id="main-container"): + yield AsciiTitleShimmer(ascii_title) + yield Static("", id="step-info", classes="step-info") + yield Static("", id="step-description", classes="step-description") + yield Container(id="step-container") + with Horizontal(classes="buttons"): + yield Button( + "Back", variant="default", id="back-btn", disabled=True + ) + yield Button("Next", variant="primary", id="next-btn") + yield Button("Cancel", variant="default", id="cancel-btn") + yield Footer() def on_mount(self) -> None: @@ -124,10 +274,6 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif event.button.id == UIElementIds.CANCEL_BTN: self.action_cancel() - def on_input_submitted(self, event: Input.Submitted) -> None: - """Handle Enter key in Input fields - progress to next step.""" - self._next_step() - def on_radio_set_changed(self, event) -> None: """Handle RadioSet selection change - allow Enter to progress.""" # Don't auto-progress on selection change, just allow Enter to work @@ -192,9 +338,9 @@ def show_current_step(self) -> None: def _focus_step_widget(self) -> None: """Set focus to the main widget for the current step.""" step = self.steps[self.current_step] - step_container = self.query_one("#step-container") - + try: + step_container = self.query_one("#step-container") # Focus based on step type if step == "input_type": # Focus the RadioSet @@ -287,7 +433,7 @@ def _clear_error(self) -> None: try: error_widget = self.query_one(".error-message") error_widget.remove() - except: + except NoMatches: pass # No error message to remove def _show_error(self, message: str) -> None: @@ -300,25 +446,16 @@ def _show_error(self, message: str) -> None: print(f"Failed to show error message: {e}") def action_next_step(self) -> None: - """Move to next step when Enter is pressed.""" - # Don't progress if an Input widget has focus - let it handle Enter - focused = self.app.focused - if focused and focused.__class__.__name__ == "Input": - return # Let the Input widget handle Enter - - # For RadioSet and RadioButton, Enter selects the option but we also want to progress - # Check if we're on a step with RadioSet/RadioButton - if focused and focused.__class__.__name__ in ["RadioSet", "RadioButton"]: - # Still progress to next step - self._next_step() - return - - # Otherwise, progress to next step + """Move to the next step regardless of the focused control.""" self._next_step() + + def action_select_current_option(self) -> None: + """Use Space to select the option under the current focus.""" + select_focused_option(self.app.focused) def action_cancel(self) -> None: """Cancel the configuration and exit - same as legacy.""" - self.app.pop_screen() + self.app.exit(result="cancelled") def action_process(self) -> None: """Start processing with the collected configuration - transition to processing.""" @@ -358,7 +495,7 @@ def action_help(self) -> None: ## Selection Process After configuration, frames will be extracted and analyzed. You'll then see an interactive selection screen where you can: -- Choose selection method (Best N, Batched, Outlier Removal) +- Choose selection method (Best N, Batched, Outlier Detection) - Adjust parameters with real-time preview - See exactly how many frames will be selected @@ -369,5 +506,3 @@ def action_help(self) -> None: def get_current_step_name(self) -> str: """Get the name of the current step.""" return self.steps[self.current_step] - - diff --git a/sharp_frames/ui/screens/configuration_original.py b/sharp_frames/ui/screens/configuration_original.py index 19dfeb8..3ebd960 100644 --- a/sharp_frames/ui/screens/configuration_original.py +++ b/sharp_frames/ui/screens/configuration_original.py @@ -246,7 +246,7 @@ def _create_selection_method_step(self, container) -> None: select_widget = Select([ ("Best N frames - Choose a specific number of frames", "best-n"), ("Batched selection - Best frame from each batch", "batched"), - ("Outlier removal - Remove the blurriest frames", "outlier-removal") + ("Outlier detection - Reject local sharpness outliers", "outlier-removal") ], value=self.config_data.get("selection_method", "best-n"), id="selection-method-field") container.mount(select_widget) @@ -306,17 +306,17 @@ def _create_method_params_step(self, container) -> None: input1.focus() elif method == "outlier-removal": - container.mount(Label("Outlier Removal Configuration:", classes="question")) + container.mount(Label("Outlier Detection Configuration:", classes="question")) container.mount(Label("Window size for comparison:")) input1 = Input( value=str(self.config_data.get("outlier_window_size", 15)), - validators=[IntRangeValidator(min_value=3, max_value=30)], + validators=[IntRangeValidator(min_value=5, max_value=31)], id="param1" ) container.mount(input1) container.mount(Label("Sensitivity (0-100, higher = more aggressive):")) input2 = Input( - value=str(self.config_data.get("outlier_sensitivity", 50)), + value=str(self.config_data.get("outlier_sensitivity", 60)), validators=[IntRangeValidator(min_value=0, max_value=100)], id="param2" ) @@ -387,7 +387,7 @@ def _build_config_summary(self) -> str: lines.append(f" Batch buffer: {self.config_data.get('batch_buffer', 2)}") elif method == "outlier-removal": lines.append(f" Window size: {self.config_data.get('outlier_window_size', 15)}") - lines.append(f" Sensitivity: {self.config_data.get('outlier_sensitivity', 50)}") + lines.append(f" Sensitivity: {self.config_data.get('outlier_sensitivity', 60)}") # Only show output format and resize options for non-directory modes input_type = self.config_data.get("input_type", InputTypes.VIDEO) @@ -559,8 +559,8 @@ def _save_current_step(self) -> bool: elif method == "outlier-removal": window_size = int(value1) sensitivity = int(value2) - if window_size < 3: - self._show_error(step_container, "Window size must be at least 3") + if window_size < 5: + self._show_error(step_container, "Window size must be at least 5") return False if sensitivity < 0 or sensitivity > 100: self._show_error(step_container, "Sensitivity must be between 0 and 100") @@ -691,7 +691,7 @@ def _prepare_final_config(self) -> Dict[str, Any]: config["batch_size"] = 5 config["batch_buffer"] = 2 config["outlier_window_size"] = 15 - config["outlier_sensitivity"] = 50 + config["outlier_sensitivity"] = 60 # Override with method-specific values if selection_method == "best-n": @@ -702,6 +702,6 @@ def _prepare_final_config(self) -> Dict[str, Any]: config["batch_buffer"] = self.config_data.get("batch_buffer", 2) elif selection_method == "outlier-removal": config["outlier_window_size"] = self.config_data.get("outlier_window_size", 15) - config["outlier_sensitivity"] = self.config_data.get("outlier_sensitivity", 50) + config["outlier_sensitivity"] = self.config_data.get("outlier_sensitivity", 60) - return config \ No newline at end of file + return config diff --git a/sharp_frames/ui/screens/processing.py b/sharp_frames/ui/screens/processing.py index 86b9b69..b6a22e9 100644 --- a/sharp_frames/ui/screens/processing.py +++ b/sharp_frames/ui/screens/processing.py @@ -5,16 +5,19 @@ import threading import logging import traceback -import os -from typing import Dict, Any, Optional +from typing import Dict, Any +from rich.text import Text from textual.app import ComposeResult from textual.containers import Container -from textual.widgets import Header, Footer, Button, Static, ProgressBar +from textual.reactive import reactive from textual.screen import Screen from textual.binding import Binding +from textual.widget import Widget +from textual.widgets import Header, Footer, Button, Static +from textual.worker import WorkerState -from ..constants import WorkerNames, ProcessingPhases +from ..constants import WorkerNames from ..utils import ErrorContext from .selection import SelectionScreen @@ -22,8 +25,50 @@ logger = logging.getLogger(__name__) +class BlockProgressBar(Widget): + """A full-cell progress bar with a compact percentage label.""" + + progress = reactive(0.0) + total = 100.0 + + COMPONENT_CLASSES = { + "block-progress--filled", + "block-progress--empty", + "block-progress--percentage", + } + + def update(self, *, progress: float) -> None: + """Update progress on a fixed zero-to-one-hundred scale.""" + self.progress = max(0.0, min(float(progress), self.total)) + + def render(self) -> Text: + """Render a solid, full-height cell track and its percentage.""" + percentage = self.progress / self.total if self.total else 0.0 + label = f" {self.progress:.0f}%".rjust(6) + label_width = min(len(label), self.size.width) + bar_width = max(self.size.width - label_width, 0) + filled_width = round(bar_width * percentage) + + result = Text() + result.append( + " " * filled_width, + self.get_component_rich_style("block-progress--filled"), + ) + result.append( + " " * (bar_width - filled_width), + self.get_component_rich_style("block-progress--empty"), + ) + result.append( + label[-label_width:] if label_width else "", + self.get_component_rich_style("block-progress--percentage"), + ) + return result + + class ProcessingScreen(Screen): """Screen for two-phase processing (extraction/analysis → interactive selection).""" + + PHASE_LABEL = "Phase 1 of 2" BINDINGS = [ Binding("ctrl+c", "cancel", "Cancel Processing"), @@ -67,20 +112,39 @@ def phase_1_complete(self) -> bool: @phase_1_complete.setter def phase_1_complete(self, value: bool) -> None: - """Thread-safe setter for phase 1 complete state.""" + """Thread-safe setter for whether phase one is terminal.""" with self._state_lock: self._phase_1_complete = value - + + def _set_terminal_state( + self, + status: str, + phase: str, + *, + detail: str = "", + progress: float = 0, + ) -> None: + """Mark phase one terminal and present a closeable screen.""" + self.phase_1_complete = True + self.query_one("#status-text", Static).update(status) + self.query_one("#phase-text", Static).update(phase) + detail_text = self.query_one("#detail-text", Static) + detail_text.update(detail) + detail_text.display = bool(detail) + self.query_one("#progress-bar", BlockProgressBar).update(progress=progress) + close_button = self.query_one("#cancel-processing", Button) + close_button.label = "Close" + close_button.disabled = False + def compose(self) -> ComposeResult: """Create the processing layout.""" logger.info("TwoPhaseProcessingScreen compose() called") yield Header() with Container(id="processing-container"): - yield Static("Sharp Frames - Two Phase Processing", classes="title") yield Static("", id="status-text") yield Static("", id="phase-text") - yield ProgressBar(id="progress-bar", show_eta=False) + yield BlockProgressBar(id="progress-bar") yield Static("", id="detail-text", classes="detail") yield Button("Cancel", variant="default", id="cancel-processing") @@ -107,17 +171,22 @@ def start_phase_1_processing(self) -> None: # Validate configuration if not self._validate_config(self.config): logger.error("Configuration validation failed") - status_text.update("❌ Configuration validation failed") - phase_text.update("Please check your settings and try again.") - self.query_one("#cancel-processing").label = "Close" + self._set_terminal_state( + "Configuration needs attention", + "Unable to start processing", + detail="Please check your settings and try again.", + ) return logger.info("Configuration validation passed") # Show Phase 1 initialization - status_text.update("🔄 Phase 1: Initializing extraction and analysis...") - phase_text.update("Preparing to process frames...") - detail_text.update("This may take a few minutes depending on input size.") + status_text.update(f"Preparing frames · {self.PHASE_LABEL}") + phase_text.update("Starting extraction and analysis…") + detail_text.display = True + detail_text.update( + "This may take a few minutes depending on the input size." + ) progress_bar.update(progress=0) logger.info("Starting Phase 1 worker thread...") @@ -130,8 +199,10 @@ def start_phase_1_processing(self) -> None: except Exception as e: logger.error(f"Error in start_phase_1_processing(): {e}") logger.error(traceback.format_exc()) - self.query_one("#status-text").update(f"❌ Error starting processing: {str(e)}") - self.query_one("#cancel-processing").label = "Close" + self._set_terminal_state( + "Error starting processing", + str(e), + ) def _validate_config(self, config: Dict[str, Any]) -> bool: """Validate configuration for Phase 1 processing.""" @@ -157,7 +228,9 @@ def _validate_config(self, config: Dict[str, Any]) -> bool: # Check system dependencies try: - dependency_error = ErrorContext.check_system_dependencies() + dependency_error = ErrorContext.check_system_dependencies( + require_video_tools=config.get('input_type') in {'video', 'video_directory'} + ) if dependency_error: logger.error(f"System dependency error: {dependency_error}") return False @@ -177,7 +250,11 @@ def _process_phase_1(self) -> bool: try: logger.info("Creating TUIProcessor...") self.processor = TUIProcessor() - + + if self.processing_cancelled: + self.processor.cancel_processing() + logger.info("Phase 1 was cancelled before processor startup completed") + return False logger.info("Starting extraction and analysis...") # Create progress callback @@ -197,7 +274,11 @@ def progress_callback(phase, current, total, description): # Run Phase 1: extract and analyze with progress callback self.extraction_result = self.processor.extract_and_analyze(self.config, progress_callback) - + + if self.processing_cancelled: + logger.info("Phase 1 completed after cancellation was requested") + return False + if not self.extraction_result or not self.extraction_result.frames: logger.error("Phase 1 completed but no frames were extracted") self.app.call_from_thread( @@ -219,11 +300,7 @@ def progress_callback(phase, current, total, description): except Exception as e: logger.error(f"Error in Phase 1 processing: {e}") logger.error(traceback.format_exc()) - self.app.call_from_thread( - self._update_progress_ui, - "error", 0, 100, 0, f"Error: {str(e)}" - ) - return False + raise def _update_progress_ui(self, phase: str, current: int, total: int, total_progress: float, description: str): """Update the UI with progress information.""" @@ -241,107 +318,112 @@ def _update_progress_ui(self, phase: str, current: int, total: int, total_progre progress_bar = self.query_one("#progress-bar") detail_text = self.query_one("#detail-text") + count = ( + f"{current:,} of {total:,} frames" + if total > 0 + else "Working…" + ) + if phase == "extraction": - status_text.update("🔄 Phase 1: Extracting frames...") - phase_text.update(f"Progress: {current}/{total}" if total > 0 else "Extracting frames...") - + status_text.update(f"Extracting frames · {self.PHASE_LABEL}") + phase_text.update(count) + detail_text.update("") + detail_text.display = False + elif phase == "analysis" or "sharpness" in phase.lower(): - status_text.update("🔍 Phase 1: Analyzing frame sharpness...") - phase_text.update(f"Analyzing: {current}/{total}" if total > 0 else "Calculating sharpness scores...") - + status_text.update( + f"Analysing frame sharpness · {self.PHASE_LABEL}" + ) + phase_text.update(count) + detail_text.update("") + detail_text.display = False + elif phase == "complete": - status_text.update("✅ Phase 1 Complete!") + status_text.update(f"Analysis complete · {self.PHASE_LABEL}") phase_text.update("Ready for interactive selection") - + detail_text.update("") + detail_text.display = False + elif phase == "error": - status_text.update("❌ Phase 1 Failed") - phase_text.update("Processing error occurred") - - # Update progress bar and detail + status_text.update("Processing failed") + phase_text.update("Frame preparation stopped") + detail_text.update(description) + detail_text.display = True + + else: + status_text.update(f"Processing frames · {self.PHASE_LABEL}") + phase_text.update(count) + detail_text.update(description) + detail_text.display = bool(description) + progress_bar.update(progress=total_progress) - detail_text.update(description) except Exception as e: logger.error(f"Error updating progress UI: {e}") def on_worker_state_changed(self, event) -> None: - """Handle worker state changes.""" - if event.worker.name != f"{WorkerNames.FRAME_PROCESSOR}_phase1": + """Handle terminal states from the phase-one worker.""" + worker = event.worker + if worker.name != f"{WorkerNames.FRAME_PROCESSOR}_phase1": return - - logger.info(f"Phase 1 worker state changed: {event.worker.state}") - - status_text = self.query_one("#status-text") - phase_text = self.query_one("#phase-text") - progress_bar = self.query_one("#progress-bar") - - if event.worker.is_finished: - logger.info(f"Phase 1 worker finished with result: {event.worker.result}") - - if event.worker.result and self.extraction_result: - # Phase 1 completed successfully + + state = worker.state + logger.info(f"Phase 1 worker state changed: {state}") + if state == WorkerState.SUCCESS: + result = worker.result + logger.info(f"Phase 1 worker finished with result: {result}") + if self.processing_cancelled: + self._set_terminal_state( + "Processing cancelled", + "No frames were saved", + ) + elif result and self.extraction_result: self.phase_1_complete = True - logger.info("Phase 1 completed successfully, transitioning to selection screen") - - status_text.update("✅ Phase 1 Complete - Transitioning to selection...") - phase_text.update("Opening interactive selection screen...") - progress_bar.update(progress=100) - - # Transition to selection screen + self.query_one("#status-text", Static).update("Analysis complete") + self.query_one("#phase-text", Static).update( + "Opening interactive selection…" + ) + self.query_one("#progress-bar", BlockProgressBar).update( + progress=100 + ) self._transition_to_selection_screen() - else: - # Phase 1 failed - logger.error("Phase 1 failed") - if self.processing_cancelled: - status_text.update("⚠️ Phase 1 cancelled by user.") - phase_text.update("Processing was cancelled.") - else: - status_text.update("❌ Phase 1 failed.") - phase_text.update("Frame extraction or analysis failed.") - - progress_bar.update(progress=0) - self.query_one("#cancel-processing").label = "Close" - - elif event.worker.is_cancelled: + self._set_terminal_state( + "Processing failed", + "Frame extraction or analysis did not complete", + ) + return + + if state == WorkerState.CANCELLED: logger.info("Phase 1 worker was cancelled") - self.phase_1_complete = True - status_text.update("⚠️ Phase 1 cancelled.") - phase_text.update("Processing was cancelled.") - progress_bar.update(progress=0) - self.query_one("#cancel-processing").label = "Close" - - def on_worker_state_error(self, event) -> None: - """Handle worker errors.""" - if event.worker.name != f"{WorkerNames.FRAME_PROCESSOR}_phase1": + self._set_terminal_state( + "Processing cancelled", + "No frames were saved", + ) return - - logger.error(f"Phase 1 worker error: {event.error}") - - self.phase_1_complete = True - self.last_error = event.error - - # Analyze error and provide user-friendly message - error_msg = "Unknown error occurred" - if event.error: - error_msg = ErrorContext.analyze_processing_failure(self.config, event.error) - - # Log detailed error - if hasattr(event.error, '__traceback__'): - error_details = ''.join(traceback.format_exception( - type(event.error), event.error, event.error.__traceback__ - )) - logger.error(f"Detailed error traceback:\n{error_details}") - - # Update UI - status_text = self.query_one("#status-text") - phase_text = self.query_one("#phase-text") - progress_bar = self.query_one("#progress-bar") - - status_text.update(f"❌ Phase 1 Error") - phase_text.update(error_msg) - progress_bar.update(progress=0) - self.query_one("#cancel-processing").label = "Close" + + if state == WorkerState.ERROR: + error = worker.error + logger.error(f"Phase 1 worker error: {error}") + self.last_error = error + error_msg = "Unknown error occurred" + if error is not None: + error_msg = ErrorContext.analyze_processing_failure( + self.config, error + ) + if hasattr(error, "__traceback__"): + error_details = "".join( + traceback.format_exception( + type(error), error, error.__traceback__ + ) + ) + logger.error( + f"Detailed error traceback:\n{error_details}" + ) + self._set_terminal_state( + "Processing error", + error_msg, + ) def _transition_to_selection_screen(self) -> None: """Transition to the interactive selection screen.""" @@ -361,69 +443,46 @@ def _transition_to_selection_screen(self) -> None: except Exception as e: logger.error(f"Error transitioning to selection screen: {e}") logger.error(traceback.format_exc()) - - # Fallback error display - status_text = self.query_one("#status-text") - status_text.update(f"❌ Error opening selection screen: {str(e)}") - self.query_one("#cancel-processing").label = "Close" + self._set_terminal_state( + "Error opening selection screen", + str(e), + ) def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "cancel-processing": - if self.phase_1_complete: - # Processing is complete, this is now a "Close" button - self.app.pop_screen() - else: - # Cancel processing - self.action_cancel() - + self.action_cancel() + def action_cancel(self) -> None: - """Cancel the current processing.""" + """Close a terminal screen or request cooperative cancellation.""" + if self.phase_1_complete: + self.app.pop_screen() + return + if self.processing_cancelled: + return + logger.info("Cancelling Phase 1 processing") - - # Set cancellation flag self.processing_cancelled = True - - # Cancel processor operations - if self.processor and hasattr(self.processor, 'cancel_processing'): - try: - self.processor.cancel_processing() - logger.info("Processor cancellation requested") - except Exception as e: - logger.error(f"Error cancelling processor: {e}") - - # Cancel any running workers - try: - for worker in self.workers: - if not worker.is_finished: - worker.cancel() - logger.info(f"Worker {worker.name} cancellation requested") - except Exception as e: - logger.error(f"Error cancelling workers: {e}") - - # Update UI + try: - status_text = self.query_one("#status-text") - phase_text = self.query_one("#phase-text") - progress_bar = self.query_one("#progress-bar") - - status_text.update("⚠️ Cancelling...") - phase_text.update("Please wait while processing stops...") - progress_bar.update(progress=0) - + self.query_one("#status-text", Static).update("Cancelling…") + self.query_one("#phase-text", Static).update( + "Stopping background work safely…" + ) + self.query_one("#progress-bar", BlockProgressBar).update(progress=0) + self.query_one("#cancel-processing", Button).disabled = True except Exception as e: logger.error(f"Error updating UI during cancellation: {e}") - - # Close the screen after a short delay to allow cleanup - def delayed_close(): + + if self.processor and hasattr(self.processor, "cancel_processing"): try: - self.app.pop_screen() + self.processor.cancel_processing() + logger.info("Processor cancellation requested") except Exception as e: - logger.error(f"Error closing screen: {e}") - - # Schedule delayed close (give 2 seconds for cleanup) - import threading - threading.Timer(2.0, delayed_close).start() + logger.error(f"Error cancelling processor: {e}") + + # The thread worker remains active until extraction and temporary-file + # cleanup finish, then its terminal state makes this screen closeable. def on_unmount(self) -> None: """Clean up when screen is unmounted.""" diff --git a/sharp_frames/ui/screens/selection.py b/sharp_frames/ui/screens/selection.py index c1ad82a..8ec6af0 100644 --- a/sharp_frames/ui/screens/selection.py +++ b/sharp_frames/ui/screens/selection.py @@ -8,9 +8,13 @@ from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Container, Horizontal +from textual.css.query import NoMatches +from textual.events import Click, Resize +from textual.geometry import Size from textual.message import Message from textual.reactive import reactive from textual.screen import Screen +from textual.scroll_view import ScrollView from textual.strip import Strip from textual.widget import Widget from textual.widgets import ( @@ -22,109 +26,384 @@ from ...models.frame_data import ExtractionResult, FrameData from ...processing.tui_processor import TUIProcessor +from ..components.raster_preview import RasterImagePreview +from ..keyboard import OptionSelect, select_focused_option +from ..utils.image_opener import open_image_file -class SharpnessChart(Widget): - """Bar chart widget to display sharpness scores and selection status.""" - +class SharpnessChart(ScrollView): + """Horizontally scrollable frame-by-frame sharpness timeline.""" + + FRAME_STRIDE = 2 + BAR_GLYPHS = (" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█") + VERTICAL_SUBDIVISIONS = len(BAR_GLYPHS) - 1 + can_focus = True + + BINDINGS = [ + Binding("left", "scroll_left", "Scroll Left", show=False), + Binding("right", "scroll_right", "Scroll Right", show=False), + Binding("pageup", "previous_selected", "Previous Selected", show=False), + Binding("pagedown", "next_selected", "Next Selected", show=False), + Binding("ctrl+pageup", "page_left", "Page Left", show=False), + Binding("ctrl+pagedown", "page_right", "Page Right", show=False), + Binding("o", "open_inspected", "Open Frame", show=False), + Binding("home", "first_frame", "First Frame", show=False), + Binding("end", "last_frame", "Last Frame", show=False), + ] + + COMPONENT_CLASSES = { + "sharpness-chart--background", + "sharpness-chart--selected", + "sharpness-chart--unselected", + "sharpness-chart--inspected", + "sharpness-chart--title", + } + DEFAULT_CSS = """ SharpnessChart { - height: 12; + height: 1fr; + min-height: 13; width: 100%; border: solid $primary; margin: 1 0; + overflow-x: auto; + overflow-y: hidden; + background: $background; + } + + SharpnessChart:focus { + border: tall $accent; + } + + SharpnessChart .sharpness-chart--background { + color: $background; + background: $background; + } + + SharpnessChart .sharpness-chart--selected { + color: $primary-lighten-2; + background: $background; + text-style: bold; + } + + SharpnessChart .sharpness-chart--unselected { + color: $text-muted; + background: $background; + } + + SharpnessChart .sharpness-chart--inspected { + color: $warning; + background: $background; + text-style: bold; + } + + SharpnessChart .sharpness-chart--title { + color: $primary; + background: $background; + text-style: bold; } """ - - def __init__(self, frames: List[FrameData], selected_indices: set = None, max_frames: int = 100, **kwargs): + + def __init__( + self, + frames: List[FrameData], + selected_indices: Optional[set[int]] = None, + **kwargs, + ) -> None: super().__init__(**kwargs) - self.frames = frames[:max_frames] # Only show first 100 frames - self.selected_indices = selected_indices or set() - self.max_frames = max_frames - - # Calculate min and max sharpness for normalization + self.frames = frames + self.selected_indices = set(selected_indices or ()) + self.inspected_index: Optional[int] = None + self.inspected_position: Optional[int] = None + self.timeline_width = max(len(frames) * self.FRAME_STRIDE, 1) + self.virtual_size = Size(self.timeline_width, 1) + self.border_title = "Frame selection" + self.border_subtitle = ( + "Click inspect | Arrows scroll | PgUp/PgDn selected | " + "Ctrl+PgUp/PgDn page | Home/End" + ) + if self.frames: scores = [f.sharpness_score for f in self.frames] self.min_score = min(scores) self.max_score = max(scores) - self.score_range = self.max_score - self.min_score if self.max_score > self.min_score else 1 + self.score_range = max(self.max_score - self.min_score, 1) else: self.min_score = 0 self.max_score = 1 self.score_range = 1 - - def update_selection(self, selected_indices: set): + + def on_resize(self, _event: Resize) -> None: + """Keep the virtual canvas as tall as the visible chart.""" + scrollbar_rows = int(self.timeline_width > self.size.width) + self.virtual_size = Size( + self.timeline_width, + max(self.size.height - scrollbar_rows, 1), + ) + + def update_selection(self, selected_indices: set[int]) -> None: """Update the selection status and refresh the chart.""" - self.selected_indices = selected_indices + self.selected_indices = set(selected_indices) self.refresh() - - def render_line(self, y: int) -> "Strip": - """Render a single line of the chart.""" - from textual.strip import Strip - + + class FrameInspectRequested(Message): + """Request that the owning screen display a frame for inspection.""" + + def __init__(self, frame: FrameData) -> None: + self.frame = frame + super().__init__() + + class FrameOpenRequested(Message): + """Request that the owning screen open an inspected frame externally.""" + + def __init__(self, frame: FrameData) -> None: + self.frame = frame + super().__init__() + + def on_click(self, event: Click) -> None: + """Open the frame represented by a clicked chart column.""" + content_offset = event.get_content_offset(self) + if content_offset is None or content_offset.y == 0: + return + + virtual_column = int(self.scroll_offset.x) + content_offset.x + frame_position = virtual_column // self.FRAME_STRIDE + if not 0 <= frame_position < len(self.frames): + return + + self._inspect_frame(frame_position) + event.stop() + + def _inspect_frame(self, frame_position: int) -> None: + """Select a frame for inspection and keep its chart bar visible.""" + if not 0 <= frame_position < len(self.frames): + return + + frame = self.frames[frame_position] + self.inspected_position = frame_position + self.inspected_index = frame.index + self.border_subtitle = ( + "Arrows inspect | PgUp/PgDn selected | " + "Ctrl+PgUp/PgDn page | O open | Home/End" + ) + self._scroll_frame_into_view(frame_position) + self.refresh() + self.post_message(self.FrameInspectRequested(frame)) + + def _scroll_frame_into_view(self, frame_position: int) -> None: + """Scroll just enough to reveal the inspected frame's complete slot.""" + viewport_width = max(self.scrollable_content_region.width, 1) + scroll_x = int(self.scroll_offset.x) + frame_left = frame_position * self.FRAME_STRIDE + frame_right = frame_left + self.FRAME_STRIDE + + if frame_left < scroll_x: + self.scroll_to(x=frame_left, animate=False) + elif frame_right > scroll_x + viewport_width: + self.scroll_to( + x=frame_right - viewport_width, + animate=False, + ) + + def action_first_frame(self) -> None: + """Scroll directly to the beginning of the analyzed timeline.""" + self.scroll_to(x=0, animate=False) + + def action_scroll_left(self) -> None: + """Inspect the previous frame, or scroll before inspection begins.""" + if self.inspected_position is not None: + self._inspect_frame(self.inspected_position - 1) + return + self.scroll_relative(x=-self.FRAME_STRIDE, animate=False) + + def action_scroll_right(self) -> None: + """Inspect the next frame, or scroll before inspection begins.""" + if self.inspected_position is not None: + self._inspect_frame(self.inspected_position + 1) + return + self.scroll_relative(x=self.FRAME_STRIDE, animate=False) + + def _inspect_selected_frame(self, direction: int) -> None: + """Inspect the nearest selected frame in the requested direction.""" + if self.inspected_position is None: + positions = ( + range(len(self.frames)) + if direction > 0 + else range(len(self.frames) - 1, -1, -1) + ) + else: + positions = range( + self.inspected_position + direction, + len(self.frames) if direction > 0 else -1, + direction, + ) + + for position in positions: + if self.frames[position].index in self.selected_indices: + self._inspect_frame(position) + return + + def action_previous_selected(self) -> None: + """Inspect the previous frame retained by the current selection.""" + self._inspect_selected_frame(-1) + + def action_next_selected(self) -> None: + """Inspect the next frame retained by the current selection.""" + self._inspect_selected_frame(1) + + def action_open_inspected(self) -> None: + """Open the currently inspected frame in the default image viewer.""" + if self.inspected_position is None: + return + self.post_message( + self.FrameOpenRequested(self.frames[self.inspected_position]) + ) + + def action_last_frame(self) -> None: + """Scroll directly to the end of the analyzed timeline.""" + self.scroll_to(x=self.max_scroll_x, animate=False) + + def render_line(self, y: int) -> Strip: + """Render only the visible frame window at the current scroll offset.""" + blank_style = self.get_component_rich_style( + "sharpness-chart--background" + ) width = self.size.width - height = self.size.height - 2 # Account for border - - if not self.frames or width < 10 or height < 1: - return Strip([Segment(" " * width)]) - - # First line is the title + viewport_height = self.scrollable_content_region.height + if width < 1 or y >= viewport_height: + return Strip([], 0) + if y == 0: - title = "Frame selection - first 100" - padding = (width - len(title)) // 2 - return Strip([ - Segment(" " * padding), - Segment(title, Style(color="#3190FF", bold=True)), - Segment(" " * (width - padding - len(title))) - ]) - - # Chart content starts from line 1 - chart_y = y - 1 - chart_height = height - 1 # Reserve first line for title - - # Calculate bar dimensions - num_frames = min(len(self.frames), 100) # Max 100 frames - bar_width = 1 # Each bar is 1 character wide - gap_width = 1 # 1 character gap between bars - - segments = [] - - for i in range(num_frames): - frame = self.frames[i] - - # Normalize sharpness score to chart height - normalized_score = (frame.sharpness_score - self.min_score) / self.score_range - bar_height = int(normalized_score * chart_height) - - # Determine if we should draw the bar at this y position - # chart_y=0 is top of chart, chart_height-1 is bottom - should_draw = (chart_height - 1 - chart_y) < bar_height - - # Choose color based on selection status - if frame.index in self.selected_indices: - color = Style(color="white", bold=True) - else: - color = Style(color="#666666") # Light grey for unselected - - # Draw the bar - if should_draw: - segments.append(Segment("█", color)) - else: - segments.append(Segment(" ")) - - # Add gap after bar (except for the last one) - if i < num_frames - 1: - segments.append(Segment(" ")) - - # Calculate total width used - total_used = num_frames * bar_width + (num_frames - 1) * gap_width - - # Fill remaining space - remaining = width - total_used - if remaining > 0: - segments.append(Segment(" " * remaining)) - - return Strip(segments) + start, stop = self._visible_frame_range(width) + return self._render_window_title(width, start, stop, blank_style) + + if not self.frames: + return Strip([Segment(" " * width, blank_style)], width) + + return self._render_chart_line( + width, + y - 1, + max(viewport_height - 1, 1), + blank_style, + ) + + def _render_chart_line( + self, + width: int, + chart_y: int, + chart_height: int, + blank_style: Style, + ) -> Strip: + """Render frame bars and their guaranteed blank separator columns.""" + selected_style = self.get_component_rich_style( + "sharpness-chart--selected" + ) + unselected_style = self.get_component_rich_style( + "sharpness-chart--unselected" + ) + inspected_style = self.get_component_rich_style( + "sharpness-chart--inspected" + ) + segments = [ + self._render_timeline_column( + virtual_column, + chart_y, + chart_height, + selected_style, + unselected_style, + inspected_style, + blank_style, + ) + for virtual_column in range( + int(self.scroll_offset.x), + int(self.scroll_offset.x) + width, + ) + ] + return Strip(segments, width) + + def _render_timeline_column( + self, + virtual_column: int, + chart_y: int, + chart_height: int, + selected_style: Style, + unselected_style: Style, + inspected_style: Style, + blank_style: Style, + ) -> Segment: + """Render a bar column or the blank gap following it.""" + frame_position, slot_column = divmod(virtual_column, self.FRAME_STRIDE) + if slot_column or frame_position >= len(self.frames): + return Segment(" ", blank_style) + + frame = self.frames[frame_position] + normalized = (frame.sharpness_score - self.min_score) / self.score_range + glyph = self._bar_glyph(normalized, chart_y, chart_height) + if glyph == " ": + return Segment(" ", blank_style) + + if frame.index == self.inspected_index: + style = inspected_style + elif frame.index in self.selected_indices: + style = selected_style + else: + style = unselected_style + return Segment(glyph, style) + + @classmethod + def _bar_glyph( + cls, normalized: float, chart_y: int, chart_height: int + ) -> str: + """Render a bar cell with eighth-row vertical precision.""" + subdivisions = cls.VERTICAL_SUBDIVISIONS + variable_units = max((chart_height - 1) * subdivisions, 0) + clamped = max(0.0, min(float(normalized), 1.0)) + filled_units = subdivisions + int( + (clamped * variable_units) + 0.5 + ) + row_from_bottom = chart_height - 1 - chart_y + units_in_row = filled_units - (row_from_bottom * subdivisions) + units_in_row = max(0, min(units_in_row, subdivisions)) + return cls.BAR_GLYPHS[units_in_row] + + def _visible_frame_range(self, width: int) -> tuple[int, int]: + """Return the frame positions represented by the visible viewport.""" + scroll_x = min(int(self.scroll_offset.x), self.timeline_width) + visible_end = min(scroll_x + width, self.timeline_width) + start = min( + (scroll_x + self.FRAME_STRIDE - 1) // self.FRAME_STRIDE, + len(self.frames), + ) + stop = min( + (visible_end + self.FRAME_STRIDE - 1) // self.FRAME_STRIDE, + len(self.frames), + ) + return start, stop + + def _render_window_title( + self, + width: int, + start: int, + stop: int, + blank_style: Style, + ) -> Strip: + """Render a centered description of the visible timeline window.""" + if not self.frames: + title = "No analyzed frames" + else: + title = f"Frames {start + 1:,}-{stop:,} of {len(self.frames):,}" + title = title[:width] + left_padding = max((width - len(title)) // 2, 0) + right_padding = width - left_padding - len(title) + title_style = self.get_component_rich_style("sharpness-chart--title") + return Strip( + [ + Segment(" " * left_padding, blank_style), + Segment(title, title_style), + Segment(" " * right_padding, blank_style), + ], + width, + ) class InputWithControls(Widget): @@ -141,31 +420,53 @@ class InputWithControls(Widget): width: 20; margin: 0 1 0 0; height: 3; + border: solid #9f9f9f; + } + + InputWithControls Input.-valid { + border: solid #9f9f9f; + } + + InputWithControls Input:focus, + InputWithControls Input.-valid:focus { + border: solid $primary; } - InputWithControls .increment-controls { - width: 8; + InputWithControls .stepper-controls { + width: 14; layout: horizontal; height: 3; } - InputWithControls .increment-btn, - InputWithControls .decrement-btn { + InputWithControls .stepper-button { height: 3; - width: 3; + width: 7; margin: 0; padding: 0; - min-width: 3; + min-width: 7; min-height: 3; max-height: 3; - max-width: 3; + max-width: 7; content-align: center middle; text-align: center; + color: $text-muted; + background: $surface; + border: tall $surface-lighten-1; + text-style: bold; } - - InputWithControls .decrement-btn { - margin-right: 2; + + InputWithControls .stepper-button:hover { + color: $text; + background: $surface-lighten-1; + border: tall $surface-lighten-2; } + + InputWithControls .stepper-button:focus { + color: $text; + background: $surface; + border: tall $primary; + } + """ def __init__(self, value: str = "", input_id: str = "", min_value: int = 0, max_value: int = 10000, step: int = 1, **kwargs): @@ -179,9 +480,19 @@ def __init__(self, value: str = "", input_id: str = "", min_value: int = 0, max_ def compose(self) -> ComposeResult: """Compose the input with increment/decrement buttons.""" yield Input(value=self._value, id=self.input_id) - with Container(classes="increment-controls"): - yield Button(label="-", classes="decrement-btn", id=f"{self.input_id}_dec", variant="primary") - yield Button(label="+", classes="increment-btn", id=f"{self.input_id}_inc", variant="primary") + with Container(classes="stepper-controls"): + yield Button( + label="−", + classes="stepper-button decrement-btn", + id=f"{self.input_id}_dec", + variant="default", + ) + yield Button( + label="+", + classes="stepper-button increment-btn", + id=f"{self.input_id}_inc", + variant="default", + ) def on_button_pressed(self, event: Button.Pressed) -> None: """Handle increment/decrement button presses.""" @@ -225,7 +536,7 @@ def value(self) -> str: try: input_widget = self.query_one(f"#{self.input_id}", Input) return input_widget.value - except: + except NoMatches: return self._value @value.setter @@ -235,17 +546,34 @@ def value(self, new_value: str) -> None: try: input_widget = self.query_one(f"#{self.input_id}", Input) input_widget.value = new_value - except: + except NoMatches: pass class SelectionScreen(Screen): """Interactive selection screen with real-time preview.""" - + + # Below 56 rows the default spacing pushes the chart under the fold, so a + # tighter tier takes over (see the -vertical-compact rules in styles.py). + VERTICAL_BREAKPOINTS = [(0, "-vertical-compact"), (56, "-vertical-regular")] + BINDINGS = [ Binding("ctrl+c", "cancel", "Cancel"), Binding("escape", "cancel", "Cancel"), - Binding("enter", "confirm", "Confirm Selection", key_display="Enter"), + Binding( + "enter", + "confirm", + "Confirm Selection", + key_display="Enter", + priority=True, + ), + Binding( + "space", + "select_current_option", + "Select", + show=False, + priority=True, + ), Binding("f1", "help", "Help", show=True), ] @@ -278,8 +606,15 @@ def __init__(self, processor: TUIProcessor, extraction_result: ExtractionResult, # Selection state self.current_method = "batched" self.current_parameters = {"batch_size": 5, "batch_buffer": 2} # Default parameters for batched - self.preview_task = None # For debouncing preview updates + self.preview_task = None # Single-flight preview worker task + self._preview_generation = 0 + self._pending_preview = None + self._final_processing_task = None self.selected_indices = set() # Track which frames are selected + # While True the inline preview follows the first selected frame. It is + # switched off the moment the user clicks a chart bar to inspect a + # specific frame, so recomputed previews no longer override their choice. + self._auto_preview_active = True # Method definitions with default parameters - matching legacy application exactly self.method_definitions = { @@ -300,11 +635,11 @@ def __init__(self, processor: TUIProcessor, extraction_result: ExtractionResult, } }, "outlier_removal": { - "name": "Outlier Removal", + "name": "Outlier Detection", "description": "Remove frames with unusually low sharpness scores compared to neighbors", "parameters": { - "outlier_sensitivity": {"type": "int", "default": 50, "min": 0, "max": 100, "label": "Removal aggressiveness (0-100)"}, - "outlier_window_size": {"type": "int", "default": 15, "min": 3, "max": 30, "label": "Neighbor comparison window"} + "outlier_sensitivity": {"type": "int", "default": 60, "min": 0, "max": 100, "label": "Detection sensitivity (0-100)"}, + "outlier_window_size": {"type": "int", "default": 15, "min": 5, "max": 31, "label": "Local comparison window"} } } } @@ -319,55 +654,56 @@ def compose(self) -> ComposeResult: # Main container with all content with Container(id="main_content"): - # Title section - single line with left and right text - with Horizontal(id="title_section", classes="title_section"): - yield Static("Select Frames", classes="title_left") - yield Static(f"Choose from {total_frames:,} analyzed frames", classes="title_right") + # Inline source preview above the chart. Uses the original analyzed + # file and a true terminal raster protocol. On load it shows the + # first selected frame; clicking a chart bar swaps in that frame. + yield RasterImagePreview(id="frame_preview") - # Sharpness chart - shows first 100 frames + # Sharpness chart - one horizontally scrollable column per frame yield SharpnessChart( self.extraction_result.frames, selected_indices=self.selected_indices, - max_frames=100, id="sharpness_chart" ) - # Controls section - method and parameters side by side - with Horizontal(id="controls_section", classes="controls"): - # Method selection on the left - with Container(id="method_container", classes="control_group"): - yield Label("Selection Method", classes="control_label") - yield Select( - options=[(info["name"], key) for key, info in self.method_definitions.items()], - value="batched", - id="method_select" - ) - yield Static(self.method_definitions[self.current_method]["description"], - id="method_description", classes="description") - - # Parameters on the right - with Container(id="parameter_container", classes="control_group"): - yield Label("Parameters", classes="control_label") - with Container(id="parameter_inputs", classes="parameter_inputs"): - # Initial parameters for batched method (default) - yield Label("Frames per batch:", classes="param_label") - yield InputWithControls( - value="5", - input_id="param_batched_batch_size", - min_value=1, - max_value=100, - step=1, - classes="param_input_with_controls" - ) - yield Label("Frames to skip between batches:", classes="param_label") - yield InputWithControls( - value="2", - input_id="param_batched_batch_buffer", - min_value=0, - max_value=50, - step=1, - classes="param_input_with_controls" + # Controls are capped for readability while the data-heavy chart + # and raster preview continue to use the full terminal width. + with Container(classes="bounded-row"): + with Horizontal(id="controls_section", classes="controls"): + # Method selection on the left + with Container(id="method_container", classes="control_group"): + yield Label("Selection Method", classes="control_label") + yield OptionSelect( + options=[(info["name"], key) for key, info in self.method_definitions.items()], + value="batched", + id="method_select" ) + yield Static(self.method_definitions[self.current_method]["description"], + id="method_description", classes="description") + + # Parameters on the right + with Container(id="parameter_container", classes="control_group"): + yield Label("Parameters", classes="control_label") + with Container(id="parameter_inputs", classes="parameter_inputs"): + # Initial parameters for batched method (default) + yield Label("Frames per batch:", classes="param_label") + yield InputWithControls( + value="5", + input_id="param_batched_batch_size", + min_value=1, + max_value=100, + step=1, + classes="param_input_with_controls" + ) + yield Label("Frames to skip between batches:", classes="param_label") + yield InputWithControls( + value="2", + input_id="param_batched_batch_buffer", + min_value=0, + max_value=50, + step=1, + classes="param_input_with_controls" + ) # Action buttons inside main content for better positioning with Horizontal(id="action_buttons", classes="action_buttons"): @@ -375,7 +711,97 @@ def compose(self) -> ComposeResult: yield Button(f"Save {initial_count:,} Images", id="confirm_button", variant="primary") yield Footer() - + + async def on_sharpness_chart_frame_inspect_requested( + self, event: SharpnessChart.FrameInspectRequested + ) -> None: + """Preview a clicked frame without launching external applications.""" + # The user is now driving the preview manually, so stop auto-following + # the first selected frame when the selection is recomputed. + self._auto_preview_active = False + frame = event.frame + try: + preview = self.query_one("#frame_preview", RasterImagePreview) + shown_inline = preview.show_frame( + frame.path, + frame_number=frame.index + 1, + score=frame.sharpness_score, + ) + fallback_reason = ( + "This terminal does not support inline raster graphics" + ) + except FileNotFoundError as exc: + self.notify( + str(exc), + title="Unable to inspect frame", + severity="error", + ) + return + except (OSError, ValueError) as exc: + shown_inline = False + fallback_reason = ( + f"The inline raster renderer could not show this frame ({exc})" + ) + + if shown_inline: + self._notify_inline_preview(frame) + return + + self.notify( + f"{fallback_reason}. Press O to open the original frame in your " + "default image viewer.", + title="Inline preview unavailable", + timeout=5, + ) + + async def on_sharpness_chart_frame_open_requested( + self, event: SharpnessChart.FrameOpenRequested + ) -> None: + """Open the currently inspected temporary frame in the default viewer.""" + frame = event.frame + try: + await asyncio.to_thread(open_image_file, frame.path) + except (FileNotFoundError, OSError) as exc: + self.notify( + str(exc), + title="Unable to open frame", + severity="error", + ) + return + + self.notify( + f"Frame {frame.index + 1:,} opened in the default image viewer.", + title="Opened externally", + timeout=3, + ) + + def _notify_inline_preview(self, frame: FrameData) -> None: + """Show at most three concurrent frame-navigation notifications.""" + message = ( + f"Frame {frame.index + 1:,} · " + f"sharpness {frame.sharpness_score:.2f}" + ) + notify_limited = ( + getattr(self.app, "notify_limited", None) + if self.is_mounted + else None + ) + if callable(notify_limited): + notify_limited( + message, + channel="frame-preview", + limit=3, + title="Inline preview", + timeout=3, + ) + return + + self.notify( + message, + title="Inline preview", + timeout=3, + ) + def on_mount(self) -> None: """Initialize the screen when mounted.""" # Parameter inputs are already created in compose() with correct initial values @@ -474,12 +900,37 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.action_start_over() def action_cancel(self) -> None: - """Cancel selection and return to previous screen.""" + """Return unless a final save is already in progress.""" + if ( + self._final_processing_task is not None + and not self._final_processing_task.done() + ): + self.notify( + "Saving is already in progress. Please wait for it to finish.", + title="Save in progress", + timeout=3, + ) + return self.app.pop_screen() def action_confirm(self) -> None: - """Confirm selection and proceed with saving.""" + """Confirm selection once the latest preview is ready.""" + if ( + self._final_processing_task is not None + and not self._final_processing_task.done() + ): + return + try: + confirm_button = self.query_one("#confirm_button", Button) + except NoMatches: + return + if confirm_button.disabled or self.selected_count <= 0: + return self._start_final_processing() + + def action_select_current_option(self) -> None: + """Use Space to select the option under the current focus.""" + select_focused_option(self.app.focused) def action_start_over(self) -> None: """Reset everything and return to the first step of configuration.""" @@ -487,12 +938,13 @@ def action_start_over(self) -> None: if self.processor and hasattr(self.processor, 'cleanup_temp_directory'): self.processor.cleanup_temp_directory() - # Reset configuration screen to first step - # The configuration screen should be at index 0 in the screen stack - if len(self.app.screen_stack) >= 3: # Config, Processing, Selection - config_screen = self.app.screen_stack[0] - if hasattr(config_screen, 'reset_to_first_step'): - config_screen.reset_to_first_step() + # Reset the configuration screen to the first step. Textual keeps a + # default screen at the bottom of the stack, so locate the form by + # capability rather than by index. + for screen in self.app.screen_stack: + if hasattr(screen, 'reset_to_first_step'): + screen.reset_to_first_step() + break # Pop both selection and processing screens to return to configuration at step 1 self.app.pop_screen() # Pop selection screen @@ -511,14 +963,15 @@ def action_help(self) -> None: **Batched Selection**: Divide all frames into equal groups and pick the sharpest from each group. Great for ensuring even coverage across the entire video. -**Outlier Removal**: Automatically remove frames that are significantly blurrier than their neighbors. Best when you want to keep most frames but remove the obviously bad ones. +**Outlier Detection**: Automatically reject frames that are significantly blurrier than their local neighbors. Best when you want to keep most frames but remove clear sharpness outliers. ## How It Works 1. **Choose a method** from the dropdown 2. **Adjust parameters** as needed 3. **Watch the count update** in real-time as you change settings -4. **Press "Process"** when you're happy with the selection +4. **Click a chart bar** to inspect it, then press **O** to open it externally +5. **Press "Process"** when you're happy with the selection The preview count updates instantly as you make changes, so you can experiment freely! @@ -603,64 +1056,96 @@ async def _update_parameter_inputs_async(self) -> None: try: input_field = first_input.query_one(Input) input_field.focus() - except: + except NoMatches: first_input.focus() except Exception as e: self.app.log.error(f"Error updating parameter inputs: {e}") def _update_preview_async(self) -> None: - """Update preview with debouncing to avoid too frequent updates.""" - # Cancel previous preview task if still running - if self.preview_task and not self.preview_task.done(): - self.preview_task.cancel() - - # Schedule new preview update - self.preview_task = asyncio.create_task(self._update_preview_debounced()) - - async def _update_preview_debounced(self) -> None: - """Update preview with small delay to debounce rapid changes.""" + """Coalesce preview requests behind one selector thread at a time.""" + self._preview_generation += 1 + self._pending_preview = ( + self._preview_generation, + self.current_method, + dict(self.current_parameters), + ) + try: - # Small delay to debounce rapid parameter changes - await asyncio.sleep(0.1) # 100ms debounce - - # Get preview from processor - count = self.processor.preview_selection(self.current_method, **self.current_parameters) - - # Update UI elements - self._update_preview_display(count) - - # Post message for other components that might be listening - await self.post_message(self.SelectionPreview(count, self.current_method, **self.current_parameters)) - + self.query_one("#confirm_button", Button).disabled = True + except NoMatches: + pass + + if self.preview_task is None or self.preview_task.done(): + self.preview_task = asyncio.create_task(self._run_preview_worker()) + + async def _run_preview_worker(self) -> None: + """Process only the newest pending preview without overlapping calls.""" + try: + while self._pending_preview is not None: + await asyncio.sleep(0.1) + request = self._pending_preview + self._pending_preview = None + if request is None: + continue + await self._compute_preview(*request) except asyncio.CancelledError: - # Task was cancelled, ignore pass - except Exception as e: - self.app.log.error(f"Error updating preview: {e}") - - def _update_preview_display(self, count: int) -> None: - """Update the preview display with new count in the button.""" - self.selected_count = count - - # Update selected indices for the chart - # Get which frames would be selected with current settings + finally: + if asyncio.current_task() is self.preview_task: + self.preview_task = None + + async def _update_preview_debounced( + self, + generation: int, + method: str, + parameters: Dict[str, Any], + ) -> None: + """Compatibility helper for one explicitly debounced preview.""" + await asyncio.sleep(0.1) + await self._compute_preview(generation, method, parameters) + + async def _compute_preview( + self, + generation: int, + method: str, + parameters: Dict[str, Any], + ) -> None: + """Compute one selection off-thread and ignore stale completions.""" try: - # Use the actual selection method to get the selected frames - selected_frames = self.processor.selector.select_frames( + selected_frames = await asyncio.to_thread( + self.processor.selector.select_frames, self.extraction_result.frames, - self.current_method, - **self.current_parameters + method, + **parameters, ) - self.selected_indices = {frame.index for frame in selected_frames} - - # Update the chart - chart = self.query_one("#sharpness_chart", SharpnessChart) - chart.update_selection(self.selected_indices) + + if generation != self._preview_generation or not self.is_mounted: + return + + selected_indices = {frame.index for frame in selected_frames} + count = len(selected_frames) + self._update_preview_display(count, selected_indices) + await self.post_message( + self.SelectionPreview(count, method, **parameters) + ) + except asyncio.CancelledError: + raise except Exception as e: - self.app.log.error(f"Error updating chart selection: {e}") - - # Update the action button to show what will happen + if generation == self._preview_generation: + self.app.log.error(f"Error updating preview: {e}") + + def _update_preview_display( + self, count: int, selected_indices: set[int] + ) -> None: + """Update the chart and action button from one selection result.""" + self.selected_count = count + self.selected_indices = selected_indices + self.query_one("#sharpness_chart", SharpnessChart).update_selection( + selected_indices + ) + self._refresh_auto_preview(selected_indices) + confirm_btn = self.query_one("#confirm_button", Button) if count > 0: confirm_btn.label = f"Save {count:,} Images" @@ -668,32 +1153,103 @@ def _update_preview_display(self, count: int) -> None: else: confirm_btn.label = "No Images Selected" confirm_btn.disabled = True - + + def _refresh_auto_preview(self, selected_indices: set[int]) -> None: + """Show the first selected frame until the user inspects one manually.""" + if not self._auto_preview_active: + return + + first_selected = next( + ( + frame + for frame in self.extraction_result.frames + if frame.index in selected_indices + ), + None, + ) + if first_selected is None: + return + + try: + preview = self.query_one("#frame_preview", RasterImagePreview) + except NoMatches: + return + if not preview.raster_supported: + return + + try: + preview.show_frame( + first_selected.path, + frame_number=first_selected.index + 1, + score=first_selected.sharpness_score, + ) + except (FileNotFoundError, OSError, ValueError): + # A missing or unreadable first frame must never break the preview + # refresh; the user can still inspect other frames explicitly. + pass + + def on_unmount(self) -> None: + """Invalidate preview work before the screen is removed.""" + self._preview_generation += 1 + self._pending_preview = None + if self.preview_task and not self.preview_task.done(): + self.preview_task.cancel() + def _start_final_processing(self) -> None: - """Start the final processing phase (selection and saving).""" - # Disable UI during processing + """Start at most one final selection and saving operation.""" + if ( + self._final_processing_task is not None + and not self._final_processing_task.done() + ): + return + + self._preview_generation += 1 + self._pending_preview = None + if self.preview_task and not self.preview_task.done(): + self.preview_task.cancel() + self.query_one("#confirm_button", Button).disabled = True self.query_one("#method_select", Select).disabled = True - - # Create final config without mixing in selection parameters - # The parameters will be passed separately to complete_selection + + # Drop any inline raster preview before saving: completing the save + # removes the extraction temp directory, and a stale frame path would + # crash Textual's render loop on the next repaint. + try: + self.query_one("#frame_preview", RasterImagePreview).clear() + except NoMatches: + pass + + method = self.current_method + parameters = dict(self.current_parameters) final_config = self.config.copy() - final_config['selection_method'] = self.current_method - - # Start processing in background - asyncio.create_task(self._process_final_selection(final_config)) - - async def _process_final_selection(self, final_config: Dict[str, Any]) -> None: + final_config['selection_method'] = method + + self._final_processing_task = asyncio.create_task( + self._process_final_selection( + final_config, + method=method, + parameters=parameters, + ) + ) + + async def _process_final_selection( + self, + final_config: Dict[str, Any], + *, + method: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + ) -> None: """Process the final selection and saving.""" selected_count = self.selected_count processing_label = None - + try: - # Show processing indicator processing_label = await self._show_processing_indicator() - - # Run the final selection in background thread - success = await self._execute_selection_in_background(final_config) + success = await self._execute_selection_in_background( + final_config, + method=method, + parameters=parameters, + ) if success: await self._handle_selection_success(processing_label, selected_count, final_config) @@ -703,24 +1259,35 @@ async def _process_final_selection(self, final_config: Dict[str, Any]) -> None: except Exception as e: self.app.log.error(f"Error during final processing: {e}") await self._handle_selection_error(processing_label, str(e)) - + finally: + if asyncio.current_task() is self._final_processing_task: + self._final_processing_task = None + async def _show_processing_indicator(self) -> Label: """Show the processing indicator and return the label widget.""" processing_label = Label("🔄 Processing selection...", classes="processing_indicator") await self.query_one("#main_content").mount(processing_label) return processing_label - async def _execute_selection_in_background(self, final_config: Dict[str, Any]) -> bool: + async def _execute_selection_in_background( + self, + final_config: Dict[str, Any], + *, + method: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + ) -> bool: """Execute the selection process in a background thread.""" - # Run the final selection (this is CPU intensive, so we run it in a thread) - # Note: run_in_executor doesn't support keyword arguments, so we use a lambda - return await asyncio.get_event_loop().run_in_executor( - None, - lambda: self.processor.complete_selection( - self.current_method, - final_config, - **self.current_parameters - ) + selected_method = method or self.current_method + selected_parameters = ( + dict(parameters) + if parameters is not None + else dict(self.current_parameters) + ) + return await asyncio.to_thread( + self.processor.complete_selection, + selected_method, + final_config, + **selected_parameters, ) async def _handle_selection_success(self, processing_label: Label, selected_count: int, final_config: Dict[str, Any]) -> None: @@ -758,17 +1325,20 @@ async def _handle_selection_error(self, processing_label: Optional[Label], error pass self._re_enable_ui() - def _create_success_container(self, selected_count: int, final_config: Dict[str, Any]) -> Horizontal: + def _create_success_container(self, selected_count: int, final_config: Dict[str, Any]) -> Container: """Create the success message container.""" - return Horizontal( - Container( - Static("✅ Images saved successfully!", classes="success_message"), - Static(f"Saved {selected_count} frames to {final_config['output_dir']}", classes="success_details"), - classes="success_text_container" + return Container( + Horizontal( + Container( + Static("✅ Images saved successfully!", classes="success_message"), + Static(f"Saved {selected_count} frames to {final_config['output_dir']}", classes="success_details"), + classes="success_text_container" + ), + Button("Start Over", id="start_over_button", variant="primary"), + id="success_container", + classes="success_container" ), - Button("Start Over", id="start_over_button", variant="primary"), - id="success_container", - classes="success_container" + classes="bounded-row success-row", ) def _re_enable_ui(self) -> None: diff --git a/sharp_frames/ui/styles.py b/sharp_frames/ui/styles.py index fdd785e..8fe9bb1 100644 --- a/sharp_frames/ui/styles.py +++ b/sharp_frames/ui/styles.py @@ -8,6 +8,14 @@ layout: vertical; } +ConfigurationForm { + align-horizontal: center; +} + +ProcessingScreen { + align: center middle; +} + Header { dock: top; } @@ -48,6 +56,7 @@ } .summary { + height: auto; margin: 1 0; padding: 1; border: solid #3190FF; @@ -55,7 +64,7 @@ } .buttons { - margin: 0 0 1 0; + margin: 3 0 0 0; align: center middle; height: 3; } @@ -104,26 +113,92 @@ #main-container { padding: 1; + height: auto; + max-height: 100%; + min-height: 0; + width: 110; + max-width: 100%; + overflow-y: auto; +} + +#form-sequence { height: 1fr; min-height: 0; + width: 100%; + align: center middle; } #step-container { - height: 1fr; + height: auto; + width: 100%; padding: 0 1; min-height: 0; overflow: auto; } #processing-container { - padding: 1; - text-align: center; + padding: 1 3; + margin: 0; + height: auto; + text-align: left; + width: 84; + max-width: 100%; + border: solid $surface-lighten-1; + background: $surface; +} + +#status-text { + height: auto; + margin: 0; + color: $text; + text-style: bold; } #phase-text { - margin: 0 0 2 0; + height: 1; + margin: 0; color: $text-muted; - text-style: italic; +} + +#progress-bar { + height: 1; + width: 100%; + margin: 1 0; +} + +#progress-bar .block-progress--filled { + color: $primary; + background: $primary; +} + +#progress-bar .block-progress--empty { + color: $surface-lighten-2; + background: $surface-lighten-2; +} + +#progress-bar .block-progress--percentage { + color: $text; + background: $surface; +} + +#detail-text { + height: auto; + margin: 0 0 1 0; + color: $text-muted; +} + +#cancel-processing { + min-width: 16; + margin: 1 0 0 0; + color: white; + text-style: bold not reverse; +} + +#cancel-processing:hover, +#cancel-processing:focus, +#cancel-processing.-active { + color: white; + text-style: bold not reverse; } Input { @@ -340,6 +415,8 @@ #configuration-container { padding: 1; height: 1fr; + width: 110; + max-width: 100%; } .step-title { @@ -392,46 +469,84 @@ text-style: bold; } -/* New Selection Screen Styles - Clean Layout without Preview */ +/* Selection screen */ #main_content { padding: 1; + height: 1fr; + min-height: 0; + overflow-y: auto; +} + +.bounded-row { height: auto; + width: 100%; + align-horizontal: center; } -/* Title section - horizontal layout */ -.title_section { - height: 1; +/* Full-resolution source preview. The terminal raster backend performs the + fit-to-window resampling; the source file is never rewritten. It is given + more of the flexible vertical space than the chart below it. */ +RasterImagePreview { + height: 2fr; + min-height: 12; + width: 100%; margin: 0 0 1 0; + padding: 0 1; + border: solid $surface; + background: $background; } -.title_left { - text-style: bold; - color: $primary; - text-align: left; - height: 1; - width: 1fr; +RasterImagePreview.-unsupported { + height: 5; + min-height: 5; + max-height: 5; } -.title_right { - color: $text-muted; - text-align: right; +.frame-preview-title { height: 1; - width: 1fr; + width: 100%; + color: $text-muted; + text-align: center; +} + +.frame-preview-canvas { + height: 1fr; + width: 100%; + align: center middle; + overflow: hidden; +} + +#frame_preview_image { + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; +} + +.frame-preview-fallback { + height: 2; + width: 100%; + color: $warning; + text-align: center; + content-align: center middle; } /* Sharpness chart */ SharpnessChart { - height: 12; + height: 1fr; + min-height: 13; width: 100%; border: solid $primary; margin: 1 0; - background: $surface-lighten-1; + background: $background; } /* Controls section takes remaining space */ .controls { margin: 1 0 2 0; height: auto; + width: 120; + max-width: 100%; } .control_group { @@ -488,34 +603,82 @@ margin: 0 0 1 0; } +/* Keep the parameter panel compact, with one deliberate row after the title + and between each complete label/input control group. */ +#parameter_container { + padding: 1 2 0 1; + min-height: 10; +} + +#parameter_container .parameter_inputs, +#parameter_container .param_label { + margin: 0; +} + +#parameter_container .control_label, +#parameter_container .param_input_with_controls, +#parameter_container InputWithControls { + margin: 0 0 1 0; +} + +#parameter_container .param_input_with_controls:last-child, +#parameter_container InputWithControls:last-child { + margin-bottom: 0; +} + +#parameter_container .parameter_inputs { + min-height: 0; +} + InputWithControls Input { width: 20; margin: 0 1 0 0; height: 3; + border: solid #9f9f9f; +} + +InputWithControls Input.-valid { + border: solid #9f9f9f; +} + +InputWithControls Input:focus, +InputWithControls Input.-valid:focus { + border: solid $primary; } -InputWithControls .increment-controls { - width: 8; +InputWithControls .stepper-controls { + width: 14; layout: horizontal; height: 3; } -InputWithControls .increment-btn, -InputWithControls .decrement-btn { +InputWithControls .stepper-button { height: 3 !important; - width: 3 !important; + width: 7 !important; margin: 0 !important; padding: 0 !important; - min-width: 3 !important; + min-width: 7 !important; min-height: 3 !important; max-height: 3 !important; - max-width: 3 !important; + max-width: 7 !important; content-align: center middle; text-align: center; + color: $text-muted; + background: $surface; + border: tall $surface-lighten-1; + text-style: bold; +} + +InputWithControls .stepper-button:hover { + color: $text; + background: $surface-lighten-1; + border: tall $surface-lighten-2; } -InputWithControls .decrement-btn { - margin-right: 2 !important; +InputWithControls .stepper-button:focus { + color: $text; + background: $surface; + border: tall $primary; } /* Action buttons inside main content */ @@ -525,6 +688,52 @@ height: 3; } +/* Compact tier for short terminals. The default spacing needs ~56 rows to + keep the whole screen above the fold; below that spacing tightens and the + preview and chart shrink so both stay in the viewport. The chart uses a + percentage and the preview is the only fr child: with two or more fr + siblings whose minimums exceed the free space, Textual falls back to + sizing every fr child against the whole remaining space, which balloons + the preview and pushes the chart below the fold. The action buttons dock + to the bottom so Save stays visible even when the controls overflow into + the scrollable area on tiny terminals. */ +SelectionScreen.-vertical-compact #main_content { + padding: 0 1; +} + +SelectionScreen.-vertical-compact RasterImagePreview { + min-height: 4; + margin: 0; +} + +SelectionScreen.-vertical-compact RasterImagePreview.-unsupported { + min-height: 5; +} + +SelectionScreen.-vertical-compact SharpnessChart { + height: 25%; + min-height: 6; +} + +SelectionScreen.-vertical-compact .controls { + margin: 0; +} + +SelectionScreen.-vertical-compact .control_group { + padding: 0 2 0 1; + min-height: 0; +} + +SelectionScreen.-vertical-compact #parameter_container { + padding: 0 2 0 1; + min-height: 0; +} + +SelectionScreen.-vertical-compact .action_buttons { + dock: bottom; + margin: 1 0 0 0; +} + .action_buttons Button { margin: 0 2; min-width: 15; @@ -538,10 +747,13 @@ margin: 1 0; border: solid $success; height: auto; + width: 100; + max-width: 100%; } .success_text_container { layout: vertical; + height: auto; padding: 0 2 0 0; } @@ -572,4 +784,8 @@ color: $primary; text-style: bold; } -""" \ No newline at end of file + +.success-row { + height: auto; +} +""" diff --git a/sharp_frames/ui/utils/error_analysis.py b/sharp_frames/ui/utils/error_analysis.py index 6aa60ca..4057687 100644 --- a/sharp_frames/ui/utils/error_analysis.py +++ b/sharp_frames/ui/utils/error_analysis.py @@ -6,6 +6,8 @@ import subprocess from typing import Optional, Dict, Any +from ...video_utils import SUPPORTED_IMAGE_EXTENSIONS, get_ffmpeg_installation_hint + class ErrorContext: """Class to analyze errors and provide user-friendly messages.""" @@ -72,9 +74,11 @@ def analyze_processing_failure(config: Dict[str, Any], error: Exception = None) # Check if directory has images try: - image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp'} files = os.listdir(input_path) - image_files = [f for f in files if os.path.splitext(f.lower())[1] in image_extensions] + image_files = [ + f for f in files + if os.path.splitext(f.lower())[1] in SUPPORTED_IMAGE_EXTENSIONS + ] if not image_files: return f"No image files found in directory: {input_path}" except Exception: @@ -108,19 +112,15 @@ def analyze_processing_failure(config: Dict[str, Any], error: Exception = None) return "Processing failed due to an unexpected error. Check input files and system resources." @staticmethod - def check_system_dependencies() -> Optional[str]: + def check_system_dependencies(require_video_tools: bool = True) -> Optional[str]: """Check system dependencies and return error message if issues found.""" - # Check FFmpeg - try: - result = subprocess.run(['ffmpeg', '-version'], capture_output=True, text=True, timeout=10) - if result.returncode != 0: - return "FFmpeg is installed but not working properly. Try reinstalling FFmpeg." - except subprocess.TimeoutExpired: - return "FFmpeg check timed out. FFmpeg might be corrupted." - except FileNotFoundError: - return "FFmpeg not found. Please install FFmpeg and add it to your system PATH." - except Exception as e: - return f"Error checking FFmpeg: {str(e)}" + if require_video_tools: + dependency_error = ErrorContext._check_video_tool('ffmpeg', 'FFmpeg') + if dependency_error: + return dependency_error + dependency_error = ErrorContext._check_video_tool('ffprobe', 'FFprobe') + if dependency_error: + return dependency_error # Check OpenCV (basic import test) try: @@ -142,4 +142,26 @@ def check_system_dependencies() -> Optional[str]: except Exception as e: return f"Error checking OpenCV: {str(e)}" - return None # No issues found \ No newline at end of file + return None # No issues found + + @staticmethod + def _check_video_tool(executable: str, display_name: str) -> Optional[str]: + """Verify a required video executable can run.""" + try: + result = subprocess.run( + [executable, '-version'], capture_output=True, text=True, timeout=10 + ) + if result.returncode != 0: + return ( + f"{display_name} is installed but not working properly. " + f"Try reinstalling {display_name}." + ) + except subprocess.TimeoutExpired: + return f"{display_name} check timed out. {display_name} might be corrupted." + except FileNotFoundError: + return ( + f"{display_name} not found. {get_ffmpeg_installation_hint()}" + ) + except Exception as e: + return f"Error checking {display_name}: {str(e)}" + return None diff --git a/sharp_frames/ui/utils/image_opener.py b/sharp_frames/ui/utils/image_opener.py new file mode 100644 index 0000000..71d9984 --- /dev/null +++ b/sharp_frames/ui/utils/image_opener.py @@ -0,0 +1,42 @@ +"""Open analyzed frame images with the platform's default viewer.""" + +import os +import platform +import subprocess +from pathlib import Path +from typing import Optional + + +def open_image_file(path: str, system_name: Optional[str] = None) -> None: + """Open an existing image without blocking the Textual application.""" + image_path = Path(path).expanduser() + if not image_path.is_file(): + raise FileNotFoundError(f"Frame image is no longer available: {image_path}") + + current_system = system_name or platform.system() + if current_system == "Windows": + startfile = getattr(os, "startfile", None) + if startfile is None: + raise OSError("Windows image opener is unavailable") + startfile(str(image_path)) + return + + command = ["open", str(image_path)] if current_system == "Darwin" else [ + "xdg-open", + str(image_path), + ] + try: + result = subprocess.run( + command, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + timeout=10, + start_new_session=True, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise OSError(f"Unable to launch the default image viewer: {exc}") from exc + if result.returncode != 0: + detail = result.stderr.strip() or f"exit code {result.returncode}" + raise OSError(f"Unable to launch the default image viewer: {detail}") diff --git a/sharp_frames/video_utils.py b/sharp_frames/video_utils.py index d664fa7..8bb3c42 100644 --- a/sharp_frames/video_utils.py +++ b/sharp_frames/video_utils.py @@ -5,10 +5,95 @@ """ import os -from typing import List +import platform +import re +from typing import List, Optional -# Supported video extensions for video directory processing -SUPPORTED_VIDEO_EXTENSIONS = {'.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv', '.webm'} +# Keep media-format support in one place so validation and processing cannot drift. +SUPPORTED_VIDEO_EXTENSIONS = frozenset({ + '.3g2', '.3gp', '.avi', '.flv', '.m2ts', '.m4v', '.mkv', '.mov', + '.mp4', '.mpeg', '.mpg', '.mts', '.ogv', '.ts', '.vob', '.webm', '.wmv', +}) +AMBIGUOUS_VIDEO_EXTENSIONS = frozenset({'.ts'}) +_MPEG_TS_PACKET_SIZES = (188, 192, 204) +_MPEG_TS_REQUIRED_SYNC_BYTES = 4 + +SUPPORTED_IMAGE_EXTENSIONS = frozenset({ + '.bmp', '.jpeg', '.jpg', '.pbm', '.pgm', '.png', '.ppm', '.tif', + '.tiff', '.webp', +}) + +_NATURAL_NUMBER = re.compile(r"(\d+)") + + +def natural_path_key(path: str) -> tuple: + """Return a deterministic, case-insensitive key for numbered filenames.""" + name = os.path.basename(os.fspath(path)) + parts = tuple( + (1, int(part)) if part.isdigit() else (0, part.casefold()) + for part in _NATURAL_NUMBER.split(name) + if part + ) + return parts, name.casefold(), name + + +def get_ffmpeg_installation_hint(system_name: Optional[str] = None) -> str: + """Return platform-appropriate installation guidance for FFmpeg tools.""" + current_system = system_name or platform.system() + if current_system == 'Windows': + return ( + "Install FFmpeg using a full build from https://ffmpeg.org/download.html " + "and add its bin directory to PATH (the system PATH)." + ) + if current_system == 'Darwin': + return ( + "Install FFmpeg and FFprobe with `brew install ffmpeg`, then ensure " + "Homebrew's bin directory is on your system PATH." + ) + if current_system == 'Linux': + return ( + "Install FFmpeg with your package manager (for example, " + "`sudo apt install ffmpeg`) and ensure it is on your system PATH." + ) + return ( + "Install a full FFmpeg distribution from https://ffmpeg.org/download.html " + "and add it to PATH." + ) + + +def has_mpeg_ts_signature(path: str) -> bool: + """Return whether a file begins with a recognizable MPEG-TS packet layout.""" + read_size = max(_MPEG_TS_PACKET_SIZES) * (_MPEG_TS_REQUIRED_SYNC_BYTES + 1) + try: + with open(path, "rb") as stream: + data = stream.read(read_size) + except (OSError, PermissionError): + return False + + for packet_size in _MPEG_TS_PACKET_SIZES: + required_span = packet_size * (_MPEG_TS_REQUIRED_SYNC_BYTES - 1) + if len(data) <= required_span: + continue + max_offset = min(packet_size, len(data) - required_span) + for offset in range(max_offset): + if all( + data[offset + packet_size * packet_index] == 0x47 + for packet_index in range(_MPEG_TS_REQUIRED_SYNC_BYTES) + ): + return True + return False + + +def is_video_file(path: str) -> bool: + """Return whether an existing file is a supported video candidate.""" + if not os.path.isfile(path): + return False + extension = os.path.splitext(os.fspath(path))[1].lower() + if extension not in SUPPORTED_VIDEO_EXTENSIONS: + return False + if extension in AMBIGUOUS_VIDEO_EXTENSIONS: + return has_mpeg_ts_signature(path) + return True def get_video_files_in_directory(directory_path: str) -> List[str]: @@ -16,21 +101,21 @@ def get_video_files_in_directory(directory_path: str) -> List[str]: video_files = [] if not os.path.isdir(directory_path): return video_files - + for filename in os.listdir(directory_path): file_path = os.path.join(directory_path, filename) - if os.path.isfile(file_path): - _, ext = os.path.splitext(filename.lower()) - if ext in SUPPORTED_VIDEO_EXTENSIONS: - video_files.append(file_path) - - return sorted(video_files) + if is_video_file(file_path): + video_files.append(file_path) + + return sorted(video_files, key=natural_path_key) def detect_input_type(input_path: str) -> str: """Detect the input type based on the path contents.""" if os.path.isfile(input_path): - return "video" + if is_video_file(input_path): + return "video" + raise ValueError(f"Input file is not a supported video: {input_path}") elif os.path.isdir(input_path): # Check what's in the directory video_files = get_video_files_in_directory(input_path) @@ -39,4 +124,4 @@ def detect_input_type(input_path: str) -> str: else: return "directory" # Assume image directory else: - raise ValueError(f"Input path is neither a file nor a directory: {input_path}") \ No newline at end of file + raise ValueError(f"Input path is neither a file nor a directory: {input_path}") diff --git a/tests/requirements.txt b/tests/requirements.txt index d39b6a8..211fc18 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,3 +1,4 @@ -pytest>=7.0.0 +pytest>=9.0.3 pytest-asyncio>=0.21.0 -pytest-mock>=3.10.0 \ No newline at end of file +pytest-mock>=3.10.0 +Pillow>=9.0.0 diff --git a/tests/test_colorspace.py b/tests/test_colorspace.py new file mode 100644 index 0000000..2929dbf --- /dev/null +++ b/tests/test_colorspace.py @@ -0,0 +1,450 @@ +"""Tests for the colorspace detection and conversion module.""" + +import shutil +import subprocess +from unittest.mock import patch, MagicMock + +import pytest + +from sharp_frames.processing.colorspace import ( + VideoColorInfo, + ColorPrimaries, + TransferFunction, + ColorMatrix, + detect_color_space, + parse_color_info_from_stream, + build_colorspace_filter, + get_color_info_description, + is_zscale_available, + _default_color_info, +) + + +class TestVideoColorInfo: + """Tests for VideoColorInfo dataclass.""" + + def test_bt709_sdr_no_conversion_needed(self): + """Standard BT.709 SDR should not need conversion.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT709, + transfer_function=TransferFunction.BT709, + color_matrix=ColorMatrix.BT709, + is_hdr=False + ) + assert info.needs_conversion is False + assert info.is_wide_gamut_sdr is False + + def test_unknown_no_conversion_needed(self): + """Unknown color space should not trigger conversion (safe default).""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.UNKNOWN, + transfer_function=TransferFunction.UNKNOWN, + color_matrix=ColorMatrix.UNKNOWN, + is_hdr=False + ) + assert info.needs_conversion is False + + def test_display_p3_needs_conversion(self): + """Display P3 SDR should need conversion.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.DISPLAY_P3, + transfer_function=TransferFunction.BT709, + color_matrix=ColorMatrix.BT709, + is_hdr=False + ) + assert info.needs_conversion is True + assert info.is_wide_gamut_sdr is True + + def test_bt2020_sdr_needs_conversion(self): + """BT.2020 SDR should need conversion.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT2020, + transfer_function=TransferFunction.BT709, + color_matrix=ColorMatrix.BT709, + is_hdr=False + ) + assert info.needs_conversion is True + assert info.is_wide_gamut_sdr is True + + def test_hdr_pq_needs_conversion(self): + """HDR with PQ transfer (HDR10/Dolby Vision) should need conversion.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT2020, + transfer_function=TransferFunction.PQ, + color_matrix=ColorMatrix.BT2020_NCL, + is_hdr=True + ) + assert info.needs_conversion is True + assert info.is_wide_gamut_sdr is False # It's HDR, not SDR + + def test_hdr_hlg_needs_conversion(self): + """HDR with HLG transfer should need conversion.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT2020, + transfer_function=TransferFunction.HLG, + color_matrix=ColorMatrix.BT2020_NCL, + is_hdr=True + ) + assert info.needs_conversion is True + + +class TestParseColorInfoFromStream: + """Tests for parse_color_info_from_stream function.""" + + def test_parse_bt709_stream(self): + """Parse BT.709 stream data.""" + stream = { + 'color_primaries': 'bt709', + 'color_transfer': 'bt709', + 'color_space': 'bt709' + } + info = parse_color_info_from_stream(stream) + assert info.color_primaries == ColorPrimaries.BT709 + assert info.transfer_function == TransferFunction.BT709 + assert info.color_matrix == ColorMatrix.BT709 + assert info.is_hdr is False + + def test_parse_display_p3_stream(self): + """Parse Display P3 stream data (iPhone SDR).""" + stream = { + 'color_primaries': 'smpte432', + 'color_transfer': 'bt709', + 'color_space': 'bt709' + } + info = parse_color_info_from_stream(stream) + assert info.color_primaries == ColorPrimaries.DISPLAY_P3 + assert info.is_hdr is False + + def test_parse_hdr10_stream(self): + """Parse HDR10 stream data.""" + stream = { + 'color_primaries': 'bt2020', + 'color_transfer': 'smpte2084', + 'color_space': 'bt2020nc' + } + info = parse_color_info_from_stream(stream) + assert info.color_primaries == ColorPrimaries.BT2020 + assert info.transfer_function == TransferFunction.PQ + assert info.color_matrix == ColorMatrix.BT2020_NCL + assert info.is_hdr is True + + def test_parse_hlg_stream(self): + """Parse HLG HDR stream data.""" + stream = { + 'color_primaries': 'bt2020', + 'color_transfer': 'arib-std-b67', + 'color_space': 'bt2020nc' + } + info = parse_color_info_from_stream(stream) + assert info.transfer_function == TransferFunction.HLG + assert info.is_hdr is True + + def test_parse_empty_stream(self): + """Parse stream with missing color data.""" + stream = {} + info = parse_color_info_from_stream(stream) + assert info.color_primaries == ColorPrimaries.UNKNOWN + assert info.transfer_function == TransferFunction.UNKNOWN + assert info.color_matrix == ColorMatrix.UNKNOWN + assert info.is_hdr is False + + def test_parse_stream_with_max_luminance(self): + """Parse HDR stream with mastering display metadata.""" + stream = { + 'color_primaries': 'bt2020', + 'color_transfer': 'smpte2084', + 'color_space': 'bt2020nc', + 'side_data_list': [ + { + 'side_data_type': 'Mastering display metadata', + 'max_luminance': '10000000/10000' + } + ] + } + info = parse_color_info_from_stream(stream) + assert info.max_luminance == 1000.0 + + +class TestBuildColorspaceFilter: + """Tests for build_colorspace_filter function.""" + + def test_no_filter_for_bt709(self): + """No filter needed for standard BT.709.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT709, + transfer_function=TransferFunction.BT709, + color_matrix=ColorMatrix.BT709, + is_hdr=False + ) + assert build_colorspace_filter(info) is None + + def test_no_filter_for_unknown(self): + """No filter for unknown color space (safe default).""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.UNKNOWN, + transfer_function=TransferFunction.UNKNOWN, + color_matrix=ColorMatrix.UNKNOWN, + is_hdr=False + ) + assert build_colorspace_filter(info) is None + + def test_filter_for_display_p3(self): + """Filter generated for Display P3.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.DISPLAY_P3, + transfer_function=TransferFunction.BT709, + color_matrix=ColorMatrix.BT709, + is_hdr=False + ) + filter_str = build_colorspace_filter(info) + assert filter_str is not None + assert 'colorspace' in filter_str + assert 'bt709' in filter_str + assert 'smpte432' in filter_str + assert 'iprimaries=smpte432' in filter_str + assert 'iall=smpte432' not in filter_str + + @patch('sharp_frames.processing.colorspace.is_zscale_available', return_value=True) + def test_hdr_filter_with_zscale(self, mock_zscale): + """HDR filter uses zscale when available.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT2020, + transfer_function=TransferFunction.PQ, + color_matrix=ColorMatrix.BT2020_NCL, + is_hdr=True + ) + filter_str = build_colorspace_filter(info) + assert filter_str is not None + assert 'zscale' in filter_str + assert 'tonemap' in filter_str + assert 'hable' in filter_str + # Check that input parameters are specified + assert 'tin=' in filter_str + assert 'pin=' in filter_str + + @patch('sharp_frames.processing.colorspace.is_zscale_available', return_value=True) + def test_hdr_filter_applies_resize_before_float_intermediate(self, mock_zscale): + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT2020, + transfer_function=TransferFunction.PQ, + color_matrix=ColorMatrix.BT2020_NCL, + is_hdr=True, + ) + + filter_str = build_colorspace_filter(info, width=640) + + first_filter, float_format = filter_str.split(",", 1) + assert "w=640:h=-2:f=lanczos" in first_filter + assert float_format.startswith("format=gbrpf32le") + + @pytest.mark.parametrize("is_hdr", [False, True]) + def test_bt2020_constant_luminance_is_rejected(self, is_hdr): + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT2020, + transfer_function=( + TransferFunction.PQ if is_hdr else TransferFunction.BT709 + ), + color_matrix=ColorMatrix.BT2020_CL, + is_hdr=is_hdr, + ) + + with pytest.raises(RuntimeError, match="constant-luminance"): + build_colorspace_filter(info) + + @patch('sharp_frames.processing.colorspace.is_zscale_available', return_value=False) + def test_hdr_filter_fallback_without_zscale(self, mock_zscale): + """HDR conversion fails clearly when tone mapping is unavailable.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT2020, + transfer_function=TransferFunction.PQ, + color_matrix=ColorMatrix.BT2020_NCL, + is_hdr=True + ) + with pytest.raises(RuntimeError, match='zscale'): + build_colorspace_filter(info) + + +@pytest.mark.skipif(shutil.which('ffmpeg') is None, reason='FFmpeg is not installed') +class TestColorspaceFilterIntegration: + """Validate generated filters with a real FFmpeg process.""" + + @staticmethod + def _run_filter(source_params, filter_string): + source = ( + "testsrc2=size=64x64:rate=1,format=yuv420p," + f"setparams={source_params}" + ) + result = subprocess.run( + [ + 'ffmpeg', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', source, + '-frames:v', '1', '-vf', filter_string, + '-f', 'null', '-' + ], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + + def test_display_p3_filter_executes(self): + info = VideoColorInfo( + color_primaries=ColorPrimaries.DISPLAY_P3, + transfer_function=TransferFunction.BT709, + color_matrix=ColorMatrix.BT709, + is_hdr=False, + ) + self._run_filter( + 'color_primaries=smpte432:color_trc=bt709:colorspace=bt709', + build_colorspace_filter(info), + ) + + def test_bt2020_sdr_filter_executes(self): + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT2020, + transfer_function=TransferFunction.BT709, + color_matrix=ColorMatrix.BT2020_NCL, + is_hdr=False, + ) + self._run_filter( + 'color_primaries=bt2020:color_trc=bt709:colorspace=bt2020nc', + build_colorspace_filter(info), + ) + + def test_hdr_filter_executes(self): + if not is_zscale_available(): + pytest.skip("FFmpeg's zscale filter is not available") + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT2020, + transfer_function=TransferFunction.PQ, + color_matrix=ColorMatrix.BT2020_NCL, + is_hdr=True, + ) + self._run_filter( + 'color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc', + build_colorspace_filter(info), + ) + + +class TestGetColorInfoDescription: + """Tests for get_color_info_description function.""" + + def test_sdr_bt709_description(self): + """Description for standard SDR.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT709, + transfer_function=TransferFunction.BT709, + color_matrix=ColorMatrix.BT709, + is_hdr=False + ) + desc = get_color_info_description(info) + assert 'SDR' in desc + assert 'BT.709' in desc + + def test_display_p3_description(self): + """Description for Display P3.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.DISPLAY_P3, + transfer_function=TransferFunction.BT709, + color_matrix=ColorMatrix.BT709, + is_hdr=False + ) + desc = get_color_info_description(info) + assert 'SDR' in desc + assert 'Display P3' in desc + + def test_hdr_pq_description(self): + """Description for HDR10/Dolby Vision.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT2020, + transfer_function=TransferFunction.PQ, + color_matrix=ColorMatrix.BT2020_NCL, + is_hdr=True + ) + desc = get_color_info_description(info) + assert 'HDR10' in desc or 'Dolby Vision' in desc + assert 'BT.2020' in desc + + def test_hlg_description(self): + """Description for HLG HDR.""" + info = VideoColorInfo( + color_primaries=ColorPrimaries.BT2020, + transfer_function=TransferFunction.HLG, + color_matrix=ColorMatrix.BT2020_NCL, + is_hdr=True + ) + desc = get_color_info_description(info) + assert 'HLG' in desc + + +class TestDetectColorSpace: + """Tests for detect_color_space function with mocked ffprobe.""" + + @patch('subprocess.run') + def test_detect_bt709(self, mock_run): + """Detect BT.709 from ffprobe output.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout='{"streams": [{"codec_type": "video", "color_primaries": "bt709", "color_transfer": "bt709", "color_space": "bt709"}]}' + ) + info = detect_color_space('/fake/video.mp4') + assert info.color_primaries == ColorPrimaries.BT709 + assert info.is_hdr is False + + @patch('subprocess.run') + def test_detect_hdr(self, mock_run): + """Detect HDR from ffprobe output.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout='{"streams": [{"codec_type": "video", "color_primaries": "bt2020", "color_transfer": "smpte2084", "color_space": "bt2020nc"}]}' + ) + info = detect_color_space('/fake/video.mp4') + assert info.color_primaries == ColorPrimaries.BT2020 + assert info.transfer_function == TransferFunction.PQ + assert info.is_hdr is True + + @patch('subprocess.run') + def test_detect_failure_returns_default(self, mock_run): + """Return default color info when ffprobe fails.""" + mock_run.return_value = MagicMock(returncode=1, stdout='') + info = detect_color_space('/fake/video.mp4') + assert info.color_primaries == ColorPrimaries.UNKNOWN + assert info.is_hdr is False + + @patch('subprocess.run') + def test_detect_timeout_returns_default(self, mock_run): + """Return default color info on timeout.""" + from subprocess import TimeoutExpired + mock_run.side_effect = TimeoutExpired('ffprobe', 30) + info = detect_color_space('/fake/video.mp4') + assert info.color_primaries == ColorPrimaries.UNKNOWN + + +class TestIsZscaleAvailable: + """Tests for zscale availability check.""" + + @patch('subprocess.run') + def test_zscale_available(self, mock_run): + """Detect zscale when available.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout='... zscale ... other filters ...' + ) + # Clear the cache + import sharp_frames.processing.colorspace as cs + cs._zscale_available = None + + assert is_zscale_available() is True + + @patch('subprocess.run') + def test_zscale_not_available(self, mock_run): + """Detect when zscale is not available.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout='scale colorspace other_filter' + ) + # Clear the cache + import sharp_frames.processing.colorspace as cs + cs._zscale_available = None + + assert is_zscale_available() is False diff --git a/tests/test_frame_extractor.py b/tests/test_frame_extractor.py index 078ad0f..6002acb 100644 --- a/tests/test_frame_extractor.py +++ b/tests/test_frame_extractor.py @@ -15,10 +15,8 @@ test_video_file, test_video_directory, sample_config_video, - sample_config_directory, + sample_config_directory, sample_config_video_directory, - mock_ffmpeg_success, - mock_ffmpeg_failure ) @@ -34,7 +32,7 @@ def test_init(self): assert self.extractor is not None assert hasattr(self.extractor, 'extract_frames') - def test_extract_frames_video_type(self, sample_config_video, mock_ffmpeg_success): + def test_extract_frames_video_type(self, sample_config_video): """Test frame extraction from video file.""" config = sample_config_video.copy() config['input_type'] = 'video' @@ -70,7 +68,7 @@ def test_extract_frames_directory_type(self, sample_config_directory): mock_load.assert_called_once_with(config['input_path']) assert result.input_type == 'directory' - def test_extract_frames_video_directory_type(self, sample_config_video_directory, mock_ffmpeg_success): + def test_extract_frames_video_directory_type(self, sample_config_video_directory): """Test frame extraction from video directory.""" config = sample_config_video_directory.copy() config['input_type'] = 'video_directory' @@ -132,13 +130,24 @@ def test_load_images_nonexistent_directory(self): with pytest.raises(FileNotFoundError): self.extractor._load_images("/nonexistent/directory") - def test_extract_video_frames_success(self, sample_config_video, mock_ffmpeg_success): + def test_extract_video_frames_success(self, sample_config_video): """Test successful video frame extraction.""" config = sample_config_video.copy() + video_info = { + 'format': {'duration': '1.0'}, + 'streams': [{'codec_type': 'video'}], + } + + extracted_paths = [ + '/tmp/test_frames/frame_00001.jpg', + '/tmp/test_frames/frame_00002.jpg', + ] + with patch('tempfile.mkdtemp', return_value='/tmp/test_frames'), \ - patch('os.listdir', return_value=['frame_00001.jpg', 'frame_00002.jpg']), \ - patch('os.path.isfile', return_value=True): + patch.object(self.extractor, '_get_video_info', return_value=video_info), \ + patch.object(self.extractor, '_get_extracted_frame_files', return_value=extracted_paths), \ + patch.object(self.extractor, '_run_ffmpeg_extraction', return_value=True) as extract: result = self.extractor._extract_video_frames(config) @@ -147,18 +156,24 @@ def test_extract_video_frames_success(self, sample_config_video, mock_ffmpeg_suc assert result.temp_dir == '/tmp/test_frames' assert len(result.frames) == 2 - # Verify FFmpeg was called - mock_ffmpeg_success['run'].assert_called_once() + extract.assert_called_once() - def test_extract_video_frames_ffmpeg_failure(self, sample_config_video, mock_ffmpeg_failure): + def test_extract_video_frames_ffmpeg_failure(self, sample_config_video): """Test handling of FFmpeg extraction failure.""" config = sample_config_video.copy() - with patch('tempfile.mkdtemp', return_value='/tmp/test_frames'): + video_info = { + 'format': {'duration': '1.0'}, + 'streams': [{'codec_type': 'video'}], + } + + with patch('tempfile.mkdtemp', return_value='/tmp/test_frames'), \ + patch.object(self.extractor, '_get_video_info', return_value=video_info), \ + patch.object(self.extractor, '_run_ffmpeg_extraction', return_value=False): with pytest.raises(RuntimeError, match="Frame extraction failed"): self.extractor._extract_video_frames(config) - def test_extract_video_directory_frames_success(self, sample_config_video_directory, test_video_directory, mock_ffmpeg_success): + def test_extract_video_directory_frames_success(self, sample_config_video_directory, test_video_directory): """Test successful video directory frame extraction with video attribution.""" config = sample_config_video_directory.copy() video_dir, video_files = test_video_directory @@ -234,7 +249,7 @@ def test_create_frame_data_with_video_attribution(self): assert frame_data.sharpness_score == 150.0 assert frame_data.source_video == "video_001" assert frame_data.source_index == 5 - assert frame_data.output_name == "video01_00006" # source_index + 1, zero-padded + assert frame_data.output_name == "video001_00006" def test_temp_directory_management(self): """Test temporary directory creation and tracking.""" @@ -270,4 +285,4 @@ def test_filter_image_files(self, test_images_directory): assert file_path.endswith('.jpg') # Should not include non-image files - assert str(non_image) not in filtered_files \ No newline at end of file + assert str(non_image) not in filtered_files diff --git a/tests/test_frame_saver.py b/tests/test_frame_saver.py new file mode 100644 index 0000000..5c465da --- /dev/null +++ b/tests/test_frame_saver.py @@ -0,0 +1,236 @@ +"""Regression tests for frame output safety and metadata accuracy.""" + +import json +from pathlib import Path + +import cv2 +import numpy as np + +from sharp_frames.models.frame_data import FrameData +from sharp_frames.processing.frame_saver import FrameSaver + + +def _write_image(path: Path, color: tuple[int, int, int]) -> None: + image = np.full((12, 16, 3), color, dtype=np.uint8) + assert cv2.imwrite(str(path), image) + + +def _write_rgba_image(path: Path) -> None: + image = np.zeros((12, 16, 4), dtype=np.uint8) + image[..., :3] = (10, 40, 200) + image[..., 3] = np.linspace(0, 255, 16, dtype=np.uint8) + assert cv2.imwrite(str(path), image) + + +def _config(output_dir: Path, **overrides): + config = { + "input_type": "directory", + "input_path": str(output_dir.parent / "input"), + "output_dir": str(output_dir), + "output_format": "jpg", + "width": 0, + "force_overwrite": False, + } + config.update(overrides) + return config + + +def test_noninteractive_conflict_aborts_without_mutating_output(tmp_path, capsys): + source = tmp_path / "source.jpg" + output_dir = tmp_path / "output" + output_dir.mkdir() + existing = output_dir / "SOURCE.JPG" + _write_image(source, (0, 255, 0)) + existing.write_bytes(b"original output") + + result = FrameSaver(show_progress=False).save_frames( + [FrameData(str(source), 0, 10.0, output_name="source")], + _config(output_dir), + ) + + assert result is False + assert existing.read_bytes() == b"original output" + assert sorted(path.name for path in output_dir.iterdir()) == ["SOURCE.JPG"] + assert str(existing) in capsys.readouterr().out + + +def test_unrelated_output_files_do_not_block_save(tmp_path): + source = tmp_path / "source.jpg" + output_dir = tmp_path / "output" + output_dir.mkdir() + finder_metadata = output_dir / ".DS_Store" + _write_image(source, (0, 255, 0)) + finder_metadata.write_bytes(b"finder metadata") + + result = FrameSaver(show_progress=False).save_frames( + [FrameData(str(source), 0, 10.0, output_name="source")], + _config(output_dir), + ) + + assert result is True + assert finder_metadata.read_bytes() == b"finder metadata" + assert (output_dir / "source.jpg").is_file() + assert (output_dir / "selected_metadata.json").is_file() + + +def test_existing_metadata_blocks_save_without_force_overwrite(tmp_path): + source = tmp_path / "source.jpg" + output_dir = tmp_path / "output" + output_dir.mkdir() + metadata = output_dir / "selected_metadata.json" + _write_image(source, (0, 255, 0)) + metadata.write_bytes(b"original metadata") + + result = FrameSaver(show_progress=False).save_frames( + [FrameData(str(source), 0, 10.0, output_name="source")], + _config(output_dir), + ) + + assert result is False + assert metadata.read_bytes() == b"original metadata" + assert not (output_dir / "source.jpg").exists() + + +def test_directory_image_is_transcoded_to_requested_format(tmp_path): + source = tmp_path / "source.png" + output_dir = tmp_path / "output" + _write_image(source, (255, 0, 0)) + + result = FrameSaver(show_progress=False).save_frames( + [FrameData(str(source), 0, 12.5, output_name="source")], + _config(output_dir), + ) + + output = output_dir / "source.jpg" + assert result is True + assert output.read_bytes().startswith(b"\xff\xd8\xff") + assert cv2.imread(str(output)) is not None + + metadata = json.loads((output_dir / "selected_metadata.json").read_text()) + assert metadata["output_format"] == "jpg" + assert metadata["total_saved"] == 1 + assert metadata["total_failed"] == 0 + assert metadata["selected_frames"][0]["output_filename"] == "source.jpg" + + +def test_transcoding_to_png_preserves_alpha(tmp_path): + source = tmp_path / "source.webp" + output_dir = tmp_path / "output" + _write_rgba_image(source) + + result = FrameSaver(show_progress=False).save_frames( + [FrameData(str(source), 0, 12.5, output_name="source")], + _config(output_dir, output_format="png"), + ) + + output = cv2.imread( + str(output_dir / "source.png"), cv2.IMREAD_UNCHANGED + ) + assert result is True + assert output is not None + assert output.shape[2] == 4 + assert output[..., 3].min() == 0 + assert output[..., 3].max() == 255 + + +def test_resizing_png_preserves_alpha(tmp_path): + source = tmp_path / "source.png" + output_dir = tmp_path / "output" + _write_rgba_image(source) + + result = FrameSaver(show_progress=False).save_frames( + [FrameData(str(source), 0, 12.5, output_name="source")], + _config(output_dir, output_format="png", width=8), + ) + + output = cv2.imread( + str(output_dir / "source.png"), cv2.IMREAD_UNCHANGED + ) + assert result is True + assert output is not None + assert output.shape == (6, 8, 4) + assert output[..., 3].min() < output[..., 3].max() + + +def test_jpeg_output_composites_transparency_on_white(tmp_path): + source = tmp_path / "source.png" + output_dir = tmp_path / "output" + image = np.zeros((12, 16, 4), dtype=np.uint8) + assert cv2.imwrite(str(source), image) + + result = FrameSaver(show_progress=False).save_frames( + [FrameData(str(source), 0, 12.5, output_name="source")], + _config(output_dir, output_format="jpg"), + ) + + output = cv2.imread(str(output_dir / "source.jpg")) + assert result is True + assert output is not None + assert output.mean() > 245 + + +def test_duplicate_directory_stems_get_deterministic_unique_names(tmp_path): + png_source = tmp_path / "foo.png" + jpg_source = tmp_path / "foo.jpg" + output_dir = tmp_path / "output" + _write_image(png_source, (255, 0, 0)) + _write_image(jpg_source, (0, 0, 255)) + frames = [ + FrameData(str(png_source), 0, 20.0, output_name="foo"), + FrameData(str(jpg_source), 1, 30.0, output_name="foo"), + ] + + result = FrameSaver(show_progress=False).save_frames(frames, _config(output_dir)) + + assert result is True + assert (output_dir / "foo.jpg").is_file() + assert (output_dir / "foo_2.jpg").is_file() + metadata = json.loads((output_dir / "selected_metadata.json").read_text()) + assert [item["output_filename"] for item in metadata["selected_frames"]] == [ + "foo.jpg", + "foo_2.jpg", + ] + + +def test_video_naming_conventions_are_unchanged(tmp_path): + saver = FrameSaver(show_progress=False) + frame = FrameData( + str(tmp_path / "frame.jpg"), + 4, + 10.0, + source_video="video_002", + source_index=4, + output_name="video002_00005", + ) + + assert saver._get_output_filename(frame, 0, "video", "png") == "frame_00001.png" + assert ( + saver._get_output_filename(frame, 0, "video_directory", "png") + == "video002_00005.png" + ) + + +def test_partial_failure_writes_truthful_metadata(tmp_path): + good_source = tmp_path / "good.jpg" + missing_source = tmp_path / "missing.jpg" + output_dir = tmp_path / "output" + _write_image(good_source, (10, 20, 30)) + frames = [ + FrameData(str(good_source), 0, 40.0, output_name="good"), + FrameData(str(missing_source), 1, 50.0, output_name="missing"), + ] + + result = FrameSaver(show_progress=False).save_frames(frames, _config(output_dir)) + + assert result is False + assert (output_dir / "good.jpg").is_file() + assert not (output_dir / "missing.jpg").exists() + + metadata = json.loads((output_dir / "selected_metadata.json").read_text()) + assert metadata["total_selected"] == 2 + assert metadata["total_saved"] == 1 + assert metadata["total_failed"] == 1 + assert metadata["selection_summary"]["total_frames"] == 1 + assert [item["output_filename"] for item in metadata["selected_frames"]] == [ + "good.jpg" + ] diff --git a/tests/test_frame_selector.py b/tests/test_frame_selector.py index f61ce23..cd70b76 100644 --- a/tests/test_frame_selector.py +++ b/tests/test_frame_selector.py @@ -1,299 +1,145 @@ -""" -Tests for FrameSelector component. -""" +"""Tests for the FrameSelector adapter over canonical selection algorithms.""" + +from collections import Counter import pytest -from unittest.mock import Mock, patch -from sharp_frames.processing.frame_selector import FrameSelector from sharp_frames.models.frame_data import FrameData -from tests.fixtures import ( - sample_frames_data, - mock_sharpness_scores, - expected_selection_outcomes -) +from sharp_frames.processing.frame_selector import FrameSelector + + +def make_frames(scores, source=None, index_offset=0): + return [ + FrameData( + path=f"/tmp/{source or 'frames'}/frame_{position:05d}.jpg", + index=index_offset + position, + sharpness_score=score, + source_video=source, + source_index=position if source else None, + output_name=f"{index_offset + position + 1:05d}", + ) + for position, score in enumerate(scores) + ] class TestFrameSelector: - """Test cases for FrameSelector component.""" - def setup_method(self): - """Set up test environment.""" - self.selector = FrameSelector() - + self.selector = FrameSelector(show_progress=False) + def test_init(self): - """Test FrameSelector initialization.""" - assert self.selector is not None - assert hasattr(self.selector, 'preview_selection') - assert hasattr(self.selector, 'select_frames') - - def test_preview_selection_best_n(self, sample_frames_data): - """Test preview count for best-n selection method.""" - frames = sample_frames_data - - # Test various n values - assert self.selector.preview_selection(frames, 'best_n', n=10) == 10 - assert self.selector.preview_selection(frames, 'best_n', n=50) == 50 - assert self.selector.preview_selection(frames, 'best_n', n=200) == 100 # Capped at total frames - assert self.selector.preview_selection(frames, 'best_n', n=0) == 0 - - def test_preview_selection_batched(self, sample_frames_data): - """Test preview count for batched selection method.""" - frames = sample_frames_data - - # Test with batch_size and batch_buffer parameters (legacy compatibility) - # With 100 frames, batch_size=5, batch_buffer=2 means we select 5, skip 2, repeat - # This gives us approximately 100 / (5+2) * 5 = ~71 frames - result = self.selector.preview_selection(frames, 'batched', batch_size=5, batch_buffer=2) - assert 60 <= result <= 75 # Allow some flexibility in the calculation - - # Simple case: batch_size=10, batch_buffer=0 (no skipping) - assert self.selector.preview_selection(frames, 'batched', batch_size=10, batch_buffer=0) == 100 - - # Edge case: batch_size=1, batch_buffer=9 (select 1, skip 9) - assert self.selector.preview_selection(frames, 'batched', batch_size=1, batch_buffer=9) == 10 - - def test_preview_selection_outlier_removal(self, mock_sharpness_scores): - """Test preview count for outlier removal method.""" - # Create frames with predictable sharpness distribution - frames = [] - for i, score in enumerate(mock_sharpness_scores): - frame = FrameData( - path=f"/tmp/frame_{i:05d}.jpg", - index=i, - sharpness_score=score, - output_name=f"{i+1:05d}" - ) - frames.append(frame) - - # With outlier_sensitivity and outlier_window_size parameters (legacy compatibility) - # Higher sensitivity (closer to 100) removes more outliers - count_high = self.selector.preview_selection(frames, 'outlier_removal', - outlier_sensitivity=80, outlier_window_size=15) - count_medium = self.selector.preview_selection(frames, 'outlier_removal', - outlier_sensitivity=50, outlier_window_size=15) - count_low = self.selector.preview_selection(frames, 'outlier_removal', - outlier_sensitivity=20, outlier_window_size=15) - - # Higher sensitivity should select fewer frames (more aggressive removal) - assert count_high < count_medium < count_low - assert all(count > 0 for count in [count_05, count_15, count_20]) - - def test_preview_selection_invalid_method(self, sample_frames_data): - """Test error handling for invalid selection method.""" - frames = sample_frames_data - + assert self.selector.show_progress is False + + @pytest.mark.parametrize( + ("method", "params"), + [ + ("best_n", {"n": 8, "min_buffer": 2}), + ("batched", {"batch_size": 4, "batch_buffer": 2}), + ( + "outlier_removal", + {"outlier_sensitivity": 70, "outlier_window_size": 7}, + ), + ], + ) + def test_preview_is_exact(self, method, params): + frames = make_frames([100, 95, 90, 20, 100, 95, 90] * 4) + + assert self.selector.preview_selection(frames, method, **params) == len( + self.selector.select_frames(frames, method, **params) + ) + + def test_empty_input_and_invalid_method(self): + assert self.selector.select_frames([], "best_n", n=10) == [] + assert self.selector.preview_selection([], "batched") == 0 with pytest.raises(ValueError, match="Unsupported selection method"): - self.selector.preview_selection(frames, 'invalid_method') - - def test_preview_selection_empty_frames(self): - """Test preview with empty frame list.""" - empty_frames = [] - - assert self.selector.preview_selection(empty_frames, 'best_n', n=10) == 0 - assert self.selector.preview_selection(empty_frames, 'batched', batch_size=5, batch_buffer=2) == 0 - assert self.selector.preview_selection(empty_frames, 'outlier_removal', - outlier_sensitivity=50, outlier_window_size=15) == 0 - - def test_select_frames_best_n(self, sample_frames_data): - """Test actual frame selection using best-n method.""" - frames = sample_frames_data - n = 10 - - selected = self.selector.select_frames(frames, 'best_n', n=n) - - assert len(selected) == n - assert all(isinstance(frame, FrameData) for frame in selected) - - # Verify frames are sorted by sharpness (highest first) - scores = [frame.sharpness_score for frame in selected] - assert scores == sorted(scores, reverse=True) - - # Verify these are actually the top n frames - all_scores = [frame.sharpness_score for frame in frames] - top_n_scores = sorted(all_scores, reverse=True)[:n] - assert scores == top_n_scores - - def test_select_frames_batched(self, sample_frames_data): - """Test actual frame selection using batched method.""" - frames = sample_frames_data # 100 frames - batch_size = 5 - batch_buffer = 10 - - selected = self.selector.select_frames(frames, 'batched', batch_size=batch_size, batch_buffer=batch_buffer) - - # With batch_size=5 and batch_buffer=10, we select 5 frames, skip 10, repeat - # Expected number: floor(100 / (5+10)) * 5 = floor(6.67) * 5 = 6 * 5 = 30 frames - # Plus any remainder frames in the last incomplete batch - assert 25 <= len(selected) <= 35 - assert all(isinstance(frame, FrameData) for frame in selected) - - # Verify frames come in batches with gaps + self.selector.select_frames(make_frames([100]), "unknown") + + def test_best_n_balances_timeline_and_sharpness(self): + frames = make_frames([1_000 - index * 10 for index in range(20)]) + + selected = self.selector.select_frames(frames, "best_n", n=5, min_buffer=0) indices = [frame.index for frame in selected] - indices.sort() - - # Check that frames come in groups (batches) - # Can't check exact indices as it depends on sharpness within each batch - - def test_select_frames_outlier_removal(self, mock_sharpness_scores): - """Test actual frame selection using outlier removal method.""" - # Create frames with known score distribution - frames = [] - for i, score in enumerate(mock_sharpness_scores): - frame = FrameData( - path=f"/tmp/frame_{i:05d}.jpg", - index=i, - sharpness_score=score, - output_name=f"{i+1:05d}" - ) - frames.append(frame) - - selected = self.selector.select_frames(frames, 'outlier_removal', - outlier_sensitivity=50, outlier_window_size=15) - - assert len(selected) > 0 - assert len(selected) < len(frames) # Should remove some frames - assert all(isinstance(frame, FrameData) for frame in selected) - - # Verify outliers were removed (very high and very low scores) - selected_scores = [frame.sharpness_score for frame in selected] - - # Should not include the extreme outliers - # Very low scores (10-30) and very high scores (200-300) should be removed - assert all(score > 40 for score in selected_scores) # No very low scores - assert all(score < 180 for score in selected_scores) # No very high scores - - def test_select_frames_preserves_frame_data(self, sample_frames_data): - """Test that all frame data attributes are preserved during selection.""" - # Add video attribution to some frames - frames = sample_frames_data[:10] - for i, frame in enumerate(frames): - if i % 3 == 0: # Every 3rd frame has video attribution - frame.source_video = f"video_{i//3 + 1:03d}" - frame.source_index = i % 10 - frame.output_name = f"video{i//3 + 1:02d}_{i+1:05d}" - - selected = self.selector.select_frames(frames, 'best_n', n=5) - - # Verify all attributes are preserved - for frame in selected: - # Find original frame - original = next(f for f in frames if f.index == frame.index) - - assert frame.path == original.path - assert frame.index == original.index - assert frame.sharpness_score == original.sharpness_score - assert frame.source_video == original.source_video - assert frame.source_index == original.source_index - assert frame.output_name == original.output_name - - def test_select_frames_maintains_order_within_method(self, sample_frames_data): - """Test that frame selection maintains consistent ordering.""" - frames = sample_frames_data - - # Run selection multiple times - selected1 = self.selector.select_frames(frames, 'best_n', n=10) - selected2 = self.selector.select_frames(frames, 'best_n', n=10) - - # Should get same results - assert len(selected1) == len(selected2) - for f1, f2 in zip(selected1, selected2): - assert f1.index == f2.index - assert f1.sharpness_score == f2.sharpness_score - - def test_select_frames_edge_cases(self, sample_frames_data): - """Test edge cases in frame selection.""" - frames = sample_frames_data - - # Select more frames than available - selected = self.selector.select_frames(frames, 'best_n', n=200) - assert len(selected) == len(frames) - - # Select zero frames - selected = self.selector.select_frames(frames, 'best_n', n=0) - assert len(selected) == 0 - - # Single frame - single_frame = frames[:1] - selected = self.selector.select_frames(single_frame, 'best_n', n=10) - assert len(selected) == 1 - - def test_best_n_weighted_scoring(self): - """Test that best-n selection uses weighted scoring combining sharpness and distribution.""" - # Create frames with specific scores to test weighting - frames = [] - for i in range(10): - frame = FrameData( - path=f"/tmp/frame_{i:05d}.jpg", - index=i, - sharpness_score=100.0 + i * 10, # Increasing sharpness - output_name=f"{i+1:05d}" - ) - frames.append(frame) - - with patch.object(self.selector, '_calculate_weighted_scores') as mock_weighted: - # Mock weighted scores that favor distribution over raw sharpness - mock_weighted.return_value = [50 + i * 5 for i in range(10)] # Different from sharpness scores - - selected = self.selector.select_frames(frames, 'best_n', n=5) - - mock_weighted.assert_called_once_with(frames) - assert len(selected) == 5 - - def test_batched_selection_algorithm(self): - """Test the batched selection algorithm distributes frames correctly.""" - # Create 20 frames - frames = [] - for i in range(20): - frame = FrameData( - path=f"/tmp/frame_{i:05d}.jpg", - index=i, - sharpness_score=50.0 + i * 2, # Varying scores - output_name=f"{i+1:05d}" - ) - frames.append(frame) - - # Select 4 batches from 20 frames - selected = self.selector.select_frames(frames, 'batched', batch_size=4, batch_buffer=0) - - assert len(selected) == 4 - - # Verify frames come from different batches - # Batch 1: frames 0-4, Batch 2: frames 5-9, etc. - selected_indices = sorted([frame.index for frame in selected]) - - # Should have one frame from each batch range - assert any(0 <= idx <= 4 for idx in selected_indices) # Batch 1 - assert any(5 <= idx <= 9 for idx in selected_indices) # Batch 2 - assert any(10 <= idx <= 14 for idx in selected_indices) # Batch 3 - assert any(15 <= idx <= 19 for idx in selected_indices) # Batch 4 - - def test_outlier_removal_algorithm(self): - """Test the outlier removal algorithm correctly identifies and removes outliers.""" - # Create frames with known distribution - # Most frames: scores 100-150 (normal range) - # Outliers: scores 10-20 (too low) and 300-400 (too high) - frames = [] - scores = ([15, 12, 18] + # Low outliers - [100 + i * 2 for i in range(25)] + # Normal range - [350, 380, 320]) # High outliers - - for i, score in enumerate(scores): - frame = FrameData( - path=f"/tmp/frame_{i:05d}.jpg", - index=i, - sharpness_score=score, - output_name=f"{i+1:05d}" - ) - frames.append(frame) - - selected = self.selector.select_frames(frames, 'outlier_removal', - outlier_sensitivity=50, outlier_window_size=15) + + assert len(selected) == 5 + assert all( + any(start <= index < start + 4 for index in indices) + for start in range(0, 20, 4) + ) + + def test_best_n_enforces_intervening_frame_buffer(self): + frames = make_frames(range(10, 3, -1)) + + selected = self.selector.select_frames(frames, "best_n", n=3, min_buffer=3) + + assert len(selected) == 2 + assert all( + right.index - left.index >= 4 + for left, right in zip(selected, selected[1:]) + ) + + def test_best_n_returns_original_objects_and_preserves_metadata(self): + frames = make_frames(range(10), source="video_001") + + selected = self.selector.select_frames(frames, "best_n", n=3, min_buffer=0) + + assert all(any(frame is original for original in frames) for frame in selected) + assert all(frame.source_video == "video_001" for frame in selected) + assert [frame.index for frame in selected] == sorted(frame.index for frame in selected) + + def test_best_n_fairly_allocates_across_sources(self): + frames = [ + *make_frames([1_000] * 10, source="video_a"), + *make_frames([1] * 10, source="video_b", index_offset=10), + ] + + selected = self.selector.select_frames(frames, "best_n", n=5, min_buffer=0) + + assert Counter(frame.source_video for frame in selected) == { + "video_a": 3, + "video_b": 2, + } + + def test_batched_selects_one_sharpest_frame_per_source_local_batch(self): + frames = [ + *make_frames([1, 2, 3, 4, 5, 6], source="video_a"), + *make_frames([6, 5, 4, 3, 2, 1], source="video_b", index_offset=6), + ] + + selected = self.selector.select_frames( + frames, "batched", batch_size=4, batch_buffer=0 + ) + + assert [frame.index for frame in selected] == [3, 5, 6, 10] + + def test_outlier_removal_removes_local_blur_but_keeps_sharp_peaks(self): + frames = make_frames([100, 100, 100, 1, 100, 300, 100, 100, 100]) + + selected = self.selector.select_frames( + frames, + "outlier_removal", + outlier_sensitivity=100, + outlier_window_size=7, + ) selected_scores = [frame.sharpness_score for frame in selected] - - # Should exclude the extreme outliers - assert all(score >= 50 for score in selected_scores) # No very low scores - assert all(score <= 200 for score in selected_scores) # No very high scores - - # Should keep the normal range frames - assert len(selected) >= 20 # Most of the 25 normal frames should remain \ No newline at end of file + + assert 1 not in selected_scores + assert 300 in selected_scores + + def test_outlier_sensitivity_zero_keeps_every_frame(self): + frames = make_frames([100, 100, 1, 100, 100]) + + selected = self.selector.select_frames( + frames, + "outlier_removal", + outlier_sensitivity=0, + outlier_window_size=5, + ) + + assert selected == frames + + def test_selection_is_deterministic(self): + frames = make_frames([50 + ((index * 37) % 101) for index in range(100)]) + + first = self.selector.select_frames(frames, "best_n", n=20, min_buffer=2) + second = self.selector.select_frames(frames, "best_n", n=20, min_buffer=2) + + assert [frame.index for frame in first] == [frame.index for frame in second] diff --git a/tests/test_image_output.py b/tests/test_image_output.py new file mode 100644 index 0000000..1091b38 --- /dev/null +++ b/tests/test_image_output.py @@ -0,0 +1,86 @@ +"""Regression tests for shared image decode/resize/encode behavior.""" + +import cv2 +import numpy as np +import pytest +from PIL import Image + +from sharp_frames.image_output import ( + ImageOutputError, + canonical_image_format, + image_needs_processing, + transcode_image, +) + + +def test_uint16_png_retains_bit_depth_on_resize(tmp_path): + source = tmp_path / "deep.png" + image = np.full((8, 8, 3), 32768, dtype=np.uint16) + assert cv2.imwrite(str(source), image) + + output = tmp_path / "out.png" + transcode_image(str(source), str(output), width=4) + + decoded = cv2.imread(str(output), cv2.IMREAD_UNCHANGED) + assert decoded.dtype == np.uint16 + assert int(decoded.max()) > 255 + + +def test_uint16_png_to_jpeg_scales_to_eight_bit_midtone(tmp_path): + source = tmp_path / "deep.png" + image = np.full((8, 8, 3), 32768, dtype=np.uint16) + assert cv2.imwrite(str(source), image) + + output = tmp_path / "out.jpg" + transcode_image(str(source), str(output)) + + decoded = cv2.imread(str(output)) + assert decoded.dtype == np.uint8 + # 32768/65535 ~= 0.5 -> ~128, definitely not saturated white. + assert 120 <= int(decoded.mean()) <= 136 + + +def test_float_tiff_to_png_is_scaled_not_black(tmp_path): + source = tmp_path / "linear.tiff" + image = np.full((8, 8, 3), 0.5, dtype=np.float32) + assert cv2.imwrite(str(source), image) + + output = tmp_path / "out.png" + transcode_image(str(source), str(output)) + + decoded = cv2.imread(str(output), cv2.IMREAD_UNCHANGED) + assert decoded.dtype == np.uint8 + assert int(decoded.mean()) > 100 + + +def test_exif_orientation_is_applied_before_resize(tmp_path): + source = tmp_path / "portrait.jpg" + # Landscape pixels (tall on disk once orientation 6 is applied). + array = np.zeros((20, 40, 3), dtype=np.uint8) + Image.fromarray(array).save(str(source), exif=_exif_orientation(6)) + + output = tmp_path / "out.jpg" + transcode_image(str(source), str(output), width=10) + + decoded = cv2.imread(str(output)) + # Orientation 6 rotates 40x20 landscape into 20x40 portrait, so width 10 + # gives a taller-than-wide result. + assert decoded.shape[0] > decoded.shape[1] + + +def test_canonical_format_aliases(): + assert canonical_image_format("a.JPEG") == "jpg" + assert canonical_image_format("a.tif") == "tiff" + assert image_needs_processing("a.png", "b.png", width=0) is False + assert image_needs_processing("a.png", "b.jpg", width=0) is True + + +def test_missing_source_raises(tmp_path): + with pytest.raises(ImageOutputError, match="Failed to read"): + transcode_image(str(tmp_path / "missing.png"), str(tmp_path / "o.png")) + + +def _exif_orientation(value: int) -> bytes: + exif = Image.Exif() + exif[274] = value + return exif.tobytes() diff --git a/tests/test_runtime_hardening.py b/tests/test_runtime_hardening.py new file mode 100644 index 0000000..f5b342c --- /dev/null +++ b/tests/test_runtime_hardening.py @@ -0,0 +1,476 @@ +"""Regression tests for extraction lifecycle and platform support.""" + +import io +import shutil +import subprocess +import threading +import time +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from sharp_frames.models.frame_data import ExtractionResult, FrameData +from sharp_frames.processing.colorspace import ( + ColorMatrix, + ColorPrimaries, + TransferFunction, + VideoColorInfo, +) +from sharp_frames.processing.frame_extractor import FrameExtractor +from sharp_frames.processing.tui_processor import TUIProcessor +from sharp_frames.sharp_frames_processor import SharpFrames +from sharp_frames.ui.components.validators import ( + ImageDirectoryValidator, + VideoFileValidator, +) +from sharp_frames.ui.utils.error_analysis import ErrorContext +from sharp_frames.video_utils import ( + SUPPORTED_IMAGE_EXTENSIONS, + SUPPORTED_VIDEO_EXTENSIONS, + get_ffmpeg_installation_hint, +) + + +class CompletedFakeProcess: + """Popen stand-in that has already completed.""" + + def __init__(self, returncode: int, stdout: str = "", stderr: str = ""): + self.returncode = returncode + self.stdout = io.StringIO(stdout) + self.stderr = io.StringIO(stderr) + + def poll(self): + return self.returncode + + def wait(self, timeout=None): + return self.returncode + + +class RunningFakeProcess: + """Popen stand-in that runs until terminate is called.""" + + def __init__(self): + self.returncode = None + self.stdout = io.StringIO("") + self.stderr = io.StringIO("") + self.terminated = threading.Event() + + def poll(self): + return self.returncode + + def wait(self, timeout=None): + if self.returncode is not None: + return self.returncode + if not self.terminated.wait(timeout or 0): + raise subprocess.TimeoutExpired("ffmpeg", timeout) + return self.returncode + + def terminate(self): + self.returncode = 143 + self.terminated.set() + + def kill(self): + self.returncode = -9 + self.terminated.set() + + +class StubbornFakeProcess: + """Process stand-in that never acknowledges terminate or kill.""" + + def __init__(self): + self.terminate_called = False + self.kill_called = False + + def poll(self): + return None + + def terminate(self): + self.terminate_called = True + + def kill(self): + self.kill_called = True + + def wait(self, timeout=None): + raise subprocess.TimeoutExpired("ffmpeg", timeout) + + +def _create_frames(directory: Path, count: int) -> None: + for index in range(1, count + 1): + (directory / f"frame_{index:05d}.jpg").touch() + + +def test_hdr_filters_drop_frames_and_resize_before_float_tone_mapping(): + extractor = FrameExtractor() + color_info = VideoColorInfo( + color_primaries=ColorPrimaries.BT2020, + transfer_function=TransferFunction.PQ, + color_matrix=ColorMatrix.BT2020_NCL, + is_hdr=True, + ) + + with patch( + "sharp_frames.processing.colorspace.is_zscale_available", + return_value=True, + ): + filters = extractor._build_video_filters(1, 640, color_info) + + assert filters[0] == "fps=1" + assert "w=640:h=-2:f=lanczos" in filters[1].split(",", 1)[0] + assert "format=gbrpf32le" in filters[1] + assert not any(filter_.startswith("scale=") for filter_ in filters) + + +def test_cancel_processing_only_signals_the_worker(): + extractor = FrameExtractor() + process = RunningFakeProcess() + extractor._active_process = process + + with patch.object(extractor, "_terminate_process") as terminate: + extractor.cancel_processing() + + assert extractor._cancellation_event.is_set() + terminate.assert_not_called() + assert process.returncode is None + + +def test_ffmpeg_nonzero_exit_rejects_partial_output(tmp_path): + """A failed process must never be accepted because it wrote many files.""" + _create_frames(tmp_path, 12) + process = CompletedFakeProcess(1, stdout="frame=12\nprogress=end\n", stderr="conversion failed") + extractor = FrameExtractor() + + with patch("subprocess.Popen", return_value=process) as popen: + success = extractor._run_ffmpeg_extraction( + "input.mp4", str(tmp_path), 10, "jpg", 0, duration=2.0 + ) + + assert success is False + command = popen.call_args.args[0] + assert command[command.index("-progress") + 1] == "pipe:1" + + +def test_ffmpeg_success_requires_clean_exit_and_reports_completion(tmp_path): + _create_frames(tmp_path, 2) + process = CompletedFakeProcess(0, stdout="frame=2\nprogress=end\n") + progress = Mock() + extractor = FrameExtractor() + extractor.progress_callback = progress + + with patch("subprocess.Popen", return_value=process): + success = extractor._run_ffmpeg_extraction( + "input.mp4", str(tmp_path), 2, "jpg", 0, duration=1.0 + ) + + assert success is True + progress.assert_any_call("extraction", 2, 2, "Extraction complete: 2 frames") + + +def test_ffmpeg_timeout_terminates_process_and_rejects_output(tmp_path): + _create_frames(tmp_path, 20) + process = RunningFakeProcess() + extractor = FrameExtractor() + + with patch("subprocess.Popen", return_value=process), patch.object( + extractor, + "_wait_for_process", + side_effect=subprocess.TimeoutExpired("ffmpeg", 300), + ): + success = extractor._run_ffmpeg_extraction( + "input.mp4", str(tmp_path), 10, "jpg", 0, duration=1.0 + ) + + assert success is False + assert process.terminated.is_set() + assert not any( + thread.name.startswith("sharp-frames-ffmpeg-") + for thread in threading.enumerate() + ) + + +def test_cancel_processing_terminates_active_ffmpeg(tmp_path): + process = RunningFakeProcess() + extractor = FrameExtractor() + + with patch("subprocess.Popen", return_value=process): + worker = threading.Thread( + target=extractor._run_ffmpeg_extraction, + args=("input.mp4", str(tmp_path), 10, "jpg", 0), + ) + worker.start() + time.sleep(0.05) + extractor.cancel_processing() + worker.join(timeout=1) + + assert process.terminated.is_set() + assert not worker.is_alive() + + +def test_keyboard_interrupt_terminates_active_ffmpeg(tmp_path): + process = RunningFakeProcess() + extractor = FrameExtractor() + + with patch("subprocess.Popen", return_value=process), patch.object( + extractor, "_wait_for_process", side_effect=KeyboardInterrupt + ): + with pytest.raises(KeyboardInterrupt): + extractor._run_ffmpeg_extraction( + "input.mp4", str(tmp_path), 10, "jpg", 0 + ) + + assert process.terminated.is_set() + + +def test_termination_escalation_does_not_mask_original_failure(): + process = StubbornFakeProcess() + + FrameExtractor._terminate_process(process) + + assert process.terminate_called is True + assert process.kill_called is True + + +def test_ffprobe_missing_is_reported_without_unbound_local_error(): + extractor = FrameExtractor() + with patch("subprocess.run", side_effect=FileNotFoundError): + with pytest.raises(RuntimeError, match="FFprobe not found"): + extractor._get_video_info("input.mp4") + + +def test_ffprobe_failure_preserves_actionable_stderr(): + extractor = FrameExtractor() + failed_probe = subprocess.CompletedProcess( + ["ffprobe"], 1, stdout="", stderr="Invalid data found when processing input" + ) + + with patch("subprocess.run", return_value=failed_probe) as run_probe: + with pytest.raises(RuntimeError, match="Invalid data found"): + extractor._get_video_info("broken.mp4") + + command = run_probe.call_args.args[0] + assert command[command.index("-v") + 1] == "error" + + +def test_progress_callback_failure_does_not_stop_pipe_drain(): + extractor = FrameExtractor() + extractor.progress_callback = Mock(side_effect=RuntimeError("UI unavailable")) + stream = io.StringIO("frame=1\nframe=2\nprogress=end\n") + + extractor._read_ffmpeg_progress(stream, estimated_total=2) + + assert stream.tell() == len(stream.getvalue()) + assert extractor.progress_callback is None + + +def test_tui_cancel_reaches_extractor(): + processor = TUIProcessor() + with patch.object(processor.extractor, "cancel_processing") as cancel_extraction: + processor.cancel_processing() + cancel_extraction.assert_called_once_with() + + +def test_tui_cleans_extracted_temp_dir_when_analysis_fails(tmp_path): + temp_dir = tmp_path / "frames" + temp_dir.mkdir() + frame_path = temp_dir / "frame_00001.jpg" + frame_path.touch() + result = ExtractionResult( + frames=[FrameData(str(frame_path), 0, 0.0, output_name="00001")], + metadata={}, + temp_dir=str(temp_dir), + input_type="video", + ) + processor = TUIProcessor() + + with patch.object(processor.extractor, "extract_frames", return_value=result), patch.object( + processor.analyzer, "calculate_sharpness", side_effect=RuntimeError("analysis failed") + ): + with pytest.raises(RuntimeError, match="analysis failed"): + processor.extract_and_analyze({"input_type": "video", "input_path": "input.mp4"}) + + assert not temp_dir.exists() + assert result.temp_dir is None + assert processor.current_result is None + + +def test_tui_cancellation_during_analysis_cleans_and_discards_result(tmp_path): + temp_dir = tmp_path / "frames" + temp_dir.mkdir() + frame_path = temp_dir / "frame_00001.jpg" + frame_path.touch() + result = ExtractionResult( + frames=[FrameData(str(frame_path), 0, 1.0, output_name="00001")], + metadata={}, + temp_dir=str(temp_dir), + input_type="video", + ) + processor = TUIProcessor() + + def cancel_during_analysis(extraction_result, progress_callback=None): + processor.cancel_processing() + return extraction_result + + with patch.object(processor.extractor, "extract_frames", return_value=result), patch.object( + processor.analyzer, "calculate_sharpness", side_effect=cancel_during_analysis + ): + cancelled_result = processor.extract_and_analyze( + {"input_type": "video", "input_path": "input.mp4"} + ) + + assert cancelled_result.frames == [] + assert processor.current_result is None + assert not temp_dir.exists() + + +def test_processor_can_be_explicitly_reset_after_cancellation(): + processor = TUIProcessor() + processor.cancel_processing() + + processor.reset_current_result() + + assert processor._cancelled is False + assert processor.extractor._cancellation_event.is_set() is False + assert processor.analyzer._cancellation_event.is_set() is False + + +def test_image_only_dependency_check_does_not_invoke_ffmpeg(): + with patch("subprocess.run", side_effect=AssertionError("video tools should not be checked")), patch( + "cv2.cvtColor", return_value=Mock() + ): + assert ErrorContext.check_system_dependencies(require_video_tools=False) is None + + +@pytest.mark.parametrize( + ("system_name", "expected"), + [ + ("Windows", "bin directory to PATH"), + ("Darwin", "brew install ffmpeg"), + ("Linux", "sudo apt install ffmpeg"), + ], +) +def test_missing_video_tool_guidance_is_platform_specific(system_name, expected): + with patch("sharp_frames.video_utils.platform.system", return_value=system_name): + assert expected in get_ffmpeg_installation_hint() + + +def test_media_extensions_use_one_shared_contract(): + assert FrameExtractor().SUPPORTED_IMAGE_EXTENSIONS == SUPPORTED_IMAGE_EXTENSIONS + assert ImageDirectoryValidator.SUPPORTED_IMAGE_EXTENSIONS == SUPPORTED_IMAGE_EXTENSIONS + assert VideoFileValidator.SUPPORTED_VIDEO_EXTENSIONS == SUPPORTED_VIDEO_EXTENSIONS + assert {".webp", ".tif"} <= SUPPORTED_IMAGE_EXTENSIONS + assert {".m4v", ".3gp", ".ts", ".mts", ".m2ts"} <= SUPPORTED_VIDEO_EXTENSIONS + + +def test_direct_cli_discovers_shared_image_formats(tmp_path): + expected = {tmp_path / "photo.webp", tmp_path / "scan.tif", tmp_path / "map.ppm"} + for path in expected: + path.touch() + (tmp_path / "notes.txt").touch() + processor = SharpFrames( + input_path=str(tmp_path), + input_type="directory", + output_dir=str(tmp_path / "output"), + ) + + assert set(map(Path, processor._get_image_paths_from_dir())) == expected + + +def test_minimal_progress_conflict_aborts_without_overwrite(tmp_path, capsys): + from sharp_frames.processing.minimal_progress import MinimalProgressSharpFrames + + output_dir = tmp_path / "output" + output_dir.mkdir() + existing = output_dir / "keep.jpg" + existing.write_bytes(b"keep") + processor = MinimalProgressSharpFrames( + input_path=str(tmp_path), + input_type="directory", + output_dir=str(output_dir), + force_overwrite=False, + ) + + with patch.object(processor, "_check_dependencies", return_value=True): + assert processor._setup() is False + + assert existing.read_bytes() == b"keep" + assert str(existing) in capsys.readouterr().out + + +def test_video_directory_cancellation_stops_before_next_video(tmp_path): + temp_dir = tmp_path / "extracted" + temp_dir.mkdir() + extractor = FrameExtractor() + + def cancel_after_first(*args, **kwargs): + extractor.cancel_processing() + return [] + + with patch( + "sharp_frames.processing.frame_extractor.get_video_files_in_directory", + return_value=["one.mp4", "two.mp4"], + ), patch.object(extractor, "_create_temp_directory", return_value=str(temp_dir)), patch.object( + extractor, "_extract_single_video", side_effect=cancel_after_first + ) as extract_one: + with pytest.raises(RuntimeError, match="cancelled"): + extractor._extract_video_directory_frames( + {"input_path": str(tmp_path), "input_type": "video_directory"} + ) + + assert extract_one.call_count == 1 + assert not temp_dir.exists() + + +def test_video_directory_all_failures_do_not_leak_temp_dir(tmp_path): + temp_dir = tmp_path / "extracted" + temp_dir.mkdir() + extractor = FrameExtractor() + + with patch( + "sharp_frames.processing.frame_extractor.get_video_files_in_directory", + return_value=["broken.mp4"], + ), patch.object(extractor, "_create_temp_directory", return_value=str(temp_dir)), patch.object( + extractor, "_extract_single_video", side_effect=RuntimeError("bad video") + ): + result = extractor._extract_video_directory_frames( + {"input_path": str(tmp_path), "input_type": "video_directory"} + ) + + assert result.frames == [] + assert result.temp_dir is None + assert not temp_dir.exists() + + +@pytest.mark.skipif( + shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None, + reason="FFmpeg and FFprobe are required for the extraction integration test", +) +def test_real_ffmpeg_extraction_completes_and_reports_progress(tmp_path): + video_path = tmp_path / "synthetic.mp4" + subprocess.run( + [ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=1", + "-pix_fmt", "yuv420p", str(video_path), + ], + check=True, + ) + progress = Mock() + extractor = FrameExtractor() + result = extractor.extract_frames( + { + "input_type": "video", + "input_path": str(video_path), + "fps": 5, + "output_format": "jpg", + "width": 0, + }, + progress, + ) + + try: + assert len(result.frames) == 5 + assert all(Path(frame.path).is_file() for frame in result.frames) + assert any(call.args[0] == "extraction" for call in progress.call_args_list) + finally: + if result.temp_dir: + shutil.rmtree(result.temp_dir) diff --git a/tests/test_selection_preview.py b/tests/test_selection_preview.py index fed06df..3a5f072 100644 --- a/tests/test_selection_preview.py +++ b/tests/test_selection_preview.py @@ -1,328 +1,178 @@ -""" -Tests for selection preview functionality. -""" +"""Tests for exact selection previews.""" + +import time import pytest -from unittest.mock import Mock, patch - -from sharp_frames.selection_preview import get_selection_count, get_selection_preview -from sharp_frames.models.frame_data import FrameData -from tests.fixtures import ( - sample_frames_data, - mock_sharpness_scores, - expected_selection_outcomes + +from sharp_frames.selection_methods import ( + select_batched_frames_core, + select_best_n_frames_core, + select_outlier_removal_frames_core, +) +from sharp_frames.selection_preview import ( + _calculate_cached_preview, + _preview_cache, + get_selection_count, + get_selection_preview, ) -class TestSelectionPreview: - """Test cases for selection preview functions.""" - - def test_get_selection_count_best_n(self, sample_frames_data): - """Test selection count preview for best-n method.""" - frames_dict = self._convert_frames_to_dict(sample_frames_data) - - # Test various n values - assert get_selection_count(frames_dict, 'best-n', n=10) == 10 - assert get_selection_count(frames_dict, 'best-n', n=50) == 50 - assert get_selection_count(frames_dict, 'best-n', n=200) == 100 # Capped at total frames - assert get_selection_count(frames_dict, 'best-n', n=0) == 0 - - def test_get_selection_count_batched(self, sample_frames_data): - """Test selection count preview for batched method.""" - frames_dict = self._convert_frames_to_dict(sample_frames_data) - - # Test with batch_size and batch_buffer parameters - result = get_selection_count(frames_dict, 'batched', batch_size=5, batch_buffer=10) - assert 25 <= result <= 35 # With 100 frames, batch_size=5, batch_buffer=10 - - assert get_selection_count(frames_dict, 'batched', batch_size=10, batch_buffer=0) == 100 - assert get_selection_count(frames_dict, 'batched', batch_size=1, batch_buffer=9) == 10 - - def test_get_selection_count_outlier_removal(self, mock_sharpness_scores): - """Test selection count preview for outlier removal method.""" - frames_dict = self._create_frames_dict_with_scores(mock_sharpness_scores) - - # Test with outlier_sensitivity and outlier_window_size parameters - count_high = get_selection_count(frames_dict, 'outlier-removal', - outlier_sensitivity=80, outlier_window_size=15) - count_medium = get_selection_count(frames_dict, 'outlier-removal', - outlier_sensitivity=50, outlier_window_size=15) - count_low = get_selection_count(frames_dict, 'outlier-removal', - outlier_sensitivity=20, outlier_window_size=15) - - # Higher sensitivity should select fewer frames (more aggressive removal) - assert count_high < count_medium < count_low - assert all(count > 0 for count in [count_high, count_medium, count_low]) - - def test_get_selection_count_invalid_method(self, sample_frames_data): - """Test error handling for invalid selection method.""" - frames_dict = self._convert_frames_to_dict(sample_frames_data) - - with pytest.raises(ValueError, match="Unsupported selection method"): - get_selection_count(frames_dict, 'invalid-method') - - def test_get_selection_count_empty_frames(self): - """Test selection count with empty frame list.""" - empty_frames = [] - - assert get_selection_count(empty_frames, 'best-n', n=10) == 0 - assert get_selection_count(empty_frames, 'batched', batch_size=5, batch_buffer=2) == 0 - assert get_selection_count(empty_frames, 'outlier-removal', - outlier_sensitivity=50, outlier_window_size=15) == 0 - - def test_get_selection_count_performance_large_dataset(self): - """Test that selection count calculation is fast for large datasets.""" - import time - - # Create large dataset (10,000 frames) - large_frames = [] - for i in range(10000): - frame_dict = { - 'id': f"frame_{i:05d}.jpg", - 'path': f"/tmp/frame_{i:05d}.jpg", - 'index': i, - 'sharpnessScore': 50.0 + i * 0.01 # Gradually increasing scores - } - large_frames.append(frame_dict) - - # Measure time for preview calculation - start_time = time.time() - count = get_selection_count(large_frames, 'best-n', n=1000) - end_time = time.time() - - # Should complete within 100ms (0.1 seconds) for responsive UI - elapsed = end_time - start_time - assert elapsed < 0.1, f"Preview took {elapsed:.3f}s, should be <0.1s" - assert count == 1000 - - def test_get_selection_preview_best_n(self, sample_frames_data): - """Test detailed selection preview for best-n method.""" - frames_dict = self._convert_frames_to_dict(sample_frames_data) - - preview = get_selection_preview(frames_dict, 'best-n', n=10) - - assert isinstance(preview, dict) - assert 'count' in preview - assert 'distribution' in preview - assert 'statistics' in preview - - assert preview['count'] == 10 - - # Check distribution (histogram of selected frames across timeline) - distribution = preview['distribution'] - assert isinstance(distribution, list) - assert len(distribution) > 0 # Should have distribution data - - # Check statistics - stats = preview['statistics'] - assert 'min_sharpness' in stats - assert 'max_sharpness' in stats - assert 'avg_sharpness' in stats - assert stats['min_sharpness'] <= stats['avg_sharpness'] <= stats['max_sharpness'] - - def test_get_selection_preview_batched(self, sample_frames_data): - """Test detailed selection preview for batched method.""" - frames_dict = self._convert_frames_to_dict(sample_frames_data) - - preview = get_selection_preview(frames_dict, 'batched', batch_size=5, batch_buffer=2) - - assert preview['count'] == 5 - - # Distribution should show frames spread across timeline - distribution = preview['distribution'] - assert len(distribution) == 5 # One bin per batch - - # Each bin should have exactly 1 frame (for 5 batches from 100 frames) - assert all(bin_count == 1 for bin_count in distribution) - - def test_get_selection_preview_outlier_removal(self, mock_sharpness_scores): - """Test detailed selection preview for outlier removal method.""" - frames_dict = self._create_frames_dict_with_scores(mock_sharpness_scores) - - preview = get_selection_preview(frames_dict, 'outlier-removal', - outlier_sensitivity=50, outlier_window_size=15) - - assert preview['count'] > 0 - assert preview['count'] < len(frames_dict) # Should remove some frames - - # Statistics should exclude outliers - stats = preview['statistics'] - assert stats['min_sharpness'] > min(mock_sharpness_scores) # Excluded low outliers - assert stats['max_sharpness'] < max(mock_sharpness_scores) # Excluded high outliers - - def test_selection_count_updates_with_parameter_changes(self, sample_frames_data): - """Test that selection count updates correctly as parameters change.""" - frames_dict = self._convert_frames_to_dict(sample_frames_data) - - # Best-n method with increasing n - counts = [] - for n in [5, 10, 20, 50]: - count = get_selection_count(frames_dict, 'best-n', n=n) - counts.append(count) - - # Counts should increase with n - assert counts == [5, 10, 20, 50] - - # Batched method with different batch_size values - batch_counts = [] - for batch_size in [2, 5, 10, 25]: - count = get_selection_count(frames_dict, 'batched', batch_size=batch_size, batch_buffer=0) - batch_counts.append(count) - - # With batch_buffer=0, all frames should be selected - assert batch_counts == [100, 100, 100, 100] - - def test_preview_consistency_with_actual_selection(self, sample_frames_data): - """Test that preview counts match actual selection results.""" - from sharp_frames.processing.frame_selector import FrameSelector - - frames_dict = self._convert_frames_to_dict(sample_frames_data) - selector = FrameSelector() - - # Test best-n - preview_count = get_selection_count(frames_dict, 'best-n', n=15) - actual_selected = selector.select_frames(sample_frames_data, 'best_n', n=15) - assert preview_count == len(actual_selected) - - # Test batched - preview_count = get_selection_count(frames_dict, 'batched', batch_size=8, batch_buffer=2) - actual_selected = selector.select_frames(sample_frames_data, 'batched', batch_size=8, batch_buffer=2) - assert abs(preview_count - len(actual_selected)) <= 2 # Allow small difference - - # Test outlier removal - preview_count = get_selection_count(frames_dict, 'outlier-removal', - outlier_sensitivity=50, outlier_window_size=15) - actual_selected = selector.select_frames(sample_frames_data, 'outlier_removal', - outlier_sensitivity=50, outlier_window_size=15) - assert abs(preview_count - len(actual_selected)) <= 5 # Allow small difference due to algorithm - - def test_preview_caching_for_performance(self, sample_frames_data): - """Test that repeated preview calculations are optimized.""" - frames_dict = self._convert_frames_to_dict(sample_frames_data) - - # Mock a caching mechanism - with patch('sharp_frames.selection_preview._calculate_cached_preview') as mock_cache: - mock_cache.return_value = {'count': 10, 'distribution': [], 'statistics': {}} - - # First call should calculate - preview1 = get_selection_preview(frames_dict, 'best-n', n=10) - - # Second call with same parameters should use cache - preview2 = get_selection_preview(frames_dict, 'best-n', n=10) - - assert preview1 == preview2 - - def test_distribution_histogram_accuracy(self, sample_frames_data): - """Test that distribution histogram accurately represents frame distribution.""" - frames_dict = self._convert_frames_to_dict(sample_frames_data) - - # Test with batched method for predictable distribution - preview = get_selection_preview(frames_dict, 'batched', batch_count=4) - distribution = preview['distribution'] - - # Should have 4 bins (one per batch) - assert len(distribution) == 4 - - # Each bin should have exactly 1 frame - assert all(count == 1 for count in distribution) - - # Sum of distribution should equal total count - assert sum(distribution) == preview['count'] - - def test_statistics_calculation_accuracy(self): - """Test accuracy of statistics calculations.""" - # Create frames with known score distribution - scores = [10.0, 20.0, 30.0, 40.0, 50.0] # Known values for easy testing - frames_dict = self._create_frames_dict_with_scores(scores) - - preview = get_selection_preview(frames_dict, 'best-n', n=3) # Select top 3 - stats = preview['statistics'] - - # Top 3 scores should be 50, 40, 30 - assert stats['min_sharpness'] == 30.0 - assert stats['max_sharpness'] == 50.0 - assert stats['avg_sharpness'] == 40.0 # (50+40+30)/3 - - def test_real_time_parameter_updates(self, sample_frames_data): - """Test rapid parameter changes for real-time UI updates.""" - frames_dict = self._convert_frames_to_dict(sample_frames_data) - - import time - - # Simulate rapid parameter changes - parameters = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50] - start_time = time.time() - - for n in parameters: - count = get_selection_count(frames_dict, 'best-n', n=n) - assert count == min(n, len(frames_dict)) - - end_time = time.time() - - # All 10 calculations should complete quickly for responsive UI - elapsed = end_time - start_time - assert elapsed < 0.5, f"10 preview calculations took {elapsed:.3f}s, should be <0.5s" - - def test_edge_case_single_frame(self): - """Test preview with single frame.""" - single_frame = [{ - 'id': 'frame_001.jpg', - 'path': '/tmp/frame_001.jpg', - 'index': 0, - 'sharpnessScore': 100.0 - }] - - # All methods should handle single frame gracefully - assert get_selection_count(single_frame, 'best-n', n=1) == 1 - assert get_selection_count(single_frame, 'best-n', n=10) == 1 # Capped - assert get_selection_count(single_frame, 'batched', batch_count=1) == 1 - assert get_selection_count(single_frame, 'batched', batch_count=5) == 1 # Capped - assert get_selection_count(single_frame, 'outlier-removal', factor=1.5) <= 1 - - def test_edge_case_identical_sharpness_scores(self): - """Test preview with frames having identical sharpness scores.""" - # Create frames with identical scores - identical_frames = [] - for i in range(10): - frame_dict = { - 'id': f"frame_{i:03d}.jpg", - 'path': f"/tmp/frame_{i:03d}.jpg", - 'index': i, - 'sharpnessScore': 100.0 # All identical - } - identical_frames.append(frame_dict) - - # Should still work with deterministic results - assert get_selection_count(identical_frames, 'best-n', n=5) == 5 - assert get_selection_count(identical_frames, 'batched', batch_size=3, batch_buffer=0) == 3 - # Outlier removal might select all or none depending on implementation - outlier_count = get_selection_count(identical_frames, 'outlier-removal', - outlier_sensitivity=50, outlier_window_size=15) - assert 0 <= outlier_count <= 10 - - # Helper methods - def _convert_frames_to_dict(self, frames_data): - """Convert FrameData objects to dictionary format for preview functions.""" - frames_dict = [] - for frame in frames_data: - frame_dict = { - 'id': f"frame_{frame.index:05d}.jpg", - 'path': frame.path, - 'index': frame.index, - 'sharpnessScore': frame.sharpness_score - } - frames_dict.append(frame_dict) - return frames_dict - - def _create_frames_dict_with_scores(self, scores): - """Create frame dictionaries with specific sharpness scores.""" - frames_dict = [] - for i, score in enumerate(scores): - frame_dict = { - 'id': f"frame_{i:05d}.jpg", - 'path': f"/tmp/frame_{i:05d}.jpg", - 'index': i, - 'sharpnessScore': score - } - frames_dict.append(frame_dict) - return frames_dict \ No newline at end of file +def make_frames(scores, source=None, index_offset=0): + return [ + { + "id": f"{source or 'frames'}_{position:05d}", + "path": f"/tmp/{source or 'frames'}/frame_{position:05d}.jpg", + "index": index_offset + position, + "sharpnessScore": score, + "source_video": source, + "source_index": position if source else None, + } + for position, score in enumerate(scores) + ] + + +def test_best_n_count_includes_exact_buffer_capacity(): + frames = make_frames(range(100)) + + assert get_selection_count(frames, "best-n", n=50, min_buffer=0) == 50 + assert get_selection_count(frames, "best-n", n=50, min_buffer=3) == 25 + assert get_selection_count(frames, "best-n", n=0, min_buffer=3) == 0 + + +def test_batched_count_is_one_pick_per_batch(): + frames = make_frames(range(100)) + + assert get_selection_count(frames, "batched", batch_size=5, batch_buffer=10) == 7 + assert get_selection_count(frames, "batched", batch_size=10, batch_buffer=0) == 10 + assert get_selection_count(frames, "batched", batch_size=1, batch_buffer=9) == 10 + + +@pytest.mark.parametrize( + ("method", "params", "select"), + [ + ( + "best-n", + {"n": 8, "min_buffer": 2}, + lambda frames: select_best_n_frames_core(frames, 8, 2), + ), + ( + "batched", + {"batch_size": 4, "batch_buffer": 2}, + lambda frames: select_batched_frames_core(frames, 4, 2), + ), + ( + "outlier-removal", + {"outlier_sensitivity": 75, "outlier_window_size": 7}, + lambda frames: [ + frame + for frame in select_outlier_removal_frames_core(frames, 7, 75) + if frame["selected"] + ], + ), + ], +) +def test_count_matches_canonical_execution(method, params, select): + frames = make_frames([100, 100, 10, 100, 80, 120, 100] * 5) + + assert get_selection_count(frames, method, **params) == len(select(frames)) + + +def test_detailed_preview_uses_exact_selected_scores_and_positions(): + frames = make_frames([10, 90, 20, 80, 30, 70, 40, 60, 50, 100]) + selected = select_best_n_frames_core(frames, 3, 0) + + preview = get_selection_preview(frames, "best-n", n=3, min_buffer=0) + selected_scores = [frame["sharpnessScore"] for frame in selected] + + assert preview["count"] == len(selected) + assert sum(preview["distribution"]) == len(selected) + assert preview["statistics"] == { + "min_sharpness": min(selected_scores), + "max_sharpness": max(selected_scores), + "avg_sharpness": sum(selected_scores) / len(selected_scores), + } + + +def test_distribution_tracks_duplicate_frame_metadata_by_input_position(): + duplicate = { + "id": "duplicate", + "path": "/tmp/duplicate.jpg", + "index": 0, + "sharpnessScore": 100, + } + frames = [duplicate.copy() for _ in range(20)] + + preview = get_selection_preview(frames, "best-n", n=2, min_buffer=0) + + assert sum(preview["distribution"][:5]) == 1 + assert sum(preview["distribution"][5:]) == 1 + + +def test_outlier_preview_enforces_a_meaningful_minimum_window(): + frames = make_frames([100, 100, 100, 1, 100, 100, 100]) + + small_window = get_selection_count( + frames, + "outlier-removal", + outlier_sensitivity=100, + outlier_window_size=3, + ) + large_window = get_selection_count( + frames, + "outlier-removal", + outlier_sensitivity=100, + outlier_window_size=7, + ) + + assert small_window == 6 + assert large_window == 6 + + +def test_video_source_boundaries_are_reflected_in_preview(): + frames = [ + *make_frames(range(6), source="video_a"), + *make_frames(range(6), source="video_b", index_offset=6), + ] + + assert get_selection_count( + frames, "batched", batch_size=4, batch_buffer=0 + ) == 4 + + +def test_empty_and_invalid_inputs(): + assert get_selection_count([], "best-n", n=10) == 0 + assert get_selection_preview([], "best-n", n=10)["count"] == 0 + with pytest.raises(ValueError, match="Unsupported selection method"): + get_selection_count(make_frames([100]), "invalid") + + +def test_preview_cache_includes_middle_scores(): + _preview_cache.clear() + frames = make_frames([100, 100, 1, 100, 100, 100, 100]) + first = _calculate_cached_preview( + frames, + "outlier-removal", + outlier_sensitivity=100, + outlier_window_size=7, + ) + frames[2]["sharpnessScore"] = 100 + second = _calculate_cached_preview( + frames, + "outlier-removal", + outlier_sensitivity=100, + outlier_window_size=7, + ) + + assert first["count"] == 6 + assert second["count"] == 7 + + +def test_best_n_count_remains_responsive_for_large_inputs(): + frames = make_frames([50 + index * 0.01 for index in range(10_000)]) + + start = time.perf_counter() + count = get_selection_count(frames, "best-n", n=1_000, min_buffer=0) + elapsed = time.perf_counter() - start + + assert count == 1_000 + assert elapsed < 0.1 diff --git a/tests/test_selection_quality.py b/tests/test_selection_quality.py new file mode 100644 index 0000000..403fed3 --- /dev/null +++ b/tests/test_selection_quality.py @@ -0,0 +1,234 @@ +"""Regression probes for the deferred selection-quality roadmap.""" + +from pathlib import Path + +import cv2 +import numpy as np + +from sharp_frames.processing.frame_extractor import FrameExtractor +from sharp_frames.processing.sharpness_analyzer import SharpnessAnalyzer +from sharp_frames.selection_methods import select_outlier_removal_frames_core +from sharp_frames.sharp_frames_processor import SharpFrames +from sharp_frames.video_utils import get_video_files_in_directory + + +def _write_image(path: Path, image: np.ndarray) -> str: + assert cv2.imwrite(str(path), image) + return str(path) + + +def test_focus_score_is_comparable_across_source_resolutions(tmp_path): + analyzer = SharpnessAnalyzer(max_workers=1) + scores = [] + + for resolution in (256, 1024): + edge = np.zeros((resolution, resolution), dtype=np.uint8) + edge[:, resolution // 2 :] = 255 + path = _write_image(tmp_path / f"edge_{resolution}.png", edge) + scores.append(analyzer._calculate_single_frame_sharpness(path)) + + assert max(scores) / min(scores) < 1.5 + + +def test_focus_score_materially_separates_fine_detail_from_mild_blur(tmp_path): + analyzer = SharpnessAnalyzer(max_workers=1) + rng = np.random.default_rng(7) + texture = rng.normal(127, 45, (1080, 1920)).clip(0, 255).astype(np.uint8) + sharp = cv2.GaussianBlur(texture, (0, 0), 0.7) + mildly_blurred = cv2.GaussianBlur(sharp, (0, 0), 2.0) + + sharp_score = analyzer._calculate_single_frame_sharpness( + _write_image(tmp_path / "fine-detail-sharp.png", sharp) + ) + blurred_score = analyzer._calculate_single_frame_sharpness( + _write_image(tmp_path / "fine-detail-blurred.png", mildly_blurred) + ) + + assert sharp_score > blurred_score * 1.4 + + +def test_focus_score_does_not_prefer_noisy_blur_over_clean_edge(tmp_path): + analyzer = SharpnessAnalyzer(max_workers=1) + sharp = np.zeros((512, 512), dtype=np.uint8) + sharp[:, 256:] = 255 + blurred = cv2.GaussianBlur(sharp, (0, 0), 8) + noise = np.random.default_rng(42).normal(0, 20, blurred.shape) + noisy_blur = np.clip(blurred.astype(float) + noise, 0, 255).astype(np.uint8) + + sharp_score = analyzer._calculate_single_frame_sharpness( + _write_image(tmp_path / "sharp.png", sharp) + ) + noisy_blur_score = analyzer._calculate_single_frame_sharpness( + _write_image(tmp_path / "noisy_blur.png", noisy_blur) + ) + + assert sharp_score > noisy_blur_score + + +def test_modern_and_direct_cli_use_the_same_focus_score(tmp_path): + image = np.zeros((360, 640), dtype=np.uint8) + image[:, 320:] = 255 + path = _write_image(tmp_path / "edge.png", image) + + modern_score = SharpnessAnalyzer(max_workers=1)._calculate_single_frame_sharpness( + path + ) + direct_cli_score = SharpFrames._process_image(path) + + assert direct_cli_score == modern_score + + +def test_image_and_video_directories_use_natural_casefolded_order(tmp_path): + image_dir = tmp_path / "images" + video_dir = tmp_path / "videos" + image_dir.mkdir() + video_dir.mkdir() + + for name in ("frame10.png", "Frame2.png", "frame1.png"): + (image_dir / name).touch() + for name in ("clip10.mp4", "Clip2.mp4", "clip1.mp4"): + (video_dir / name).touch() + + image_names = [ + Path(path).name for path in FrameExtractor()._filter_image_files(str(image_dir)) + ] + video_names = [ + Path(path).name for path in get_video_files_in_directory(str(video_dir)) + ] + + assert image_names == ["frame1.png", "Frame2.png", "frame10.png"] + assert video_names == ["clip1.mp4", "Clip2.mp4", "clip10.mp4"] + + +def test_robust_outliers_ignore_one_extreme_high_score(): + scores = [100.0] * 15 + scores[7] = 1.0 + scores[14] = 1_000_000_000.0 + frames = [ + {"index": index, "sharpnessScore": score} + for index, score in enumerate(scores) + ] + + assessed = select_outlier_removal_frames_core( + frames, + window_size=15, + sensitivity=50, + min_neighbors=3, + threshold_divisor=4.0, + ) + + assert assessed[7]["selected"] is False + assert assessed[14]["selected"] is True + + +def test_labeled_blur_probe_tracks_rejection_precision_and_recall(): + blurry_positions = {7, 15, 23} + scores = [100.0] * 31 + for position in blurry_positions: + scores[position] = 10.0 + scores[-1] = 1_000_000_000.0 + frames = [ + {"index": index, "sharpnessScore": score} + for index, score in enumerate(scores) + ] + + assessed = select_outlier_removal_frames_core( + frames, + window_size=7, + sensitivity=50, + min_neighbors=3, + threshold_divisor=4.0, + ) + rejected = { + position + for position, frame in enumerate(assessed) + if not frame["selected"] + } + true_rejections = rejected & blurry_positions + precision = len(true_rejections) / len(rejected) + recall = len(true_rejections) / len(blurry_positions) + + assert precision == 1.0 + assert recall == 1.0 + + +def test_default_outlier_detection_requires_a_meaningful_relative_drop(): + scores = [100.0] * 15 + scores[5] = 98.0 + scores[9] = 75.0 + frames = [ + {"index": index, "sharpnessScore": score} + for index, score in enumerate(scores) + ] + + assessed = select_outlier_removal_frames_core( + frames, + window_size=15, + sensitivity=60, + ) + + assert assessed[5]["selected"] is True + assert assessed[9]["selected"] is False + + +def test_log_scaled_outlier_detection_is_consistent_across_score_magnitudes(): + def rejected_dip(baseline, dip): + scores = [baseline] * 15 + scores[7] = dip + frames = [ + {"index": index, "sharpnessScore": score} + for index, score in enumerate(scores) + ] + return not select_outlier_removal_frames_core( + frames, + window_size=15, + sensitivity=60, + )[7]["selected"] + + assert rejected_dip(10.0, 7.0) is True + assert rejected_dip(1_000.0, 700.0) is True + + +def test_smooth_trough_is_not_mistaken_for_an_outlier_cluster(): + scores = [ + 162.32, 162.21, 161.43, 159.67, 160.00, 159.41, 159.17, + 157.90, 158.01, 160.00, 160.01, 160.47, 160.46, 160.53, + 160.82, 160.60, 160.10, 159.10, 158.01, 157.06, 155.20, + 152.87, 149.68, 148.97, 150.22, 153.09, 156.55, 158.64, + 159.78, 160.12, 160.68, 161.82, 162.06, 162.84, 162.69, + 163.60, 165.84, 165.63, 164.79, 163.05, + ] + frames = [ + {"index": index, "sharpnessScore": score} + for index, score in enumerate(scores) + ] + + assessed = select_outlier_removal_frames_core( + frames, + window_size=31, + sensitivity=100, + ) + + assert all(assessed[position]["selected"] for position in (22, 23, 24)) + + +def test_abrupt_dip_in_a_broad_trend_is_detected_at_maximum_sensitivity(): + scores = [ + 191.30, 190.19, 191.34, 189.59, 188.12, 185.22, 180.47, + 177.02, 173.26, 170.84, 168.32, 165.99, 163.30, 162.04, + 165.47, 166.67, 169.98, 173.32, 172.83, 169.09, 169.62, + 169.61, 168.71, 167.32, 164.46, 162.95, 162.07, 160.60, + 158.71, 157.39, 155.58, + ] + frames = [ + {"index": index, "sharpnessScore": score} + for index, score in enumerate(scores) + ] + + assessed = select_outlier_removal_frames_core( + frames, + window_size=31, + sensitivity=100, + ) + + assert assessed[13]["selected"] is False diff --git a/tests/test_selection_regressions.py b/tests/test_selection_regressions.py new file mode 100644 index 0000000..54b7f9a --- /dev/null +++ b/tests/test_selection_regressions.py @@ -0,0 +1,214 @@ +"""Regression tests for the canonical frame-selection contract.""" + +from collections import Counter +from typing import Optional + +from sharp_frames.models.frame_data import FrameData +from sharp_frames.processing.frame_selector import FrameSelector +from sharp_frames.selection_methods import ( + select_batched_frames, + select_best_n_frames, + select_outlier_removal_frames, + select_outlier_removal_frames_core, +) +from sharp_frames.selection_preview import get_selection_count +from sharp_frames.sharp_frames_processor import SharpFrames + + +def _frame( + index: int, + score: float, + source: Optional[str] = None, + source_index: Optional[int] = None, +) -> FrameData: + return FrameData( + path=f"/tmp/{source or 'frames'}/frame_{index:05d}.jpg", + index=index, + sharpness_score=score, + source_video=source, + source_index=index if source_index is None else source_index, + ) + + +def _as_dict(frame: FrameData) -> dict: + return { + "id": f"frame_{frame.index:05d}", + "path": frame.path, + "index": frame.index, + "sharpnessScore": frame.sharpness_score, + "source_video": frame.source_video, + "source_index": frame.source_index, + } + + +def _selected_dicts(frames: list[dict]) -> list[dict]: + return [frame for frame in frames if frame.get("selected", True)] + + +def test_best_n_balances_normalized_sharpness_and_timeline_in_both_apis(): + frames = [_frame(index, 10_000 - index * 100) for index in range(20)] + frame_dicts = [_as_dict(frame) for frame in frames] + + selected_objects = FrameSelector(show_progress=False).select_frames( + frames, "best_n", n=5, min_buffer=0 + ) + selected_dicts = select_best_n_frames(frame_dicts, 5, 0, 0.7, 0.3) + + object_indices = [frame.index for frame in selected_objects] + assert object_indices == [frame["index"] for frame in selected_dicts] + assert all(any(start <= index < start + 4 for index in object_indices) for start in range(0, 20, 4)) + + +def test_best_n_weights_control_normalized_sharpness_distribution_tradeoff(): + frames = [_frame(index, score) for index, score in enumerate([100, 1, 99, 1, 1])] + frame_dicts = [_as_dict(frame) for frame in frames] + + sharpness_only = select_best_n_frames(frame_dicts, 1, 0, 1.0, 0.0) + distribution_only = select_best_n_frames(frame_dicts, 1, 0, 0.0, 1.0) + + assert sharpness_only[0]["index"] == 0 + assert distribution_only[0]["index"] == 2 + + +def test_min_buffer_means_intervening_frames_and_previews_are_exact(): + frames = [_frame(index, 100 - index) for index in range(7)] + frame_dicts = [_as_dict(frame) for frame in frames] + selector = FrameSelector(show_progress=False) + + selected = selector.select_frames(frames, "best_n", n=3, min_buffer=3) + + assert len(selected) == 2 + assert all(right.index - left.index >= 4 for left, right in zip(selected, selected[1:])) + assert selector.preview_selection(frames, "best_n", n=3, min_buffer=3) == len(selected) + assert get_selection_count(frame_dicts, "best-n", n=3, min_buffer=3) == len(selected) + + +def test_outlier_endpoints_and_preview_match_across_apis(): + scores = [100, 100, 100, 1, 100, 100, 100] + frames = [_frame(index, score) for index, score in enumerate(scores)] + frame_dicts = [_as_dict(frame) for frame in frames] + selector = FrameSelector(show_progress=False) + + at_zero = selector.select_frames( + frames, "outlier_removal", outlier_sensitivity=0, outlier_window_size=7 + ) + at_hundred = selector.select_frames( + frames, "outlier_removal", outlier_sensitivity=100, outlier_window_size=7 + ) + legacy_at_hundred = _selected_dicts( + select_outlier_removal_frames(frame_dicts, 7, 100, 3, 4) + ) + + assert len(at_zero) == len(frames) + assert [frame.index for frame in at_hundred] == [0, 1, 2, 4, 5, 6] + assert [frame.index for frame in at_hundred] == [frame["index"] for frame in legacy_at_hundred] + assert selector.preview_selection( + frames, "outlier_removal", outlier_sensitivity=100, outlier_window_size=7 + ) == len(at_hundred) + assert get_selection_count( + frame_dicts, "outlier-removal", outlier_sensitivity=100, outlier_window_size=7 + ) == len(at_hundred) + + +def test_maximum_outlier_sensitivity_keeps_identical_frames(): + frames = [_frame(index, 100) for index in range(20)] + + selected = FrameSelector(show_progress=False).select_frames( + frames, "outlier_removal", outlier_sensitivity=100, outlier_window_size=7 + ) + + assert len(selected) == len(frames) + + +def test_outlier_window_without_neighbors_never_divides_by_zero(): + frame_dicts = [ + _as_dict(_frame(0, 1.0)), + _as_dict(_frame(1, 100.0)), + ] + + assessed = select_outlier_removal_frames_core( + frame_dicts, + window_size=1, + sensitivity=100, + min_neighbors=0, + threshold_divisor=4.0, + ) + + assert all(frame["selected"] for frame in assessed) + + +def test_best_n_allocates_fairly_across_video_sources(): + frames = [ + *[_frame(index, 1_000 - index, "video_a", index) for index in range(10)], + *[_frame(index + 10, 10 - index, "video_b", index) for index in range(10)], + ] + frame_dicts = [_as_dict(frame) for frame in frames] + + selected = FrameSelector(show_progress=False).select_frames( + frames, "best_n", n=4, min_buffer=0 + ) + selected_dicts = select_best_n_frames(frame_dicts, 4, 0, 0.7, 0.3) + + assert Counter(frame.source_video for frame in selected) == {"video_a": 2, "video_b": 2} + assert [frame.index for frame in selected] == [frame["index"] for frame in selected_dicts] + + +def test_batched_selection_restarts_at_each_video_boundary(): + frames = [ + *[_frame(index, index, "video_a", index) for index in range(6)], + *[_frame(index + 6, index, "video_b", index) for index in range(6)], + ] + frame_dicts = [_as_dict(frame) for frame in frames] + + selected = FrameSelector(show_progress=False).select_frames( + frames, "batched", batch_size=4, batch_buffer=0 + ) + selected_dicts = select_batched_frames(frame_dicts, 4, 0) + + assert Counter(frame.source_video for frame in selected) == {"video_a": 2, "video_b": 2} + assert [frame.index for frame in selected] == [frame["index"] for frame in selected_dicts] + assert get_selection_count(frame_dicts, "batched", batch_size=4, batch_buffer=0) == 4 + + +def test_outlier_windows_do_not_cross_video_boundaries(): + frames = [ + *[_frame(index, score, "video_a", index) for index, score in enumerate([100, 100, 1])], + *[_frame(index + 3, score, "video_b", index) for index, score in enumerate([100, 100, 100])], + ] + frame_dicts = [_as_dict(frame) for frame in frames] + + selected = FrameSelector(show_progress=False).select_frames( + frames, "outlier_removal", outlier_sensitivity=100, outlier_window_size=7 + ) + selected_dicts = _selected_dicts( + select_outlier_removal_frames(frame_dicts, 7, 100, 3, 4) + ) + + assert len(selected) == 6 + assert [frame.index for frame in selected] == [frame["index"] for frame in selected_dicts] + assert get_selection_count( + frame_dicts, "outlier-removal", outlier_sensitivity=100, outlier_window_size=7 + ) == 6 + + +def test_legacy_video_directory_analysis_records_source_boundaries(tmp_path): + processor = SharpFrames( + input_path=str(tmp_path), + input_type="video_directory", + output_dir=str(tmp_path / "output"), + ) + processor._process_image = lambda path: 100.0 + paths = [ + str(tmp_path / "video_001" / "frame_00001.jpg"), + str(tmp_path / "video_001" / "frame_00002.jpg"), + str(tmp_path / "video_002" / "frame_00001.jpg"), + ] + + frames = processor._calculate_sharpness(paths) + + assert [frame["source_video"] for frame in frames] == [ + "video_001", + "video_001", + "video_002", + ] + assert [frame["source_index"] for frame in frames] == [0, 1, 0] diff --git a/tests/test_sharp_frames.py b/tests/test_sharp_frames.py index 5c57597..a2df8fa 100644 --- a/tests/test_sharp_frames.py +++ b/tests/test_sharp_frames.py @@ -7,15 +7,150 @@ import pytest import os import tempfile +import json from unittest.mock import patch, Mock +import cv2 +import numpy as np + +from sharp_frames.sharp_frames_processor import SharpFrames from sharp_frames.video_utils import ( - get_video_files_in_directory, + SUPPORTED_VIDEO_EXTENSIONS, detect_input_type, - SUPPORTED_VIDEO_EXTENSIONS + get_video_files_in_directory, + has_mpeg_ts_signature, ) +class TestDirectCliSaving: + """Direct CLI output must use the same safe encoding contract as the TUI.""" + + @staticmethod + def _write_image(path, color): + image = np.full((12, 16, 3), color, dtype=np.uint8) + assert cv2.imwrite(str(path), image) + + @staticmethod + def _write_rgba_image(path): + image = np.zeros((12, 16, 4), dtype=np.uint8) + image[..., :3] = (10, 40, 200) + image[..., 3] = np.linspace(0, 255, 16, dtype=np.uint8) + assert cv2.imwrite(str(path), image) + + def test_directory_images_are_transcoded_and_collision_safe(self, tmp_path): + input_dir = tmp_path / "input" + output_dir = tmp_path / "output" + input_dir.mkdir() + output_dir.mkdir() + png_source = input_dir / "photo.png" + jpg_source = input_dir / "photo.jpg" + self._write_image(png_source, (255, 0, 0)) + self._write_image(jpg_source, (0, 0, 255)) + processor = SharpFrames( + input_path=str(input_dir), + input_type="directory", + output_dir=str(output_dir), + output_format="jpg", + force_overwrite=True, + ) + selected = [ + {"id": path.name, "path": str(path), "index": index, + "sharpnessScore": 10.0 + index} + for index, path in enumerate((png_source, jpg_source)) + ] + + assert processor._save_frames(selected) is True + + assert (output_dir / "photo.jpg").read_bytes().startswith(b"\xff\xd8\xff") + assert (output_dir / "photo_2.jpg").read_bytes().startswith(b"\xff\xd8\xff") + metadata = json.loads((output_dir / "selected_metadata.json").read_text()) + assert metadata["total_selected"] == 2 + assert metadata["total_saved"] == 2 + assert metadata["total_failed"] == 0 + assert [item["output_filename"] for item in metadata["selected_items"]] == [ + "photo.jpg", "photo_2.jpg" + ] + + def test_direct_cli_png_output_preserves_alpha(self, tmp_path): + input_dir = tmp_path / "input" + output_dir = tmp_path / "output" + input_dir.mkdir() + output_dir.mkdir() + source = input_dir / "photo.webp" + self._write_rgba_image(source) + processor = SharpFrames( + input_path=str(input_dir), + input_type="directory", + output_dir=str(output_dir), + output_format="png", + force_overwrite=True, + ) + selected = [{ + "id": source.name, + "path": str(source), + "index": 0, + "sharpnessScore": 10.0, + }] + + assert processor._save_frames(selected) is True + + output = cv2.imread( + str(output_dir / "photo.png"), cv2.IMREAD_UNCHANGED + ) + assert output is not None + assert output.shape[2] == 4 + assert output[..., 3].min() == 0 + assert output[..., 3].max() == 255 + + def test_save_failure_is_reported_to_run(self, tmp_path): + processor = SharpFrames( + input_path=str(tmp_path), + input_type="directory", + output_dir=str(tmp_path / "output"), + ) + selected = [{ + "id": "missing.png", + "path": str(tmp_path / "missing.png"), + "index": 0, + "sharpnessScore": 1.0, + }] + + with ( + patch.object(processor, "_setup", return_value=True), + patch.object(processor, "_load_input_frames", return_value=(["input"], False)), + patch.object(processor, "_analyze_and_select_frames", return_value=selected), + patch.object(processor, "_save_frames", return_value=False), + ): + assert processor.run() is False + + def test_direct_cli_uses_canonical_ffmpeg_runner(self, tmp_path): + processor = SharpFrames( + input_path=str(tmp_path / "input.mp4"), + input_type="video", + output_dir=str(tmp_path / "output"), + fps=5, + output_format="png", + width=640, + ) + processor.temp_dir = str(tmp_path / "frames") + + with patch( + "sharp_frames.processing.frame_extractor.FrameExtractor._run_ffmpeg_extraction", + return_value=True, + ) as extraction: + assert processor._extract_frames(duration=2.0) is True + + extraction.assert_called_once_with( + processor.input_path, + processor.temp_dir, + 5, + "png", + 640, + 2.0, + None, + ) + + class TestVideoDirectoryUtilities: """Test cases for video directory utility functions.""" @@ -46,15 +181,40 @@ def test_get_video_files_in_directory_no_videos(self): with tempfile.TemporaryDirectory() as temp_dir: # Create some non-video files non_video_files = ['image.jpg', 'text.txt', 'document.pdf'] - + for filename in non_video_files: filepath = os.path.join(temp_dir, filename) with open(filepath, 'w') as f: f.write('test content') - + result = get_video_files_in_directory(temp_dir) assert result == [] + def test_typescript_file_does_not_override_image_directory_detection( + self, tmp_path + ): + (tmp_path / "photo.png").write_bytes(b"image") + (tmp_path / "helpers.ts").write_text( + "export const answer = 42;", encoding="utf-8" + ) + + assert get_video_files_in_directory(str(tmp_path)) == [] + assert detect_input_type(str(tmp_path)) == "directory" + + @pytest.mark.parametrize("packet_size", [188, 192, 204]) + def test_mpeg_transport_stream_signature_is_detected( + self, tmp_path, packet_size + ): + sync_offset = 4 if packet_size == 192 else 0 + stream = bytearray(sync_offset + packet_size * 4) + for packet_index in range(4): + stream[sync_offset + packet_index * packet_size] = 0x47 + path = tmp_path / "video.ts" + path.write_bytes(stream) + + assert has_mpeg_ts_signature(str(path)) is True + assert get_video_files_in_directory(str(tmp_path)) == [str(path)] + def test_get_video_files_in_directory_case_insensitive(self): """Test that video file detection is case insensitive.""" with tempfile.TemporaryDirectory() as temp_dir: @@ -76,7 +236,11 @@ def test_get_video_files_in_directory_nonexistent(self): def test_supported_video_extensions_comprehensive(self): """Test that all expected video extensions are supported.""" - expected_extensions = {'.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv', '.webm'} + expected_extensions = frozenset({ + '.3g2', '.3gp', '.avi', '.flv', '.m2ts', '.m4v', '.mkv', '.mov', + '.mp4', '.mpeg', '.mpg', '.mts', '.ogv', '.ts', '.vob', '.webm', + '.wmv', + }) assert SUPPORTED_VIDEO_EXTENSIONS == expected_extensions @@ -97,6 +261,14 @@ def test_detect_input_type_single_file(self): except (OSError, PermissionError): pass # Ignore cleanup errors on Windows + def test_detect_input_type_rejects_non_video_ts_file(self, tmp_path): + """A TypeScript .ts file passed directly must not be treated as video.""" + source = tmp_path / "helpers.ts" + source.write_text("export const answer = 42;", encoding="utf-8") + + with pytest.raises(ValueError, match="not a supported video"): + detect_input_type(str(source)) + def test_detect_input_type_video_directory(self): """Test input type detection for directory containing videos.""" with tempfile.TemporaryDirectory() as temp_dir: @@ -173,4 +345,4 @@ def test_video_file_extensions_comprehensive_coverage(self): common_video_extensions = ['.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm'] for ext in common_video_extensions: - assert ext in SUPPORTED_VIDEO_EXTENSIONS, f"Extension {ext} should be supported" \ No newline at end of file + assert ext in SUPPORTED_VIDEO_EXTENSIONS, f"Extension {ext} should be supported" diff --git a/tests/test_sharpness_analyzer.py b/tests/test_sharpness_analyzer.py index bf241c6..42fc7a1 100644 --- a/tests/test_sharpness_analyzer.py +++ b/tests/test_sharpness_analyzer.py @@ -9,7 +9,10 @@ from PIL import Image import cv2 -from sharp_frames.processing.sharpness_analyzer import SharpnessAnalyzer +from sharp_frames.processing.sharpness_analyzer import ( + ImageProcessingError, + SharpnessAnalyzer, +) from sharp_frames.models.frame_data import ExtractionResult, FrameData from tests.fixtures import ( test_images_directory, @@ -84,7 +87,15 @@ def test_calculate_sharpness_extraction_result(self, test_images_directory): assert frame.sharpness_score == expected_scores[i] # Verify metadata and other properties preserved - assert result.metadata == extraction_result.metadata + assert result.metadata["fps"] == extraction_result.metadata["fps"] + assert result.metadata["sharpness_analysis"] == { + "method": "normalized_laplacian_tenengrad_v1", + "analysis_long_edge": 512, + "input_count": 5, + "analyzed_count": 5, + "unreadable_count": 0, + "unreadable_paths": [], + } assert result.input_type == extraction_result.input_type assert result.temp_dir == extraction_result.temp_dir @@ -109,6 +120,50 @@ def test_calculate_sharpness_preserves_video_attribution(self, sample_video_dire assert frame.source_index is not None assert frame.output_name.startswith('video') assert frame.sharpness_score == expected_scores[i] + + def test_calculate_sharpness_excludes_unreadable_frames(self): + frames = [ + FrameData(f"/tmp/frame_{index}.png", index, 0.0) + for index in range(3) + ] + extraction_result = ExtractionResult( + frames=frames, + metadata={"source_type": "directory"}, + input_type="directory", + ) + + with patch.object( + self.analyzer, + "_calculate_sharpness_parallel", + return_value=[100.0, None, 50.0], + ): + result = self.analyzer.calculate_sharpness(extraction_result) + + assert [frame.index for frame in result.frames] == [0, 2] + assert result.metadata["sharpness_analysis"] == { + "method": "normalized_laplacian_tenengrad_v1", + "analysis_long_edge": 512, + "input_count": 3, + "analyzed_count": 2, + "unreadable_count": 1, + "unreadable_paths": ["/tmp/frame_1.png"], + } + assert "sharpness_analysis" not in extraction_result.metadata + + def test_calculate_sharpness_rejects_an_entirely_unreadable_input(self): + extraction_result = ExtractionResult( + frames=[FrameData("/tmp/broken.png", 0, 0.0)], + metadata={}, + input_type="directory", + ) + + with patch.object( + self.analyzer, + "_calculate_sharpness_parallel", + return_value=[None], + ): + with pytest.raises(ImageProcessingError, match="No readable images"): + self.analyzer.calculate_sharpness(extraction_result) def test_calculate_single_frame_sharpness_actual_calculation(self, test_images_directory): """Test actual sharpness calculation using OpenCV Laplacian.""" @@ -124,7 +179,7 @@ def test_calculate_single_frame_sharpness_invalid_image(self): """Test error handling for invalid image files.""" invalid_path = "/nonexistent/image.jpg" - with pytest.raises(FileNotFoundError): + with pytest.raises(ImageProcessingError, match="Failed to read image"): self.analyzer._calculate_single_frame_sharpness(invalid_path) def test_calculate_single_frame_sharpness_corrupted_image(self, tmp_path): @@ -167,9 +222,9 @@ def test_calculate_sharpness_parallel_with_errors(self, test_images_directory, t assert len(scores) == 5 # First 3 should be valid scores assert all(isinstance(score, float) and score >= 0.0 for score in scores[:3]) - # Last 2 should be 0.0 (error fallback) - assert scores[3] == 0.0 - assert scores[4] == 0.0 + # Failed reads are explicit so they can be excluded from selection. + assert scores[3] is None + assert scores[4] is None def test_laplacian_variance_calculation(self, test_images_directory): """Test the core Laplacian variance sharpness algorithm.""" @@ -235,7 +290,9 @@ def test_progress_tracking(self, test_images_directory): progress_updates = [] - def mock_progress_callback(current, total): + def mock_progress_callback(phase, current, total, description): + assert phase == "sharpness" + assert description progress_updates.append((current, total)) with patch.object(self.analyzer, '_progress_callback', side_effect=mock_progress_callback): @@ -274,8 +331,8 @@ def mock_single_calculation(path): with patch.object(self.analyzer, '_calculate_single_frame_sharpness', side_effect=mock_single_calculation): scores = self.analyzer._calculate_sharpness_parallel(frame_paths) - # Should return scores for all frames, with 0.0 for failed ones + # Should return scores for all frames, with failures marked explicitly. assert len(scores) == 3 assert scores[0] == 100.0 # Valid - assert scores[1] == 0.0 # Failed (fallback) - assert scores[2] == 100.0 # Valid \ No newline at end of file + assert scores[1] is None + assert scores[2] == 100.0 # Valid diff --git a/tests/test_tui_processor.py b/tests/test_tui_processor.py index 8fca2a0..8a66f3b 100644 --- a/tests/test_tui_processor.py +++ b/tests/test_tui_processor.py @@ -63,8 +63,8 @@ def test_extract_and_analyze_video(self, sample_config_video): result = self.processor.extract_and_analyze(config) - mock_extract.assert_called_once_with(config) - mock_analyze.assert_called_once_with(mock_extraction_result) + mock_extract.assert_called_once_with(config, None) + mock_analyze.assert_called_once_with(mock_extraction_result, None) assert result == analyzed_result assert self.processor.current_result == analyzed_result @@ -214,7 +214,12 @@ def test_complete_selection_success(self, sample_frames_data, sample_config_vide result = self.processor.complete_selection('best_n', config, n=10) mock_select.assert_called_once_with(sample_frames_data, 'best_n', n=10) - mock_save.assert_called_once_with(selected_frames, config) + expected_config = { + **config, + 'selection_method': 'best_n', + 'n': 10, + } + mock_save.assert_called_once_with(selected_frames, expected_config) assert result is True def test_complete_selection_no_extraction_result(self, sample_config_video): @@ -270,7 +275,9 @@ def test_cleanup_temp_directory(self, sample_config_video): with patch('shutil.rmtree') as mock_rmtree: self.processor.cleanup_temp_directory() - mock_rmtree.assert_called_once_with('/tmp/sharp_frames_test') + mock_rmtree.assert_called_once_with( + '/tmp/sharp_frames_test', ignore_errors=True + ) # Current result should still exist but temp_dir should be cleared assert self.processor.current_result is not None assert self.processor.current_result.temp_dir is None @@ -351,7 +358,6 @@ def test_error_handling_in_extraction(self, sample_config_video): with pytest.raises(Exception, match="Extraction failed"): self.processor.extract_and_analyze(config) - # Should not have set current_result on failure assert self.processor.current_result is None def test_error_handling_in_analysis(self, sample_config_video): @@ -370,7 +376,7 @@ def test_error_handling_in_analysis(self, sample_config_video): with pytest.raises(Exception, match="Analysis failed"): self.processor.extract_and_analyze(config) - # Should not have set current_result on failure + # Failed analysis cannot leave a result eligible for selection. assert self.processor.current_result is None def test_component_integration(self, sample_config_video): @@ -406,13 +412,18 @@ def test_component_integration(self, sample_config_video): save_result = self.processor.complete_selection('best_n', config, n=2) # Verify the chain of calls - mock_extract.assert_called_once_with(config) - mock_analyze.assert_called_once_with(extraction_result) + mock_extract.assert_called_once_with(config, None) + mock_analyze.assert_called_once_with(extraction_result, None) mock_preview.assert_called_once_with(mock_frames, 'best_n', n=2) mock_select.assert_called_once_with(mock_frames, 'best_n', n=2) - mock_save.assert_called_once_with(mock_frames[:2], config) + expected_config = { + **config, + 'selection_method': 'best_n', + 'n': 2, + } + mock_save.assert_called_once_with(mock_frames[:2], expected_config) # Verify results assert analyzed_result == extraction_result assert preview_count == 2 - assert save_result is True \ No newline at end of file + assert save_result is True diff --git a/tests/ui/screens/test_configuration.py b/tests/ui/screens/test_configuration.py index ec91f99..15de204 100644 --- a/tests/ui/screens/test_configuration.py +++ b/tests/ui/screens/test_configuration.py @@ -1,381 +1,156 @@ -""" -Tests for configuration screen business logic. +"""Tests for the active two-phase configuration wizard.""" -Focuses on testing the pure logic functions rather than UI components. -Tests step visibility logic, validation, and configuration building. -""" +from unittest.mock import Mock, PropertyMock, patch import pytest -from unittest.mock import Mock, patch from sharp_frames.ui.screens.configuration import ConfigurationForm -from sharp_frames.ui.components.step_handlers import ConfirmStepHandler -from sharp_frames.ui.constants import InputTypes - - -class TestConfigurationFormLogic: - """Test cases for configuration form business logic.""" - - def test_should_show_step_fps_for_video(self): - """Test that FPS step is shown for video input.""" - form = ConfigurationForm() - form.config_data = {'input_type': 'video'} - - assert form._should_show_step('fps') is True - assert form._should_show_step('input_type') is True # Always shown - assert form._should_show_step('output_dir') is True # Always shown - - def test_should_show_step_fps_for_video_directory(self): - """Test that FPS step is shown for video directory input.""" - form = ConfigurationForm() - form.config_data = {'input_type': 'video_directory'} - - assert form._should_show_step('fps') is True - assert form._should_show_step('input_type') is True # Always shown - assert form._should_show_step('output_dir') is True # Always shown - - def test_should_show_step_fps_for_directory(self): - """Test that FPS step is hidden for directory input.""" - form = ConfigurationForm() - form.config_data = {'input_type': 'directory'} - - assert form._should_show_step('fps') is False - assert form._should_show_step('input_type') is True # Always shown - - def test_should_show_step_method_params_with_methods(self): - """Test method params step visibility for different selection methods.""" - form = ConfigurationForm() - - # Methods that should show params - methods_with_params = ['best-n', 'batched', 'outlier-removal'] - - for method in methods_with_params: - form.config_data = {'selection_method': method} - assert form._should_show_step('method_params') is True, f"Method {method} should show params" - - # Method without params (if any exist) - form.config_data = {'selection_method': 'some-other-method'} - assert form._should_show_step('method_params') is False - - def test_should_show_step_always_visible_steps(self): - """Test that certain steps are always visible.""" - form = ConfigurationForm() - form.config_data = {} - - always_visible = ['input_type', 'input_path', 'output_dir', 'selection_method', - 'output_format', 'width', 'force_overwrite', 'confirm'] - - for step in always_visible: - assert form._should_show_step(step) is True, f"Step {step} should always be visible" - - -class TestConfigurationValidation: - """Test cases for configuration validation logic.""" - - @pytest.fixture - def mock_form(self): - """Create a mock configuration form for testing.""" - form = ConfigurationForm() - form.config_data = {} - return form - - def test_build_config_summary_video_input(self, mock_form): - """Test configuration summary building for video input.""" - mock_form.config_data = { - 'input_type': 'video', - 'input_path': '/path/to/video.mp4', - 'output_dir': '/output', - 'fps': 10, - 'selection_method': 'best-n', - 'num_frames': 300, - 'min_buffer': 3, - 'output_format': 'jpg', - 'width': 800, - 'force_overwrite': True - } - - # Use the ConfirmStepHandler to build the summary - handler = ConfirmStepHandler(mock_form.config_data) - summary = handler._build_config_summary() - - # Check that key information is present - assert 'video.mp4' in summary - assert '/output' in summary - assert 'FPS: 10' in summary # Actual format from implementation - assert 'best-n' in summary - assert 'JPG' in summary # Output format is uppercase - assert '800px' in summary # Width includes 'px' - assert 'Yes' in summary # Force overwrite shows as "Yes" - - def test_build_config_summary_video_directory_input(self, mock_form): - """Test configuration summary building for video directory input.""" - mock_form.config_data = { - 'input_type': 'video_directory', - 'input_path': '/path/to/videos', - 'output_dir': '/output', - 'fps': 15, - 'selection_method': 'best-n', - 'num_frames': 300, - 'min_buffer': 3, - 'output_format': 'jpg', - 'width': 800, - 'force_overwrite': True - } - - # Use the ConfirmStepHandler to build the summary - handler = ConfirmStepHandler(mock_form.config_data) - summary = handler._build_config_summary() - - # Check that key information is present - assert 'videos' in summary - assert '/output' in summary - assert 'FPS (per video): 15' in summary # Should show fps with per video label - assert 'best-n' in summary - assert 'JPG' in summary - assert '800px' in summary - assert 'Yes' in summary - - def test_build_config_summary_directory_input(self, mock_form): - """Test configuration summary building for directory input.""" - mock_form.config_data = { - 'input_type': 'directory', - 'input_path': '/path/to/images', - 'output_dir': '/output', - 'selection_method': 'batched', - 'batch_size': 10, - 'batch_buffer': 2, - 'output_format': 'png', # This will be ignored for directory input - 'width': 1024, # This will be ignored for directory input - 'force_overwrite': False - } - - # Use the ConfirmStepHandler to build the summary - handler = ConfirmStepHandler(mock_form.config_data) - summary = handler._build_config_summary() - - # Check that key information is present - assert 'images' in summary - assert 'batched' in summary - # For directory input, format and dimensions should be preserved - assert 'Preserve original formats' in summary - assert 'Preserve original dimensions' in summary - # FPS should not be mentioned for directory input - assert 'FPS:' not in summary # Actual format check - - def test_build_config_summary_outlier_removal_method(self, mock_form): - """Test summary for outlier removal method with both parameters.""" - mock_form.config_data = { - 'input_type': 'video', - 'input_path': '/video.mp4', - 'output_dir': '/output', - 'fps': 15, - 'selection_method': 'outlier-removal', - 'outlier_window_size': 15, - 'outlier_sensitivity': 50, - 'output_format': 'jpg', - 'width': 800, - 'force_overwrite': False - } - - # Use the ConfirmStepHandler to build the summary - handler = ConfirmStepHandler(mock_form.config_data) - summary = handler._build_config_summary() - - assert 'outlier-removal' in summary - assert 'Window size: 15' in summary - assert 'Sensitivity: 50' in summary - - def test_prepare_final_config_removes_ui_fields(self, mock_form): - """Test that final config copies config_data and removes None values.""" - mock_form.config_data = { - 'input_type': 'video', - 'input_path': '/video.mp4', - 'output_dir': '/output', - 'fps': 10, - 'selection_method': 'best-n', - 'num_frames': 300, - 'min_buffer': 3, - 'output_format': 'jpg', - 'width': 800, - 'force_overwrite': True - } - - final_config = mock_form._prepare_final_config() - - # Check that all non-None values are copied - assert final_config['input_type'] == 'video' - assert final_config['input_path'] == '/video.mp4' - assert final_config['output_dir'] == '/output' - assert final_config['fps'] == 10 - assert final_config['selection_method'] == 'best-n' - assert final_config['output_format'] == 'jpg' - assert final_config['width'] == 800 - assert final_config['force_overwrite'] is True - - # Only method parameters that were set should be present - assert 'num_frames' in final_config - assert final_config['num_frames'] == 300 - assert 'min_buffer' in final_config - assert final_config['min_buffer'] == 3 - - # Parameters not in config_data should not be present - assert 'batch_size' not in final_config - - def test_prepare_final_config_batched_method(self, mock_form): - """Test final config for batched method.""" - mock_form.config_data = { - 'input_type': 'directory', - 'input_path': '/images', - 'output_dir': '/output', - 'selection_method': 'batched', - 'batch_size': 10, # Set proper parameter name - 'batch_buffer': 2, # Set proper parameter name - 'output_format': 'png', - 'width': 1024, - 'force_overwrite': False - } - - final_config = mock_form._prepare_final_config() - - # Check that values are copied as-is - assert final_config['selection_method'] == 'batched' - assert final_config['input_type'] == 'directory' - assert final_config['input_path'] == '/images' - assert final_config['output_dir'] == '/output' - assert final_config['output_format'] == 'png' - assert final_config['width'] == 1024 - assert final_config['force_overwrite'] is False - - # Only parameters that were actually set should be present - assert 'batch_size' in final_config - assert final_config['batch_size'] == 10 - assert 'batch_buffer' in final_config - assert final_config['batch_buffer'] == 2 - - def test_prepare_final_config_video_directory_method(self, mock_form): - """Test final config for video directory input.""" - mock_form.config_data = { - 'input_type': 'video_directory', - 'input_path': '/path/to/videos', - 'output_dir': '/output', - 'fps': 15, - 'selection_method': 'best-n', - 'param1': 20, # num_frames - 'output_format': 'jpg', - 'width': 800, - 'force_overwrite': True - } - - final_config = mock_form._prepare_final_config() - - assert final_config['input_type'] == 'video_directory' - assert final_config['input_path'] == '/path/to/videos' - assert final_config['fps'] == 15 # Should have fps for video directory - assert final_config['selection_method'] == 'best-n' - - def test_prepare_final_config_outlier_removal_method(self, mock_form): - """Test final config for outlier removal method.""" - mock_form.config_data = { - 'input_type': 'video', - 'input_path': '/video.mp4', - 'output_dir': '/output', - 'fps': 5, - 'selection_method': 'outlier-removal', - 'outlier_window_size': 15, # Set proper parameter name - 'outlier_sensitivity': 50, # Set proper parameter name - 'output_format': 'jpg', - 'width': 800, - 'force_overwrite': True - } - - final_config = mock_form._prepare_final_config() - - # Check that values are copied as-is - assert final_config['selection_method'] == 'outlier-removal' - assert final_config['input_type'] == 'video' - assert final_config['input_path'] == '/video.mp4' - assert final_config['output_dir'] == '/output' - assert final_config['fps'] == 5 - assert final_config['output_format'] == 'jpg' - assert final_config['width'] == 800 - assert final_config['force_overwrite'] is True - - # Only parameters that were actually set should be present - assert 'outlier_window_size' in final_config - assert final_config['outlier_window_size'] == 15 - assert 'outlier_sensitivity' in final_config - assert final_config['outlier_sensitivity'] == 50 - - def test_prepare_final_config_removes_none_values(self, mock_form): - """Test that None values are filtered out of final config.""" - mock_form.config_data = { - 'input_type': 'video', - 'input_path': '/video.mp4', - 'output_dir': '/output', - 'fps': None, # Should be filtered - 'selection_method': 'best-n', - 'num_frames': 300, # Non-None value - 'min_buffer': None, # Should be filtered - 'output_format': 'jpg', - 'width': None, # Should be filtered - 'force_overwrite': False - } - - final_config = mock_form._prepare_final_config() - - # None values should be filtered out - assert 'fps' not in final_config - assert 'min_buffer' not in final_config - assert 'width' not in final_config - - # Non-None values should be present - assert final_config['input_type'] == 'video' - assert final_config['input_path'] == '/video.mp4' - assert final_config['output_dir'] == '/output' - assert final_config['selection_method'] == 'best-n' - assert final_config['num_frames'] == 300 - assert final_config['output_format'] == 'jpg' - assert final_config['force_overwrite'] is False - - -class TestConfigurationStepCounting: - """Test step counting and navigation logic.""" - - def test_step_counting_video_input(self): - """Test step count calculation for video input.""" - form = ConfigurationForm() - form.config_data = {'input_type': 'video', 'selection_method': 'best-n'} - - # Count visible steps - visible_steps = [step for step in form.steps if form._should_show_step(step)] - - # Video input should include FPS step - assert 'fps' in visible_steps - assert 'method_params' in visible_steps # best-n has params - - # Should have reasonable number of steps (not too many, not too few) - assert 8 <= len(visible_steps) <= 12 - - def test_step_counting_directory_input(self): - """Test step count calculation for directory input.""" - form = ConfigurationForm() - form.config_data = {'input_type': 'directory', 'selection_method': 'best-n'} - - visible_steps = [step for step in form.steps if form._should_show_step(step)] - - # Directory input should not include FPS step - assert 'fps' not in visible_steps - assert 'method_params' in visible_steps # best-n has params - - # Should have one less step than video (no FPS) - assert 7 <= len(visible_steps) <= 11 - - def test_step_counting_method_without_params(self): - """Test step counting for selection method without parameters.""" - form = ConfigurationForm() - form.config_data = {'input_type': 'video', 'selection_method': 'no-params-method'} - - visible_steps = [step for step in form.steps if form._should_show_step(step)] - - # Should not include method_params step - assert 'method_params' not in visible_steps \ No newline at end of file +from sharp_frames.ui.screens.processing import ProcessingScreen + + +EXPECTED_STEPS = [ + "input_type", + "input_path", + "output_dir", + "fps", + "output_format", + "width", + "force_overwrite", + "confirm", +] + + +def test_configuration_uses_extraction_only_steps(): + form = ConfigurationForm() + + assert form.steps == EXPECTED_STEPS + assert list(form.step_handlers) == EXPECTED_STEPS + assert form.current_step == 0 + assert form.config_data == {} + assert "selection_method" not in form.steps + assert "method_params" not in form.steps + + +@pytest.mark.parametrize("input_type", ["video", "video_directory"]) +def test_video_inputs_show_video_specific_steps(input_type): + form = ConfigurationForm() + form.config_data = {"input_type": input_type} + + assert form._should_show_step("fps") is True + assert form._should_show_step("output_format") is True + + +def test_image_directory_skips_video_specific_steps(): + form = ConfigurationForm() + form.config_data = {"input_type": "directory"} + + assert form._should_show_step("fps") is False + assert form._should_show_step("output_format") is False + assert all( + form._should_show_step(step) + for step in EXPECTED_STEPS + if step not in {"fps", "output_format"} + ) + + +def test_save_current_step_validates_and_merges_handler_data(): + form = ConfigurationForm() + handler = form.step_handlers["input_type"] + + with ( + patch.object(form, "_clear_error"), + patch.object(handler, "validate", return_value=True) as validate, + patch.object( + handler, "get_data", return_value={"input_type": "video"} + ) as get_data, + ): + assert form._save_current_step() is True + + validate.assert_called_once_with(form) + get_data.assert_called_once_with(form) + assert form.config_data == {"input_type": "video"} + + +def test_save_current_step_does_not_read_data_after_validation_failure(): + form = ConfigurationForm() + handler = form.step_handlers["input_type"] + + with ( + patch.object(form, "_clear_error"), + patch.object(handler, "validate", return_value=False), + patch.object(handler, "get_data") as get_data, + ): + assert form._save_current_step() is False + + get_data.assert_not_called() + assert form.config_data == {} + + +def test_navigation_skips_hidden_directory_steps(): + form = ConfigurationForm() + form.config_data = {"input_type": "directory"} + form.current_step = EXPECTED_STEPS.index("output_dir") + + with ( + patch.object(form, "_save_current_step", return_value=True), + patch.object(form, "show_current_step") as show, + ): + form._next_step() + + assert form.get_current_step_name() == "width" + show.assert_called_once() + + with patch.object(form, "show_current_step") as show: + form._back_step() + + assert form.get_current_step_name() == "output_dir" + show.assert_called_once() + + +def test_reset_clears_configuration_and_renders_first_step(): + form = ConfigurationForm() + form.current_step = 5 + form.config_data = {"input_type": "video"} + + with patch.object(form, "show_current_step") as show: + form.reset_to_first_step() + + assert form.current_step == 0 + assert form.config_data == {} + show.assert_called_once() + + +def test_action_process_passes_collected_config_to_processing_screen(): + form = ConfigurationForm() + form.config_data = { + "input_type": "video", + "input_path": "/video.mp4", + "output_dir": "/output", + } + app = Mock() + + with patch.object( + ConfigurationForm, "app", new_callable=PropertyMock, return_value=app + ): + form.action_process() + + pushed = app.push_screen.call_args.args[0] + assert isinstance(pushed, ProcessingScreen) + assert pushed.config == form.config_data + + +def test_cancel_and_help_delegate_to_the_app(): + form = ConfigurationForm() + app = Mock() + + with patch.object( + ConfigurationForm, "app", new_callable=PropertyMock, return_value=app + ): + form.action_cancel() + form.action_help() + + app.exit.assert_called_once_with(result="cancelled") + help_call = app.push_screen.call_args + assert help_call.args[0] == "help" + assert "Sharp Frames Configuration Help" in help_call.args[1] + assert "Selection Process" in help_call.args[1] diff --git a/tests/ui/test_integration.py b/tests/ui/test_integration.py index d038970..ab701e4 100644 --- a/tests/ui/test_integration.py +++ b/tests/ui/test_integration.py @@ -1,354 +1,709 @@ -""" -Integration tests for Sharp Frames UI components. -Tests screen transitions and two-phase workflow. -""" +"""Integration tests for the active Textual two-phase workflow.""" + +from unittest.mock import Mock, PropertyMock, patch import pytest -from unittest.mock import patch, MagicMock -import os -import tempfile -import shutil +from textual.app import App +from textual.containers import Container +from textual.notifications import Notification +from textual.worker import WorkerState +from textual.widgets import ( + Button, + Checkbox, + Input, + RadioSet, + Select, + Static, +) +from sharp_frames.models.frame_data import ExtractionResult, FrameData from sharp_frames.ui.app import SharpFramesApp -from sharp_frames.ui.screens.configuration import ConfigurationForm -from sharp_frames.ui.screens.processing import ProcessingScreen -from sharp_frames.ui.screens.selection import SelectionScreen -from sharp_frames.models.frame_data import FrameData - - -class TestUIIntegration: - """Test UI integration and screen transitions.""" - - def setup_method(self): - """Set up test environment.""" - self.temp_dir = tempfile.mkdtemp() - self.test_input_dir = os.path.join(self.temp_dir, "input") - self.test_output_dir = os.path.join(self.temp_dir, "output") - os.makedirs(self.test_input_dir) - os.makedirs(self.test_output_dir) - - # Create mock frame data - self.mock_frames = [ - FrameData( - path=f"/tmp/frame_{i:05d}.jpg", - index=i, - sharpness_score=float(100 - i), - source_video="test_video.mp4", - source_index=i, - output_name=f"frame_{i:05d}.jpg" - ) - for i in range(10) +from sharp_frames.ui.constants import WorkerNames +from sharp_frames.ui.screens.configuration import ( + AsciiTitleShimmer, + ConfigurationForm, +) +from sharp_frames.ui.screens.processing import BlockProgressBar, ProcessingScreen +from sharp_frames.ui.screens.selection import ( + InputWithControls, + SelectionScreen, + SharpnessChart, +) +from sharp_frames.ui.styles import SHARP_FRAMES_CSS +from sharp_frames.ui.utils.error_analysis import ErrorContext + + +class ScreenHarness(App): + """Minimal app that mounts one production screen for DOM assertions.""" + + def __init__(self, target_screen): + super().__init__() + self.target_screen = target_screen + + def on_mount(self): + self.push_screen(self.target_screen) + + +class StyledScreenHarness(ScreenHarness): + """Screen harness using the production application stylesheet.""" + + CSS = SHARP_FRAMES_CSS + + +def _extraction_result(frame_count=10): + return ExtractionResult( + frames=[ + FrameData(f"/tmp/frame_{index:05d}.jpg", index, float(index + 1)) + for index in range(frame_count) + ], + metadata={"fps": 10}, + input_type="video", + temp_dir="/tmp/frames", + ) + + +def test_app_initialization_and_path_detection(): + app = SharpFramesApp() + + assert app.TITLE == "Sharp Frames - by Reflct.app" + assert app._looks_like_file_path("/absolute/video.mp4") is True + assert app._looks_like_file_path(r"C:\\video.mp4") is True + assert app._looks_like_file_path("../relative/video.mp4") is True + assert app._looks_like_file_path("ordinary text") is False + + +def test_limited_notifications_keep_only_the_newest_three_in_a_channel(): + app = SharpFramesApp() + unrelated = Notification( + "Something else happened", + title="Other notification", + timeout=60, + ) + app._notifications.add(unrelated) + + for frame_number in range(1, 6): + app.notify_limited( + f"Frame {frame_number}", + channel="frame-preview", + limit=3, + title="Inline preview", + timeout=60, + ) + + frame_notifications = [ + notification + for notification in app._notifications + if notification.title == "Inline preview" + ] + assert [item.message for item in frame_notifications] == [ + "Frame 3", + "Frame 4", + "Frame 5", + ] + assert unrelated in app._notifications + + +@pytest.mark.asyncio +async def test_configuration_screen_mounts_current_wizard_controls(): + form = ConfigurationForm() + + async with ScreenHarness(form).run_test() as pilot: + await pilot.pause(0.2) + + assert str(form.query_one("#step-info", Static).render()) + assert str(form.query_one("#next-btn", Button).label) == "Next" + assert form.query_one("#back-btn", Button).disabled is True + assert form.get_current_step_name() == "input_type" + + +def test_title_shimmer_changes_only_color_across_the_full_title(): + title_markup = next( + value + for value in ConfigurationForm.compose.__code__.co_consts + if isinstance(value, str) and "███████" in value + ) + title = AsciiTitleShimmer(title_markup) + base_frame = title.frames[0] + base_span_count = len(base_frame.spans) + + assert all(frame.plain == base_frame.plain for frame in title.frames) + assert title.frames[-1].spans == base_frame.spans + + shimmer_spans = [ + span + for frame in title.frames[1:-1] + for span in frame.spans[base_span_count:] + ] + assert shimmer_spans + assert AsciiTitleShimmer._CORE_COLOR in { + str(span.style) for span in shimmer_spans + } + + shimmer_columns = [] + for span in shimmer_spans: + line_start = base_frame.plain.rfind("\n", 0, span.start) + 1 + shimmer_columns.append(span.start - line_start) + + occupied_columns = [ + column + for row in base_frame.plain.splitlines() + for column, character in enumerate(row) + if not character.isspace() + ] + assert min(shimmer_columns) == min(occupied_columns) + assert max(shimmer_columns) == max(occupied_columns) + + +@pytest.mark.asyncio +async def test_title_shimmer_finishes_on_the_original_title(monkeypatch): + monkeypatch.setattr(AsciiTitleShimmer, "INITIAL_DELAY_SECONDS", 0.001) + monkeypatch.setattr(AsciiTitleShimmer, "FRAME_INTERVAL_SECONDS", 0.005) + + # The final frame renders the same spans as the initial one, so completion + # must be observed through the frame callbacks rather than the render + # output. Timers fire late on slow CI runners; poll with a deadline + # instead of sleeping for the nominal animation duration. + shown_frames = [] + original_show_frame = AsciiTitleShimmer._show_frame + + def recording_show_frame(self, frame_index): + shown_frames.append(frame_index) + original_show_frame(self, frame_index) + + monkeypatch.setattr(AsciiTitleShimmer, "_show_frame", recording_show_frame) + form = ConfigurationForm() + + async with ScreenHarness(form).run_test() as pilot: + title = form.query_one("#ascii-title", AsciiTitleShimmer) + last_frame_index = len(title.frames) - 1 + for _ in range(200): + if shown_frames and shown_frames[-1] == last_frame_index: + break + await pilot.pause(0.05) + assert shown_frames == list(range(1, len(title.frames))) + + rendered = title.render() + assert rendered.plain == title.frames[-1].plain + assert [ + (span.start, span.end) for span in rendered.spans + ] == [ + (span.start, span.end) for span in title.frames[-1].spans ] - - def teardown_method(self): - """Clean up test environment.""" - if os.path.exists(self.temp_dir): - shutil.rmtree(self.temp_dir) - - def test_two_phase_app_initialization(self): - """Test that SharpFramesApp initializes correctly.""" - app = SharpFramesApp() - - assert app.CSS is not None - assert app.TITLE == "Sharp Frames - by Reflct.app" - assert hasattr(app, '_last_escape_time') - assert hasattr(app, '_escape_count') - assert hasattr(app, '_last_action_time') - - def test_configuration_form_initialization(self): - """Test TwoPhaseConfigurationForm initialization.""" - form = TwoPhaseConfigurationForm() - - # Check step configuration - expected_steps = [ - "input_type", "input_path", "output_dir", "fps", - "output_format", "width", "force_overwrite", "confirm" + + +@pytest.mark.asyncio +async def test_title_shimmer_stays_static_when_animations_are_disabled(): + form = ConfigurationForm() + app = ScreenHarness(form) + app.animation_level = "none" + + async with app.run_test(): + title = form.query_one("#ascii-title", AsciiTitleShimmer) + rendered = title.render() + + assert rendered.plain == title.frames[0].plain + assert [ + (span.start, span.end) for span in rendered.spans + ] == [ + (span.start, span.end) for span in title.frames[0].spans ] - assert form.steps == expected_steps - assert form.current_step == 0 - assert form.config_data == {} - - # Check step handlers exist - for step in expected_steps: - assert step in form.step_handlers - assert hasattr(form.step_handlers[step], 'validate') - assert hasattr(form.step_handlers[step], 'get_data') - - def test_configuration_form_step_navigation(self): - """Test step navigation in configuration form.""" - form = TwoPhaseConfigurationForm() - - # Test initial state - assert form.current_step == 0 - progress = form.get_step_progress() - assert progress == (1, 8) # Step 1 of 8 - - # Mock the DOM-dependent methods - with patch.object(form, '_render_current_step'), \ - patch.object(form, '_update_navigation_buttons'), \ - patch.object(form.step_handlers['input_type'], 'validate', return_value=True), \ - patch.object(form.step_handlers['input_type'], 'get_data', return_value={'input_type': 'video'}): - - # Test moving forward - form._handle_next() - assert form.current_step == 1 - assert form.config_data['input_type'] == 'video' - - # Test moving backward - form._handle_back() - assert form.current_step == 0 - - @patch('sharp_frames.processing.tui_processor.TUIProcessor') - def test_processing_screen_initialization(self, mock_processor_class): - """Test TwoPhaseProcessingScreen initialization.""" - mock_processor = MagicMock() - mock_processor_class.return_value = mock_processor - - config_data = { - 'input_type': 'video', - 'input_path': '/test/video.mp4', - 'output_dir': self.test_output_dir, - 'fps': 10, - 'output_format': 'jpg' - } - - screen = TwoPhaseProcessingScreen(config_data) - - assert screen.config == config_data - assert screen.processor is None # Not initialized until processing starts - assert screen._phase_1_complete == False - assert screen.extraction_result is None - - @patch('sharp_frames.processing.tui_processor.TUIProcessor') - def test_selection_screen_initialization(self, mock_processor_class): - """Test SelectionScreen initialization.""" - # Create mock processor and extraction result - mock_processor = MagicMock() - mock_extraction_result = MagicMock() - mock_extraction_result.frames = self.mock_frames - - config = { - 'input_type': 'video', - 'input_path': '/test/video.mp4', - 'output_dir': self.test_output_dir, - 'output_format': 'jpg' + + +@pytest.mark.asyncio +async def test_configuration_form_caps_fields_on_wide_terminals(): + form = ConfigurationForm() + form.current_step = form.steps.index("input_path") + form.config_data = {"input_type": "video"} + + async with StyledScreenHarness(form).run_test(size=(240, 50)) as pilot: + await pilot.pause(0.2) + + form_container = form.query_one("#main-container", Container) + path_input = form.query_one("#input-path", Input) + + assert form_container.outer_size.width == 110 + assert path_input.size.width < 110 + assert form_container.region.x == ( + 240 - form_container.outer_size.width + ) // 2 + + +@pytest.mark.asyncio +async def test_configuration_form_remains_fluid_below_its_width_cap(): + form = ConfigurationForm() + form.current_step = form.steps.index("input_path") + form.config_data = {"input_type": "video"} + + async with StyledScreenHarness(form).run_test(size=(80, 40)) as pilot: + await pilot.pause(0.2) + + form_container = form.query_one("#main-container", Container) + path_input = form.query_one("#input-path", Input) + + assert form_container.outer_size.width == 80 + assert path_input.outer_size.width < 80 + + +@pytest.mark.asyncio +async def test_selection_caps_controls_but_keeps_chart_fluid(): + result = _extraction_result(frame_count=20) + processor = Mock() + processor.preview_selection.return_value = 4 + processor.selector.select_frames.return_value = result.frames[:4] + screen = SelectionScreen( + processor, + result, + {"input_type": "video", "output_dir": "/output"}, + ) + + async with StyledScreenHarness(screen).run_test(size=(240, 60)) as pilot: + await pilot.pause(0.25) + + controls = screen.query_one("#controls_section") + chart = screen.query_one("#sharpness_chart", SharpnessChart) + + assert controls.outer_size.width == 120 + assert controls.region.x == (240 - controls.outer_size.width) // 2 + assert chart.outer_size.width > controls.outer_size.width + assert chart.outer_size.height > 13 + + +@pytest.mark.asyncio +async def test_parameter_controls_have_one_row_between_groups(): + result = _extraction_result(frame_count=20) + processor = Mock() + processor.preview_selection.return_value = 4 + processor.selector.select_frames.return_value = result.frames[:4] + screen = SelectionScreen( + processor, + result, + {"input_type": "video", "output_dir": "/output"}, + ) + + async with StyledScreenHarness(screen).run_test(size=(160, 60)) as pilot: + await pilot.pause(0.25) + + title = screen.query_one("#parameter_container .control_label") + parameter_inputs = screen.query_one("#parameter_inputs", Container) + labels = list(parameter_inputs.query(".param_label")) + controls = list(parameter_inputs.query(InputWithControls)) + + assert labels[0].region.y - title.region.bottom == 1 + assert controls[0].region.y == labels[0].region.bottom + assert labels[1].region.y - controls[0].region.bottom == 1 + assert controls[1].region.y == labels[1].region.bottom + assert controls[1].styles.margin.bottom == 0 + + +@pytest.mark.asyncio +async def test_processing_status_caps_width_on_wide_terminals(): + screen = ProcessingScreen( + { + "input_type": "video", + "input_path": "/video.mp4", + "output_dir": "/output", } - - screen = SelectionScreen(mock_processor, mock_extraction_result, config) - - assert screen.processor == mock_processor - assert screen.extraction_result == mock_extraction_result - assert screen.config == config - assert screen.current_method == "best_n" - assert screen.current_parameters == {"n": 50} - - def test_selection_screen_preview_calculation(self): - """Test selection screen preview calculation setup.""" - # Create mock processor and extraction result - mock_processor = MagicMock() - mock_extraction_result = MagicMock() - mock_extraction_result.frames = self.mock_frames - - config = { - 'input_type': 'video', - 'input_path': '/test/video.mp4', - 'output_dir': self.test_output_dir, - 'output_format': 'jpg' + ) + + with patch.object(screen, "start_phase_1_processing"): + async with StyledScreenHarness(screen).run_test( + size=(240, 50) + ) as pilot: + await pilot.pause(0.2) + + status = screen.query_one("#processing-container", Container) + + assert status.outer_size.width == 84 + assert status.region.x == (240 - status.outer_size.width) // 2 + + +@pytest.mark.asyncio +async def test_processing_progress_shows_one_count_and_real_percentage(): + screen = ProcessingScreen( + { + "input_type": "video", + "input_path": "/video.mp4", + "output_dir": "/output", } - - screen = SelectionScreen(mock_processor, mock_extraction_result, config) - - # Test that screen has the necessary attributes for preview calculation - assert hasattr(screen, 'current_method') - assert hasattr(screen, 'current_parameters') - assert hasattr(screen, 'extraction_result') + ) + + with patch.object(screen, "start_phase_1_processing"): + async with StyledScreenHarness(screen).run_test( + size=(100, 40) + ) as pilot: + await pilot.pause(0.2) + screen._update_progress_ui( + "extraction", + current=650, + total=1_814, + total_progress=35.83, + description="Extracted 650/1814 frames", + ) + await pilot.pause() + + status = str(screen.query_one("#status-text", Static).render()) + count = str(screen.query_one("#phase-text", Static).render()) + detail = str(screen.query_one("#detail-text", Static).render()) + progress = screen.query_one("#progress-bar", BlockProgressBar) + + assert status == "Extracting frames · Phase 1 of 2" + assert count == "650 of 1,814 frames" + assert detail == "" + assert screen.query_one("#detail-text", Static).display is False + assert progress.total == 100 + assert progress.progress == pytest.approx(35.83) + rendered_bar = progress.render() + filled_style = progress.get_component_rich_style( + "block-progress--filled" + ) + assert filled_style.bgcolor is not None + assert len(rendered_bar.plain) == progress.size.width + assert not list(screen.query("#processing-kicker")) + + cancel = screen.query_one("#cancel-processing", Button) + cancel.focus() + await pilot.pause() + assert cancel.styles.text_style.reverse is False + + +@pytest.mark.asyncio +async def test_enter_advances_without_toggling_focused_checkbox(): + form = ConfigurationForm() + form.config_data = {"input_type": "video"} + + async with ScreenHarness(form).run_test() as pilot: + form.current_step = form.steps.index("force_overwrite") + form.show_current_step() + await pilot.pause(0.2) + + checkbox = form.query_one("#force-overwrite", Checkbox) + checkbox.focus() + assert checkbox.value is False + + await pilot.press("enter") + await pilot.pause() + + assert form.get_current_step_name() == "confirm" + assert form.config_data["force_overwrite"] is False + + +@pytest.mark.asyncio +async def test_space_toggles_focused_checkbox_without_advancing(): + form = ConfigurationForm() + form.config_data = {"input_type": "video"} + + async with ScreenHarness(form).run_test() as pilot: + form.current_step = form.steps.index("force_overwrite") + form.show_current_step() + await pilot.pause(0.2) + + checkbox = form.query_one("#force-overwrite", Checkbox) + checkbox.focus() + await pilot.press("space") + await pilot.pause() + + assert checkbox.value is True + assert form.get_current_step_name() == "force_overwrite" + + +@pytest.mark.asyncio +async def test_space_selects_focused_radio_button_without_advancing(): + form = ConfigurationForm() + + async with ScreenHarness(form).run_test() as pilot: + await pilot.pause(0.2) + + radio_set = form.query_one("#input-type-selection", RadioSet) + radio_set.focus() + await pilot.press("down", "space") + await pilot.pause() + + assert radio_set.pressed_index == 1 + assert form.get_current_step_name() == "input_type" + + +@pytest.mark.asyncio +async def test_space_selects_highlighted_dropdown_option(): + form = ConfigurationForm() + form.config_data = {"input_type": "video"} + + async with ScreenHarness(form).run_test() as pilot: + form.current_step = form.steps.index("output_format") + form.show_current_step() + await pilot.pause(0.2) + + select = form.query_one("#format-select", Select) + select.focus() + await pilot.press("space", "down", "space") + await pilot.pause() + + assert select.value == "png" + assert select.expanded is False + assert form.get_current_step_name() == "output_format" + + +@pytest.mark.asyncio +async def test_enter_advances_input_step_exactly_once(): + form = ConfigurationForm() + form.config_data = {"input_type": "video"} + handler = form.step_handlers["input_path"] + + with ( + patch.object(handler, "validate", return_value=True), + patch.object( + handler, + "get_data", + return_value={"input_path": "/tmp/video.mp4"}, + ), + ): + async with ScreenHarness(form).run_test() as pilot: + form.current_step = form.steps.index("input_path") + form.show_current_step() + await pilot.pause(0.2) + + form.query_one("#input-path").focus() + await pilot.press("enter") + await pilot.pause() + + assert form.get_current_step_name() == "output_dir" + + +@pytest.mark.asyncio +async def test_selection_enter_confirms_with_method_menu_focused(): + processor = Mock() + processor.preview_selection.return_value = 4 + processor.selector.select_frames.return_value = _extraction_result().frames[:4] + screen = SelectionScreen( + processor, + _extraction_result(), + {"input_type": "video", "output_dir": "/output"}, + ) + + with patch.object(screen, "_start_final_processing") as process: + async with ScreenHarness(screen).run_test() as pilot: + await pilot.pause(0.2) + method_select = screen.query_one("#method_select", Select) + method_select.focus() + + await pilot.press("enter") + await pilot.pause() + + process.assert_called_once_with() + assert method_select.expanded is False + + +@pytest.mark.asyncio +async def test_selection_space_chooses_highlighted_method(): + processor = Mock() + processor.preview_selection.return_value = 4 + processor.selector.select_frames.return_value = _extraction_result().frames[:4] + screen = SelectionScreen( + processor, + _extraction_result(), + {"input_type": "video", "output_dir": "/output"}, + ) + + async with ScreenHarness(screen).run_test() as pilot: + await pilot.pause(0.2) + method_select = screen.query_one("#method_select", Select) + method_select.focus() + + await pilot.press("space", "up", "space") + await pilot.pause(0.2) + + assert method_select.value == "best_n" + assert method_select.expanded is False assert screen.current_method == "best_n" - - def test_config_data_flow(self): - """Test configuration data flow through screens.""" - # Start with configuration form - form = TwoPhaseConfigurationForm() - - # Set some test data - test_config = { - 'input_type': 'video', - 'input_path': '/test/input.mp4', - 'output_dir': self.test_output_dir, - 'fps': 15, - 'output_format': 'png', - 'width': 1920, - 'force_overwrite': True - } - - for key, value in test_config.items(): - form.set_config_data(key, value) - - # Verify data is stored correctly - config_data = form.get_config_data() - for key, value in test_config.items(): - assert config_data[key] == value - - # Test that processing screen receives the config - with patch('sharp_frames.processing.tui_processor.TUIProcessor') as mock_processor_class: - mock_processor = MagicMock() - mock_processor_class.return_value = mock_processor - - processing_screen = TwoPhaseProcessingScreen(config_data) - assert processing_screen.config == config_data - - def test_file_path_detection(self): - """Test file path detection in app.""" - app = SharpFramesApp() - - # Test various path formats - assert app._looks_like_file_path("/absolute/path/video.mp4") == True - assert app._looks_like_file_path("C:\\Windows\\path\\video.mp4") == True - assert app._looks_like_file_path("~/home/user/video.mp4") == True - assert app._looks_like_file_path("./relative/path.mp4") == True - assert app._looks_like_file_path("../parent/path.mp4") == True - - # Test non-paths - assert app._looks_like_file_path("just some text") == False - assert app._looks_like_file_path("") == False - assert app._looks_like_file_path("a") == False - - def test_screen_stack_management(self): - """Test proper screen stack management logic.""" - app = SharpFramesApp() - - # Test that the action_cancel method exists and has the expected behavior patterns - assert hasattr(app, 'action_cancel') - assert callable(app.action_cancel) - - # Test escape sequence handling exists - assert hasattr(app, '_last_escape_time') - assert hasattr(app, '_escape_count') - assert hasattr(app, '_last_action_time') - - # Test signal handler management - assert hasattr(app, 'setup_signal_handlers') - assert hasattr(app, 'restore_signal_handlers') - - # Test file path routing capability - assert hasattr(app, '_looks_like_file_path') - assert hasattr(app, '_route_file_path_to_input') - assert hasattr(app, '_get_target_input_for_step') - - def test_signal_handler_management(self): - """Test signal handler setup and restoration.""" - app = SharpFramesApp() - - # Test setup - app.setup_signal_handlers() - assert isinstance(app._original_signal_handlers, dict) - - # Test restoration (should not raise errors) - app.restore_signal_handlers() - - # Should be able to call multiple times without issues - app.setup_signal_handlers() - app.restore_signal_handlers() - - -class TestScreenTransitions: - """Test screen transitions and workflow.""" - - @patch('sharp_frames.processing.tui_processor.TUIProcessor') - def test_config_to_processing_transition(self, mock_processor_class): - """Test transition from configuration to processing screen.""" - mock_processor = MagicMock() - mock_processor_class.return_value = mock_processor - - # Create configuration form - form = TwoPhaseConfigurationForm() - - # Set up mock app using patch - mock_app = MagicMock() - - # Mock validation success and app reference - with patch.object(form, '_validate_final_config', return_value=True), \ - patch.object(form, 'app', mock_app): - - # Set some config data - form.config_data = { - 'input_type': 'video', - 'input_path': '/test/video.mp4', - 'output_dir': '/test/output', - 'output_format': 'jpg' - } - - # Trigger processing start - form._start_processing() - - # Verify processing screen was pushed - mock_app.push_screen.assert_called_once() - - # Verify the screen is a TwoPhaseProcessingScreen - pushed_screen = mock_app.push_screen.call_args[0][0] - assert isinstance(pushed_screen, TwoPhaseProcessingScreen) - assert pushed_screen.config == form.config_data - - def test_selection_to_completion(self): - """Test selection screen completion flow.""" - # Create mock components - mock_processor = MagicMock() - mock_extraction_result = MagicMock() - - mock_frames = [ - FrameData(path=f"/tmp/frame_{i}.jpg", index=i, sharpness_score=float(i)) - for i in range(10) - ] - mock_extraction_result.frames = mock_frames - - config = { - 'input_type': 'video', - 'input_path': '/test/video.mp4', - 'output_dir': '/test/output', - 'output_format': 'jpg' - } - - screen = SelectionScreen(mock_processor, mock_extraction_result, config) - - # Mock app - mock_app = MagicMock() - - # Test that screen has completion method - assert hasattr(screen, '_handle_confirm') - - # Test that screen has selection methods - assert hasattr(screen, 'current_method') - assert hasattr(screen, 'current_parameters') - - def test_error_handling_in_transitions(self): - """Test error handling during screen transitions.""" - form = TwoPhaseConfigurationForm() - - # Mock app and query methods - mock_app = MagicMock() - mock_app.log = MagicMock() - mock_step_description = MagicMock() - - # Mock processing screen creation to raise an error - with patch('sharp_frames.ui.screens.processing_v2.TwoPhaseProcessingScreen', - side_effect=Exception("Test error")), \ - patch.object(form, 'app', mock_app), \ - patch.object(form, 'query_one', return_value=mock_step_description), \ - patch.object(form, '_validate_final_config', return_value=True): - - form.config_data = { - 'input_type': 'video', - 'input_path': '/test/video.mp4', - 'output_dir': '/test/output', - 'output_format': 'jpg' - } - - form._start_processing() - - # Should log error - mock_app.log.error.assert_called() - - # Should update description with error message - mock_step_description.update.assert_called() - # Verify error message contains expected text - error_message = mock_step_description.update.call_args[0][0] - assert "Error starting processing" in error_message \ No newline at end of file + + +@pytest.mark.asyncio +async def test_selection_screen_mounts_and_updates_real_preview(): + result = _extraction_result() + processor = Mock() + processor.preview_selection.return_value = 4 + processor.selector.select_frames.return_value = result.frames[:4] + screen = SelectionScreen( + processor, + result, + {"input_type": "video", "output_dir": "/output"}, + ) + + async with ScreenHarness(screen).run_test() as pilot: + await pilot.pause(0.25) + + assert screen.query_one("#method_select", Select).value == "batched" + assert screen.query_one("#sharpness_chart", SharpnessChart) + confirm = screen.query_one("#confirm_button", Button) + assert str(confirm.label) == "Save 4 Images" + assert confirm.disabled is False + assert screen.selected_indices == {0, 1, 2, 3} + + processor.preview_selection.assert_not_called() + processor.selector.select_frames.assert_called_once_with( + result.frames, + "batched", + batch_size=5, + batch_buffer=2, + ) + + +def test_processing_screen_initializes_thread_safe_state(): + config = { + "input_type": "video", + "input_path": "/video.mp4", + "output_dir": "/output", + } + screen = ProcessingScreen(config) + + assert screen.config == config + assert screen.processor is None + assert screen.extraction_result is None + assert screen.processing_cancelled is False + assert screen.phase_1_complete is False + + screen.processing_cancelled = True + screen.phase_1_complete = True + assert screen.processing_cancelled is True + assert screen.phase_1_complete is True + + +@pytest.mark.asyncio +async def test_processing_validation_failure_close_button_pops_screen(): + screen = ProcessingScreen({}) + app = ScreenHarness(screen) + + with patch.object(screen, "_validate_config", return_value=False): + async with app.run_test() as pilot: + await pilot.pause() + button = screen.query_one("#cancel-processing", Button) + assert str(button.label) == "Close" + assert screen.phase_1_complete is True + + screen.on_button_pressed(Button.Pressed(button)) + await pilot.pause() + + assert app.screen is not screen + + +@pytest.mark.asyncio +async def test_processing_false_worker_result_becomes_closeable(): + screen = ProcessingScreen( + {"input_type": "video", "input_path": "/input", "output_dir": "/output"} + ) + + with patch.object(screen, "start_phase_1_processing"): + async with ScreenHarness(screen).run_test() as pilot: + await pilot.pause() + worker = Mock() + worker.name = f"{WorkerNames.FRAME_PROCESSOR}_phase1" + worker.state = WorkerState.SUCCESS + worker.result = False + + screen.on_worker_state_changed(Mock(worker=worker)) + + assert screen.phase_1_complete is True + assert str(screen.query_one("#cancel-processing", Button).label) == "Close" + assert "failed" in str( + screen.query_one("#status-text", Static).render() + ).lower() + + +@pytest.mark.asyncio +async def test_processing_cancel_signals_without_cancelling_thread_worker(): + screen = ProcessingScreen( + {"input_type": "video", "input_path": "/input", "output_dir": "/output"} + ) + screen.processor = Mock() + + with patch.object(screen, "start_phase_1_processing"): + async with ScreenHarness(screen).run_test() as pilot: + await pilot.pause() + screen.action_cancel() + + screen.processor.cancel_processing.assert_called_once_with() + assert screen.processing_cancelled is True + assert screen.phase_1_complete is False + assert screen.query_one("#cancel-processing", Button).disabled is True + + +@pytest.mark.parametrize( + ("input_type", "requires_video_tools"), + [("directory", False), ("video", True), ("video_directory", True)], +) +def test_processing_validation_requests_dependencies_by_input_type( + input_type, requires_video_tools +): + config = { + "input_type": input_type, + "input_path": "/input", + "output_dir": "/output", + } + screen = ProcessingScreen(config) + generic = ( + "Processing failed due to an unexpected error. " + "Check input files and system resources." + ) + + with ( + patch.object(ErrorContext, "analyze_processing_failure", return_value=generic), + patch.object( + ErrorContext, "check_system_dependencies", return_value=None + ) as dependencies, + ): + assert screen._validate_config(config) is True + + dependencies.assert_called_once_with( + require_video_tools=requires_video_tools + ) + + +@pytest.mark.parametrize("missing_key", ["input_path", "output_dir"]) +def test_processing_validation_rejects_missing_required_paths(missing_key): + config = { + "input_type": "directory", + "input_path": "/input", + "output_dir": "/output", + } + config.pop(missing_key) + + assert ProcessingScreen(config)._validate_config(config) is False + + +def test_processing_transitions_with_the_same_processor_result_and_config(): + screen = ProcessingScreen( + {"input_type": "video", "input_path": "/video", "output_dir": "/output"} + ) + screen.processor = Mock() + screen.extraction_result = _extraction_result() + app = Mock() + + with patch.object( + ProcessingScreen, "app", new_callable=PropertyMock, return_value=app + ): + screen._transition_to_selection_screen() + + pushed = app.push_screen.call_args.args[0] + assert isinstance(pushed, SelectionScreen) + assert pushed.processor is screen.processor + assert pushed.extraction_result is screen.extraction_result + assert pushed.config == screen.config + + +@pytest.mark.parametrize( + ("step", "expected"), + [("input_path", "input-path"), ("output_dir", "output-dir-input")], +) +def test_file_path_routing_targets_the_active_configuration_step(step, expected): + app = SharpFramesApp() + configuration = Mock(spec=ConfigurationForm) + configuration.get_current_step_name.return_value = step + + assert app._get_target_input_for_step(configuration, "/tmp/input.mp4") == expected + + +def test_file_path_routing_ignores_non_path_steps(): + app = SharpFramesApp() + configuration = Mock(spec=ConfigurationForm) + configuration.get_current_step_name.return_value = "fps" + + assert app._get_target_input_for_step(configuration, "/tmp/input.mp4") is None diff --git a/tests/ui/test_selection_screen.py b/tests/ui/test_selection_screen.py index 0aeac18..2694847 100644 --- a/tests/ui/test_selection_screen.py +++ b/tests/ui/test_selection_screen.py @@ -1,382 +1,850 @@ -""" -Tests for SelectionScreen UI component. -""" +"""Tests for the active interactive selection screen API.""" + +import asyncio +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, PropertyMock, patch import pytest -from unittest.mock import Mock, patch, AsyncMock -from textual.app import App -from textual.widgets import Select, Input, Label, Button +from rich.cells import cell_len +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.widget import Widget +from textual.widgets import Button, Input, Static -from sharp_frames.ui.screens.selection import SelectionScreen from sharp_frames.models.frame_data import ExtractionResult, FrameData -from sharp_frames.processing.tui_processor import TUIProcessor -from tests.fixtures import ( - create_sample_frames_data, - sample_config_video, - mock_extraction_result +from sharp_frames.ui.components.raster_preview import RasterImagePreview +from sharp_frames.ui.screens.selection import ( + InputWithControls, + SelectionScreen, + SharpnessChart, ) +from sharp_frames.ui.styles import SHARP_FRAMES_CSS + + +@pytest.fixture +def extraction_result(): + frames = [ + FrameData(f"/tmp/frame_{index:05d}.jpg", index, float(index + 1)) + for index in range(20) + ] + return ExtractionResult( + frames=frames, + metadata={"fps": 30}, + input_type="video", + temp_dir="/tmp/frames", + ) + + +@pytest.fixture +def screen(extraction_result): + processor = Mock() + processor.preview_selection.return_value = 10 + processor.selector.select_frames.return_value = extraction_result.frames[:10] + return SelectionScreen( + processor, + extraction_result, + { + "input_type": "video", + "input_path": "/video.mp4", + "output_dir": "/output", + "output_format": "jpg", + }, + ) + + +def test_initial_state_matches_visible_default_controls(screen): + assert screen.current_method == "batched" + assert screen.current_parameters == {"batch_size": 5, "batch_buffer": 2} + assert screen.selected_indices == set() + assert set(screen.method_definitions) == { + "best_n", + "batched", + "outlier_removal", + } + + +@pytest.mark.asyncio +async def test_stale_preview_completion_does_not_update_selection(screen): + screen._preview_generation = 2 + + with ( + patch( + "sharp_frames.ui.screens.selection.asyncio.sleep", + new=AsyncMock(), + ), + patch( + "sharp_frames.ui.screens.selection.asyncio.to_thread", + new=AsyncMock(return_value=screen.extraction_result.frames[:4]), + ) as to_thread, + patch.object(screen, "_update_preview_display") as display, + ): + await screen._update_preview_debounced( + 1, + "batched", + {"batch_size": 5, "batch_buffer": 2}, + ) + + to_thread.assert_awaited_once() + display.assert_not_called() + + +@pytest.mark.asyncio +async def test_preview_worker_runs_one_selection_thread_at_a_time(screen): + active = 0 + max_active = 0 + gate = asyncio.Event() + + def blocking_select(frames, method, **params): + nonlocal active, max_active + active += 1 + max_active = max(max_active, active) + while not gate.is_set(): + time.sleep(0.001) + active -= 1 + return frames[:3] + + screen.processor.selector.select_frames.side_effect = blocking_select + + with patch( + "sharp_frames.ui.screens.selection.asyncio.sleep", new=AsyncMock() + ), patch.object(screen, "_update_preview_display"), patch.object( + screen, "post_message", new=AsyncMock() + ): + screen._update_preview_async() + await asyncio.sleep(0) + screen._update_preview_async() + screen._update_preview_async() + await asyncio.sleep(0.05) + gate.set() + if screen.preview_task is not None: + await screen.preview_task + + assert max_active == 1 + + +def test_start_over_resets_config_screen_beneath_default_screen(screen): + default_screen = Mock(spec=[]) + config_screen = Mock(spec=["reset_to_first_step"]) + processing_screen = Mock(spec=[]) + app = Mock() + app.screen_stack = [default_screen, config_screen, processing_screen, screen] + + with patch.object( + SelectionScreen, "app", new_callable=PropertyMock, return_value=app + ): + screen.action_start_over() + + config_screen.reset_to_first_step.assert_called_once_with() + assert app.pop_screen.call_count == 2 + + +def test_action_confirm_ignores_repeat_while_saving(screen): + started = [] + screen.selected_count = 5 + + with patch.object(screen, "query_one") as query_one, patch.object( + screen, "_start_final_processing", side_effect=lambda: started.append(1) + ): + query_one.return_value = SimpleNamespace(disabled=False) + screen._final_processing_task = None + screen.action_confirm() + + running = SimpleNamespace(done=lambda: False) + screen._final_processing_task = running + screen.action_confirm() + + assert started == [1] + + +def test_method_definitions_expose_valid_defaults_and_ranges(screen): + for method in screen.method_definitions.values(): + assert method["name"] + assert len(method["description"]) > 20 + assert method["parameters"] + for parameter in method["parameters"].values(): + assert parameter["min"] <= parameter["default"] <= parameter["max"] + + +@pytest.mark.asyncio +async def test_method_change_resets_parameters_to_method_defaults(screen): + event = SimpleNamespace( + select=SimpleNamespace(id="method_select"), + value="best_n", + ) + + with ( + patch.object(screen, "_update_method_description") as description, + patch.object( + screen, "_update_parameter_inputs_async", new=AsyncMock() + ) as inputs, + patch.object(screen, "_update_preview_async") as preview, + ): + screen.on_select_changed(event) + await asyncio.sleep(0) + + assert screen.current_method == "best_n" + assert screen.current_parameters == {"n": 300, "min_buffer": 3} + description.assert_called_once_with() + inputs.assert_awaited_once_with() + preview.assert_called_once_with() + + +def test_parameter_changes_are_clamped_and_trigger_preview(screen): + with patch.object(screen, "_update_preview_async") as preview: + screen._handle_parameter_change("batch_size", "1000") + + assert screen.current_parameters["batch_size"] == 100 + preview.assert_called_once_with() + + +def test_blank_parameter_restores_declared_default(screen): + screen.current_parameters["batch_size"] = 8 + + with patch.object(screen, "_update_preview_async") as preview: + screen._handle_parameter_change("batch_size", "") + + assert screen.current_parameters["batch_size"] == 5 + preview.assert_called_once_with() + + +def test_input_change_routes_only_current_method_parameters(screen): + current_event = SimpleNamespace( + input=SimpleNamespace(id="param_batched_batch_buffer"), value="7" + ) + other_event = SimpleNamespace( + input=SimpleNamespace(id="param_best_n_n"), value="7" + ) + + with patch.object(screen, "_handle_parameter_change") as handle: + screen.on_input_changed(current_event) + screen.on_input_changed(other_event) + + handle.assert_called_once_with("batch_buffer", "7") + + +def test_confirm_delegates_to_final_processing(screen): + screen.selected_count = 4 + screen._final_processing_task = None + with patch.object(screen, "query_one") as query_one, patch.object( + screen, "_start_final_processing" + ) as start: + query_one.return_value = SimpleNamespace(disabled=False) + screen.action_confirm() + + start.assert_called_once_with() + + +def test_confirm_is_ignored_when_no_frames_are_selected(screen): + screen.selected_count = 0 + screen._final_processing_task = None + with patch.object(screen, "query_one") as query_one, patch.object( + screen, "_start_final_processing" + ) as start: + query_one.return_value = SimpleNamespace(disabled=True) + screen.action_confirm() + + start.assert_not_called() + + +@pytest.mark.asyncio +async def test_background_selection_passes_method_config_and_parameters(screen): + screen.current_method = "best_n" + screen.current_parameters = {"n": 12, "min_buffer": 2} + screen.processor.complete_selection.return_value = True + config = {**screen.config, "selection_method": "best_n"} + + result = await screen._execute_selection_in_background(config) + + assert result is True + screen.processor.complete_selection.assert_called_once_with( + "best_n", config, n=12, min_buffer=2 + ) + + +def test_selection_preview_message_preserves_payload(screen): + message = screen.SelectionPreview(14, "best_n", n=14, min_buffer=3) + + assert message.count == 14 + assert message.method == "best_n" + assert message.params == {"n": 14, "min_buffer": 3} + + +def test_chart_normalizes_scores_without_discarding_frames(extraction_result): + frames = [ + FrameData(f"/tmp/frame_{index:05d}.jpg", index, float(index + 1)) + for index in range(5_000) + ] + chart = SharpnessChart(frames, selected_indices={2, 4, 4_999}) + + assert len(chart.frames) == 5_000 + assert chart.virtual_size.width == 10_000 + assert chart.min_score == 1.0 + assert chart.max_score == 5_000.0 + assert chart.score_range == 4_999.0 + assert chart.selected_indices == {2, 4, 4_999} + assert chart.inspected_position is None + assert chart.border_title == "Frame selection" + + +def test_chart_uses_fractional_blocks_for_sub_row_precision(): + assert SharpnessChart._bar_glyph(0.0, chart_y=2, chart_height=3) == "█" + assert SharpnessChart._bar_glyph(0.0, chart_y=1, chart_height=3) == " " + assert SharpnessChart._bar_glyph(1.0, chart_y=0, chart_height=3) == "█" + assert SharpnessChart._bar_glyph(0.25, chart_y=1, chart_height=3) == "▄" + assert SharpnessChart._bar_glyph(0.25, chart_y=0, chart_height=3) == " " + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("size", "expected_class"), + [ + ((120, 56), "-vertical-regular"), + ((120, 44), "-vertical-compact"), + ((100, 30), "-vertical-compact"), + ((80, 24), "-vertical-compact"), + ], +) +async def test_preview_and_chart_stay_in_viewport_on_short_terminals( + screen, size, expected_class +): + class SelectionApp(App): + CSS = SHARP_FRAMES_CSS + + def on_mount(self): + self.push_screen(screen) + + async with SelectionApp().run_test(size=size) as pilot: + await pilot.pause() + # Simulate a raster-capable terminal with a visible inline preview + preview = screen.query_one("#frame_preview") + preview.remove_class("-unsupported") + preview.display = True + await pilot.pause() + + assert screen.has_class(expected_class) + fold = screen.query_one("#main_content").region.bottom + preview_region = preview.region + chart_region = screen.query_one("#sharpness_chart").region + assert preview_region.height > 0 + assert chart_region.height > 0 + assert preview_region.bottom <= fold + assert chart_region.bottom <= fold + assert screen.query_one("#action_buttons").region.bottom <= fold + + +@pytest.mark.asyncio +async def test_chart_scrolls_across_thousands_of_frames(): + frames = [ + FrameData(f"/tmp/frame_{index:05d}.jpg", index, float(index + 1)) + for index in range(5_000) + ] + chart = SharpnessChart(frames) + + class ChartApp(App): + CSS = SHARP_FRAMES_CSS + + def compose(self) -> ComposeResult: + yield chart + + async with ChartApp().run_test(size=(80, 24)) as pilot: + await pilot.pause() + + assert chart.outer_size.height > 13 + assert chart.max_scroll_x > 9_000 + assert chart.max_scroll_y == 0 + assert chart.render_line(1).cell_length == chart.size.width + background_style = chart.get_component_rich_style( + "sharpness-chart--background" + ) + title_style = chart.get_component_rich_style( + "sharpness-chart--title" + ) + assert background_style.bgcolor is not None + assert title_style.bgcolor == background_style.bgcolor + title_line = chart.render_line(0) + assert "Frames 1-39 of 5,000" in title_line.text + assert all( + segment.style.bgcolor == background_style.bgcolor + for segment in title_line + ) + bottom_line = chart.render_line(chart.scrollable_content_region.height - 1) + assert bottom_line.text[:8] == "█ █ █ █ " + + chart.focus() + await pilot.press("right") + await pilot.pause(0.1) + assert chart.scroll_offset.x == SharpnessChart.FRAME_STRIDE + assert "Frames 2-40 of 5,000" in chart.render_line(0).text + + await pilot.press("end") + await pilot.pause() + assert chart.is_horizontal_scroll_end + assert chart.render_line(0).text.rstrip().endswith("of 5,000") + + await pilot.press("home") + await pilot.pause() + assert chart.scroll_offset.x == 0 + + +@pytest.mark.asyncio +async def test_clicking_chart_bar_requests_frame_inspection(): + frames = [ + FrameData(f"/tmp/frame_{index:05d}.jpg", index, float(index + 1)) + for index in range(100) + ] + chart = SharpnessChart(frames) + + class ChartApp(App): + CSS = SHARP_FRAMES_CSS + + def __init__(self): + super().__init__() + self.inspected = [] + self.opened = [] + + def compose(self) -> ComposeResult: + yield chart + + def on_sharpness_chart_frame_inspect_requested(self, event): + self.inspected.append(event.frame) + + def on_sharpness_chart_frame_open_requested(self, event): + self.opened.append(event.frame) + + app = ChartApp() + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + await pilot.click(chart, offset=(1, 2)) + await pilot.pause() + + assert app.inspected == [frames[0]] + assert chart.inspected_index == frames[0].index + assert chart.inspected_position == 0 + assert "Arrows inspect" in chart.border_subtitle + + await pilot.press("right") + await pilot.pause() + assert app.inspected[-1] == frames[1] + assert chart.inspected_index == frames[1].index + assert chart.inspected_position == 1 + + await pilot.press("left") + await pilot.pause() + assert app.inspected[-1] == frames[0] + assert chart.inspected_position == 0 + chart.scroll_to(x=SharpnessChart.FRAME_STRIDE * 2, animate=False) + await pilot.pause() + await pilot.click(chart, offset=(1, 2)) + await pilot.pause() -class MockApp(App): - """Mock Textual app for testing.""" - pass - - -class TestSelectionScreen: - """Test cases for SelectionScreen UI component.""" - - def setup_method(self): - """Set up test environment.""" - self.app = MockApp() - - # Create mock processor with extraction result - self.mock_processor = Mock(spec=TUIProcessor) - - # Create sample extraction result - self.sample_result = ExtractionResult( - frames=create_sample_frames_data()[:20], # Use first 20 frames for testing - metadata={'fps': 30, 'duration': 10.0}, - input_type='video', - temp_dir='/tmp/test' + assert app.inspected[-1] == frames[2] + assert chart.inspected_index == frames[2].index + assert chart.inspected_position == 2 + + chart._inspect_frame(42) + await pilot.pause() + + assert app.inspected[-1] == frames[42] + assert chart.inspected_position == 42 + inspected_left = 42 * SharpnessChart.FRAME_STRIDE + inspected_right = inspected_left + SharpnessChart.FRAME_STRIDE + assert int(chart.scroll_offset.x) <= inspected_left + assert inspected_right <= ( + int(chart.scroll_offset.x) + chart.scrollable_content_region.width ) - - self.config = { - 'input_type': 'video', - 'input_path': '/path/to/video.mp4', - 'output_dir': '/path/to/output', - 'fps': 30, - 'output_format': 'jpg' - } - - def test_init(self): - """Test SelectionScreen initialization.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - assert screen.processor == self.mock_processor - assert screen.extraction_result == self.sample_result - assert screen.config == self.config - assert screen.current_method == 'best_n' # Default method - assert screen.current_parameters == {} - - def test_compose_creates_required_widgets(self): - """Test that compose method creates all required UI widgets.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - # Mock the compose method to track widget creation - with patch.object(screen, 'compose') as mock_compose: - mock_compose.return_value = iter([ - Label("Total frames: 20"), - Select(options=[("best_n", "Best N Frames")], value="best_n"), - Label("Selected: 0 frames"), - Button("Confirm", id="confirm"), - Button("Cancel", id="cancel") - ]) - - widgets = list(mock_compose()) - - # Should have header, method selector, parameter inputs, preview, and buttons - assert len(widgets) >= 5 - mock_compose.assert_called_once() - - def test_selection_screen_displays_total_frames(self): - """Test that selection screen displays total frame count.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - # Mock the frame count display - with patch.object(screen, '_update_frame_count_display') as mock_update: - screen._update_frame_count_display() - mock_update.assert_called_once() - - def test_method_dropdown_changes_parameter_inputs(self): - """Test that changing selection method updates parameter input fields.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - # Mock method change handler - with patch.object(screen, '_on_method_change') as mock_handler: - # Simulate method change - screen._on_method_change('batched') - - mock_handler.assert_called_once_with('batched') - - def test_realtime_count_update_on_parameter_change(self): - """Test that parameter changes trigger real-time preview updates.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - # Mock preview update - self.mock_processor.preview_selection.return_value = 15 - - with patch.object(screen, 'update_preview') as mock_update: - # Simulate parameter change - screen._on_parameter_change('n', 15) - - mock_update.assert_called_once() - - def test_update_preview_calls_processor(self): - """Test that update_preview calls the TUIProcessor correctly.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - screen.current_method = 'best_n' - screen.current_parameters = {'n': 10} - - self.mock_processor.preview_selection.return_value = 10 - - count = screen.update_preview() - - self.mock_processor.preview_selection.assert_called_once_with('best_n', n=10) - assert count == 10 - - def test_update_preview_handles_different_methods(self): - """Test preview updates for different selection methods.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - # Test best_n method - screen.current_method = 'best_n' - screen.current_parameters = {'n': 15} - self.mock_processor.preview_selection.return_value = 15 - - count = screen.update_preview() - self.mock_processor.preview_selection.assert_called_with('best_n', n=15) - assert count == 15 - - # Test batched method - screen.current_method = 'batched' - screen.current_parameters = {'batch_count': 5} - self.mock_processor.preview_selection.return_value = 5 - - count = screen.update_preview() - self.mock_processor.preview_selection.assert_called_with('batched', batch_count=5) - assert count == 5 - - # Test outlier_removal method - screen.current_method = 'outlier_removal' - screen.current_parameters = {'factor': 1.5} - self.mock_processor.preview_selection.return_value = 18 - - count = screen.update_preview() - self.mock_processor.preview_selection.assert_called_with('outlier_removal', factor=1.5) - assert count == 18 - - def test_update_preview_performance_under_100ms(self): - """Test that preview updates complete within 100ms for responsive UI.""" - import time - - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - screen.current_method = 'best_n' - screen.current_parameters = {'n': 10} - - # Mock fast preview calculation - def fast_preview(*args, **kwargs): - return 10 - - self.mock_processor.preview_selection = fast_preview - - start_time = time.time() - count = screen.update_preview() - end_time = time.time() - - elapsed = end_time - start_time - assert elapsed < 0.1, f"Preview update took {elapsed:.3f}s, should be <0.1s" - assert count == 10 - - def test_confirm_button_triggers_save(self): - """Test that confirm button triggers frame selection and save.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - screen.current_method = 'best_n' - screen.current_parameters = {'n': 10} - - self.mock_processor.complete_selection.return_value = True - - with patch.object(screen, 'on_confirm') as mock_confirm: - screen.on_confirm() - mock_confirm.assert_called_once() - - def test_on_confirm_calls_complete_selection(self): - """Test that on_confirm calls TUIProcessor.complete_selection.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - screen.current_method = 'best_n' - screen.current_parameters = {'n': 10} - - self.mock_processor.complete_selection.return_value = True - - with patch.object(screen.app, 'pop_screen') as mock_pop: - screen.on_confirm() - - self.mock_processor.complete_selection.assert_called_once_with( - 'best_n', self.config, n=10 - ) - mock_pop.assert_called_once() - - def test_on_cancel_returns_to_previous_screen(self): - """Test that cancel button returns to previous screen without saving.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - with patch.object(screen.app, 'pop_screen') as mock_pop: - screen.on_cancel() - - mock_pop.assert_called_once() - # Should not call complete_selection - self.mock_processor.complete_selection.assert_not_called() - - def test_parameter_validation_for_best_n(self): - """Test parameter validation for best-n method.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - # Valid parameters - assert screen._validate_parameters('best_n', {'n': 10}) is True - assert screen._validate_parameters('best_n', {'n': 1}) is True - - # Invalid parameters - assert screen._validate_parameters('best_n', {'n': 0}) is False - assert screen._validate_parameters('best_n', {'n': -1}) is False - assert screen._validate_parameters('best_n', {}) is False - assert screen._validate_parameters('best_n', {'n': 'invalid'}) is False - - def test_parameter_validation_for_batched(self): - """Test parameter validation for batched method.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - # Valid parameters - assert screen._validate_parameters('batched', {'batch_count': 5}) is True - assert screen._validate_parameters('batched', {'batch_count': 1}) is True - - # Invalid parameters - assert screen._validate_parameters('batched', {'batch_count': 0}) is False - assert screen._validate_parameters('batched', {'batch_count': -1}) is False - assert screen._validate_parameters('batched', {}) is False - - def test_parameter_validation_for_outlier_removal(self): - """Test parameter validation for outlier removal method.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - # Valid parameters - assert screen._validate_parameters('outlier_removal', {'factor': 1.5}) is True - assert screen._validate_parameters('outlier_removal', {'factor': 0.1}) is True - - # Invalid parameters - assert screen._validate_parameters('outlier_removal', {'factor': 0}) is False - assert screen._validate_parameters('outlier_removal', {'factor': -1}) is False - assert screen._validate_parameters('outlier_removal', {}) is False - - def test_error_handling_in_preview_update(self): - """Test error handling when preview update fails.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - screen.current_method = 'best_n' - screen.current_parameters = {'n': 10} - - # Mock processor to raise exception - self.mock_processor.preview_selection.side_effect = Exception("Preview failed") - - with patch.object(screen, '_show_error_message') as mock_error: - count = screen.update_preview() - - mock_error.assert_called_once() - assert count == 0 # Should return 0 on error - - def test_error_handling_in_confirm(self): - """Test error handling when confirm operation fails.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - screen.current_method = 'best_n' - screen.current_parameters = {'n': 10} - - # Mock processor to raise exception - self.mock_processor.complete_selection.side_effect = Exception("Save failed") - - with patch.object(screen, '_show_error_message') as mock_error, \ - patch.object(screen.app, 'pop_screen') as mock_pop: - - screen.on_confirm() - - mock_error.assert_called_once() - # Should not pop screen on error - mock_pop.assert_not_called() - - def test_method_dropdown_options(self): - """Test that method dropdown contains all available selection methods.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - options = screen._get_method_options() - - expected_methods = ['best_n', 'batched', 'outlier_removal'] - assert len(options) == len(expected_methods) - - option_values = [option[0] for option in options] - for method in expected_methods: - assert method in option_values - - def test_parameter_input_fields_update_on_method_change(self): - """Test that parameter input fields change based on selected method.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - # Test best_n method - with patch.object(screen, '_create_parameter_inputs') as mock_create: - screen._update_parameter_inputs('best_n') - mock_create.assert_called_once_with('best_n') - - # Test batched method - with patch.object(screen, '_create_parameter_inputs') as mock_create: - screen._update_parameter_inputs('batched') - mock_create.assert_called_once_with('batched') - - # Test outlier_removal method - with patch.object(screen, '_create_parameter_inputs') as mock_create: - screen._update_parameter_inputs('outlier_removal') - mock_create.assert_called_once_with('outlier_removal') - - def test_frame_count_display_formatting(self): - """Test that frame counts are displayed in user-friendly format.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - # Test various counts - assert screen._format_frame_count(0) == "0 frames" - assert screen._format_frame_count(1) == "1 frame" - assert screen._format_frame_count(10) == "10 frames" - assert screen._format_frame_count(1000) == "1,000 frames" - assert screen._format_frame_count(10000) == "10,000 frames" - - def test_keyboard_shortcuts(self): - """Test keyboard shortcuts for common actions.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - # Mock key event handling - with patch.object(screen, 'on_confirm') as mock_confirm, \ - patch.object(screen, 'on_cancel') as mock_cancel: - - # Test Enter key for confirm - screen._handle_key_press('enter') - mock_confirm.assert_called_once() - - # Test Escape key for cancel - screen._handle_key_press('escape') - mock_cancel.assert_called_once() - - def test_loading_states_during_operations(self): - """Test that loading states are shown during long operations.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - with patch.object(screen, '_show_loading') as mock_loading, \ - patch.object(screen, '_hide_loading') as mock_hide_loading: - - # Mock slow operation - def slow_complete_selection(*args, **kwargs): - mock_loading.assert_called_once() - return True - - self.mock_processor.complete_selection = slow_complete_selection - - screen.on_confirm() - - mock_hide_loading.assert_called_once() - - def test_help_text_for_methods(self): - """Test that help text is available for each selection method.""" - screen = SelectionScreen(self.mock_processor, self.sample_result, self.config) - - help_texts = screen._get_method_help_texts() - - assert 'best_n' in help_texts - assert 'batched' in help_texts - assert 'outlier_removal' in help_texts - - # Help texts should be descriptive - for method, help_text in help_texts.items(): - assert len(help_text) > 20 # Should have meaningful description - assert isinstance(help_text, str) \ No newline at end of file + + await pilot.press("o") + await pilot.pause() + assert app.opened == [frames[42]] + + +@pytest.mark.asyncio +async def test_page_keys_inspect_previous_and_next_selected_frames(): + frames = [ + FrameData(f"/tmp/frame_{index:05d}.jpg", index, float(index + 1)) + for index in range(12) + ] + chart = SharpnessChart(frames, selected_indices={2, 5, 9}) + + class ChartApp(App): + CSS = SHARP_FRAMES_CSS + + def __init__(self): + super().__init__() + self.inspected = [] + + def compose(self) -> ComposeResult: + yield chart + + def on_sharpness_chart_frame_inspect_requested(self, event): + self.inspected.append(event.frame) + + app = ChartApp() + async with app.run_test(size=(80, 24)) as pilot: + chart.focus() + + await pilot.press("pagedown") + await pilot.pause() + assert app.inspected[-1] == frames[2] + + await pilot.press("pagedown") + await pilot.pause() + assert app.inspected[-1] == frames[5] + + await pilot.press("pageup") + await pilot.pause() + assert app.inspected[-1] == frames[2] + + chart._inspect_frame(7) + await pilot.pause() + await pilot.press("pagedown") + await pilot.pause() + assert app.inspected[-1] == frames[9] + + chart._inspect_frame(7) + await pilot.pause() + await pilot.press("pageup") + await pilot.pause() + assert app.inspected[-1] == frames[5] + + inspected_count = len(app.inspected) + chart._inspect_frame(9) + await pilot.pause() + await pilot.press("pagedown") + await pilot.pause() + assert len(app.inspected) == inspected_count + 1 + assert app.inspected[-1] == frames[9] + + +@pytest.mark.asyncio +async def test_frame_inspection_handler_keeps_external_open_explicit(screen): + frame = screen.extraction_result.frames[3] + event = SimpleNamespace(frame=frame) + preview = Mock(spec=RasterImagePreview) + preview.show_frame.return_value = False + + with ( + patch.object(screen, "query_one", return_value=preview), + patch( + "sharp_frames.ui.screens.selection.asyncio.to_thread", + new=AsyncMock(), + ) as to_thread, + patch.object(screen, "notify") as notify, + ): + await screen.on_sharpness_chart_frame_inspect_requested(event) + + to_thread.assert_not_awaited() + preview.show_frame.assert_called_once_with( + frame.path, + frame_number=frame.index + 1, + score=frame.sharpness_score, + ) + assert notify.call_args.kwargs["title"] == "Inline preview unavailable" + assert "Press O" in notify.call_args.args[0] + + +@pytest.mark.asyncio +async def test_open_shortcut_opens_inspected_frame_in_default_viewer(screen): + frame = screen.extraction_result.frames[6] + event = SimpleNamespace(frame=frame) + + with ( + patch( + "sharp_frames.ui.screens.selection.asyncio.to_thread", + new=AsyncMock(), + ) as to_thread, + patch.object(screen, "notify") as notify, + ): + await screen.on_sharpness_chart_frame_open_requested(event) + + to_thread.assert_awaited_once() + assert to_thread.await_args.args[1] == frame.path + assert notify.call_args.kwargs["title"] == "Opened externally" + + +@pytest.mark.asyncio +async def test_frame_inspection_uses_inline_raster_without_external_open(screen): + frame = screen.extraction_result.frames[3] + event = SimpleNamespace(frame=frame) + preview = Mock(spec=RasterImagePreview) + preview.show_frame.return_value = True + + with ( + patch.object(screen, "query_one", return_value=preview), + patch( + "sharp_frames.ui.screens.selection.asyncio.to_thread", + new=AsyncMock(), + ) as to_thread, + patch.object(screen, "notify") as notify, + ): + await screen.on_sharpness_chart_frame_inspect_requested(event) + + to_thread.assert_not_awaited() + assert notify.call_args.kwargs["title"] == "Inline preview" + + +@pytest.mark.asyncio +async def test_frame_inspection_falls_back_when_raster_renderer_fails(screen): + frame = screen.extraction_result.frames[3] + event = SimpleNamespace(frame=frame) + preview = Mock(spec=RasterImagePreview) + preview.show_frame.side_effect = OSError("unsupported image data") + + with ( + patch.object(screen, "query_one", return_value=preview), + patch( + "sharp_frames.ui.screens.selection.asyncio.to_thread", + new=AsyncMock(), + ) as to_thread, + patch.object(screen, "notify") as notify, + ): + await screen.on_sharpness_chart_frame_inspect_requested(event) + + to_thread.assert_not_awaited() + assert notify.call_args.kwargs["title"] == "Inline preview unavailable" + assert "renderer could not show" in notify.call_args.args[0] + assert "Press O" in notify.call_args.args[0] + + +def _preview_display_widgets(preview): + """Map the widgets ``_update_preview_display`` looks up to fakes.""" + return { + "#sharpness_chart": Mock(), + "#frame_preview": preview, + "#confirm_button": SimpleNamespace(label="", disabled=True), + } + + +def test_auto_preview_shows_first_selected_frame(screen): + frames = screen.extraction_result.frames + preview = Mock(spec=RasterImagePreview) + preview.raster_supported = True + widgets = _preview_display_widgets(preview) + + with patch.object( + screen, "query_one", side_effect=lambda selector, *a, **k: widgets[selector] + ): + screen._update_preview_display(3, {5, 8, 12}) + + preview.show_frame.assert_called_once_with( + frames[5].path, + frame_number=frames[5].index + 1, + score=frames[5].sharpness_score, + ) + + +def test_auto_preview_skips_unsupported_raster_terminal(screen): + preview = Mock(spec=RasterImagePreview) + preview.raster_supported = False + widgets = _preview_display_widgets(preview) + + with patch.object( + screen, "query_one", side_effect=lambda selector, *a, **k: widgets[selector] + ): + screen._update_preview_display(3, {5, 8, 12}) + + preview.show_frame.assert_not_called() + + +@pytest.mark.asyncio +async def test_manual_inspection_stops_auto_preview_follow(screen): + frame = screen.extraction_result.frames[3] + event = SimpleNamespace(frame=frame) + preview = Mock(spec=RasterImagePreview) + preview.raster_supported = True + preview.show_frame.return_value = True + + with ( + patch.object(screen, "query_one", return_value=preview), + patch.object(screen, "notify"), + patch( + "sharp_frames.ui.screens.selection.asyncio.to_thread", + new=AsyncMock(), + ), + ): + await screen.on_sharpness_chart_frame_inspect_requested(event) + + assert screen._auto_preview_active is False + + # A later selection recompute must not override the inspected frame. + preview.show_frame.reset_mock() + widgets = _preview_display_widgets(preview) + with patch.object( + screen, "query_one", side_effect=lambda selector, *a, **k: widgets[selector] + ): + screen._update_preview_display(3, {5, 8, 12}) + + preview.show_frame.assert_not_called() + + +@pytest.mark.asyncio +async def test_raster_preview_uses_original_file_and_fit_to_window(tmp_path): + source = tmp_path / "original-frame.jpg" + source.write_bytes(b"source image bytes") + + class FakeRasterWidget(Widget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.image = None + + preview = RasterImagePreview( + backend_type=FakeRasterWidget, + backend_name="test raster", + ) + + class PreviewApp(App): + CSS = SHARP_FRAMES_CSS + + def compose(self) -> ComposeResult: + yield preview + + async with PreviewApp().run_test(size=(100, 30)) as pilot: + await pilot.pause() + + assert preview.show_frame(str(source), frame_number=7, score=123.45) + await pilot.pause() + + raster = preview.query_one("#frame_preview_image", FakeRasterWidget) + assert raster.image == source + assert preview.current_path == source + assert preview.display is True + assert raster.styles.width.is_auto + assert raster.styles.height.is_auto + title = str( + preview.query_one(".frame-preview-title", Static).render() + ) + assert title == "Frame 7 · sharpness 123.45 · Press O to open" + assert "test raster" not in title + assert "original image fitted to window" not in title.lower() + + +@pytest.mark.asyncio +async def test_raster_preview_clear_drops_the_backing_file_reference(tmp_path): + source = tmp_path / "original-frame.jpg" + source.write_bytes(b"source image bytes") + + class FakeRasterWidget(Widget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.image = None + + preview = RasterImagePreview( + backend_type=FakeRasterWidget, + backend_name="test raster", + ) + + class PreviewApp(App): + CSS = SHARP_FRAMES_CSS + + def compose(self) -> ComposeResult: + yield preview + + async with PreviewApp().run_test(size=(100, 30)) as pilot: + await pilot.pause() + assert preview.show_frame(str(source), frame_number=7, score=1.0) + await pilot.pause() + + preview.clear() + await pilot.pause() + + raster = preview.query_one("#frame_preview_image", FakeRasterWidget) + assert raster.image is None + assert preview.current_path is None + assert preview.display is False + + +def test_start_final_processing_clears_inline_preview(screen): + preview = Mock(spec=RasterImagePreview) + widgets = { + "#confirm_button": SimpleNamespace(disabled=False), + "#method_select": SimpleNamespace(disabled=False), + "#frame_preview": preview, + } + + def fake_query_one(selector, *args, **kwargs): + return widgets[selector] + + screen._final_processing_task = None + with patch.object(screen, "query_one", side_effect=fake_query_one), patch.object( + screen, "_process_final_selection", return_value=None + ), patch("sharp_frames.ui.screens.selection.asyncio.create_task"): + screen._start_final_processing() + + preview.clear.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_raster_preview_has_visible_unsupported_terminal_fallback(tmp_path): + source = tmp_path / "original-frame.jpg" + source.write_bytes(b"source image bytes") + preview = RasterImagePreview(backend_type=None) + + class PreviewApp(App): + CSS = SHARP_FRAMES_CSS + + def compose(self) -> ComposeResult: + yield preview + + async with PreviewApp().run_test(size=(100, 12)) as pilot: + await pilot.pause() + + assert not preview.show_frame(str(source), frame_number=7, score=123.45) + await pilot.pause() + + assert preview.raster_supported is False + assert preview.display is True + fallback = str( + preview.query_one(".frame-preview-fallback", Static).render() + ) + assert "Press O" in fallback + assert "Opening the original image" not in fallback + + +def test_input_with_controls_retains_value_before_mount(): + control = InputWithControls( + value="5", input_id="batch-size", min_value=1, max_value=10 + ) + + assert control.value == "5" + control.value = "7" + assert control.value == "7" + + +@pytest.mark.asyncio +async def test_input_stepper_uses_compact_secondary_buttons(): + control = InputWithControls( + value="5", input_id="batch-size", min_value=1, max_value=10 + ) + + class StepperApp(App): + CSS = SHARP_FRAMES_CSS + + def compose(self) -> ComposeResult: + yield control + + async with StepperApp().run_test(size=(60, 12)) as pilot: + await pilot.pause() + + decrement = control.query_one("#batch-size_dec", Button) + increment = control.query_one("#batch-size_inc", Button) + field = control.query_one("#batch-size", Input) + button_group = control.query_one(".stepper-controls", Container) + + increment.focus() + await pilot.pause() + field_border = field.styles.border_top[1] + + assert str(decrement.label) == "−" + assert str(increment.label) == "+" + assert decrement.variant == "default" + assert increment.variant == "default" + assert field_border.r == field_border.g == field_border.b + assert decrement.region.width >= decrement.region.height + assert increment.region.width >= increment.region.height + for button in (decrement, increment): + inner_width = button.region.width - 2 # Tall border consumes one cell per side. + assert (inner_width - cell_len(str(button.label))) % 2 == 0 + assert button_group.region.width == 14 + assert increment.region.x == decrement.region.right diff --git a/tests/ui/utils/test_error_analysis.py b/tests/ui/utils/test_error_analysis.py index 66e89fb..dd76e88 100644 --- a/tests/ui/utils/test_error_analysis.py +++ b/tests/ui/utils/test_error_analysis.py @@ -8,7 +8,7 @@ import os import tempfile import pytest -from unittest.mock import patch, Mock, MagicMock +from unittest.mock import call, patch, Mock, MagicMock from sharp_frames.ui.utils.error_analysis import ErrorContext @@ -326,13 +326,21 @@ def test_ffmpeg_working_correctly(self, mock_subprocess): result = ErrorContext.check_system_dependencies() assert result is None # No error means success - # Verify FFmpeg was checked - mock_subprocess['run'].assert_called_with( - ['ffmpeg', '-version'], - capture_output=True, - text=True, - timeout=10 - ) + # Both executables are required for video input. + mock_subprocess['run'].assert_has_calls([ + call( + ['ffmpeg', '-version'], + capture_output=True, + text=True, + timeout=10, + ), + call( + ['ffprobe', '-version'], + capture_output=True, + text=True, + timeout=10, + ), + ]) def test_ffmpeg_not_found(self, mock_subprocess): """Test FFmpeg not found error.""" @@ -422,4 +430,4 @@ def test_unknown_dependency_error(self, mock_subprocess): result = ErrorContext.check_system_dependencies() assert result is not None assert "missing dependency" in result.lower() - assert "unknown_module" in result.lower() \ No newline at end of file + assert "unknown_module" in result.lower() diff --git a/tests/ui/utils/test_image_opener.py b/tests/ui/utils/test_image_opener.py new file mode 100644 index 0000000..a5c9b85 --- /dev/null +++ b/tests/ui/utils/test_image_opener.py @@ -0,0 +1,81 @@ +"""Tests for opening analyzed frames in a platform viewer.""" + +import subprocess +from unittest.mock import patch + +import pytest + +from sharp_frames.ui.utils.image_opener import open_image_file + + +def _completed(returncode: int = 0, stderr: str = "") -> subprocess.CompletedProcess: + return subprocess.CompletedProcess([], returncode, stdout="", stderr=stderr) + + +def test_macos_uses_default_open_command(tmp_path): + image = tmp_path / "frame with spaces.jpg" + image.touch() + + with patch( + "sharp_frames.ui.utils.image_opener.subprocess.run", + return_value=_completed(), + ) as run: + open_image_file(str(image), system_name="Darwin") + + assert run.call_args.args[0] == ["open", str(image)] + assert run.call_args.kwargs["start_new_session"] is True + assert run.call_args.kwargs["timeout"] == 10 + + +def test_linux_uses_xdg_open(tmp_path): + image = tmp_path / "frame.jpg" + image.touch() + + with patch( + "sharp_frames.ui.utils.image_opener.subprocess.run", + return_value=_completed(), + ) as run: + open_image_file(str(image), system_name="Linux") + + assert run.call_args.args[0] == ["xdg-open", str(image)] + + +def test_windows_uses_startfile(tmp_path): + image = tmp_path / "frame.jpg" + image.touch() + + with patch( + "sharp_frames.ui.utils.image_opener.os.startfile", create=True + ) as startfile: + open_image_file(str(image), system_name="Windows") + + startfile.assert_called_once_with(str(image)) + + +def test_nonzero_launcher_exit_is_reported_as_error(tmp_path): + image = tmp_path / "frame.jpg" + image.touch() + + with patch( + "sharp_frames.ui.utils.image_opener.subprocess.run", + return_value=_completed(returncode=3, stderr="no opener configured"), + ): + with pytest.raises(OSError, match="no opener configured"): + open_image_file(str(image), system_name="Linux") + + +def test_launcher_timeout_is_reported_as_error(tmp_path): + image = tmp_path / "frame.jpg" + image.touch() + + with patch( + "sharp_frames.ui.utils.image_opener.subprocess.run", + side_effect=subprocess.TimeoutExpired("xdg-open", 10), + ): + with pytest.raises(OSError, match="Unable to launch"): + open_image_file(str(image), system_name="Linux") + + +def test_missing_frame_reports_that_temp_file_is_unavailable(tmp_path): + with pytest.raises(FileNotFoundError, match="no longer available"): + open_image_file(str(tmp_path / "missing.jpg"), system_name="Darwin")