Metadata-Version: 2.5
Name: finder-adjust-thumbnails
Version: 0.1.0
Summary: Set macOS Finder video thumbnails to a frame at a chosen offset
Project-URL: Homepage, https://github.com/doronz88/finder-adjust-thumbnails
Project-URL: Repository, https://github.com/doronz88/finder-adjust-thumbnails
Project-URL: Issues, https://github.com/doronz88/finder-adjust-thumbnails/issues
Author-email: doronz88 <doron88@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: finder,icon,macos,quicklook,thumbnail,video
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: MacOS X
Classifier: Intended Audience :: End Users/Desktop
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Desktop Environment :: File Managers
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Utilities
Requires-Python: >=3.12
Requires-Dist: pyobjc-framework-avfoundation>=12.2.2
Requires-Dist: pyobjc-framework-cocoa>=10.0
Requires-Dist: pyobjc-framework-quartz>=12.2.2
Requires-Dist: typer>=0.12
Description-Content-Type: text/markdown

# finder-adjust-thumbnails

Set the Finder thumbnail of your videos to the frame at an offset you choose, instead
of whatever QuickLook picked.

## How it works, and what that means

macOS gives you no way to tell QuickLook *which* frame to use as a video's poster
frame. What it does give you is a **custom file icon** — an image stored in the file's
resource fork and flagged in `com.apple.FinderInfo` — which Finder shows in place of
the generated thumbnail.

So this tool grabs the frame you asked for and installs it as the file's icon. Three
consequences worth knowing up front:

- **It is reversible.** `--clear` removes the custom icon and Finder goes back to its
  own thumbnail.
- **It does not travel.** The icon lives in the resource fork, so it survives copies
  and moves within macOS but is lost through zip archives and most cloud sync.
- **Icons are square, frames are not.** macOS stretches whatever it is given to fill a
  square, which would squash a 16:9 frame. The frame is therefore centred at its own
  proportions with the surrounding space left fully transparent — so what you see in
  Finder is the frame at its true shape, with no letterbox bars.

The video file's own content is never modified.

## Install

Requires macOS and Python 3.12+:

```sh
uv tool install .
```

`ffmpeg` is **optional** — see below.

## Use

```sh
# every video in a directory, one quarter of the way in
finder-adjust-thumbnails ~/Movies --offset 25%

# a fixed timestamp, recursively
finder-adjust-thumbnails ~/Movies --recursive --offset 1:30

# a single file
finder-adjust-thumbnails ~/Movies/holiday.mp4 --offset 10

# see what would happen, change nothing
finder-adjust-thumbnails ~/Movies --offset 25% --dry-run

# put the default thumbnails back
finder-adjust-thumbnails ~/Movies --clear
```

### Offsets

| Form | Example | Meaning |
|------|---------|---------|
| seconds | `10`, `90s`, `2.5` | that many seconds in |
| clock time | `1:30`, `1:00:30` | mm:ss or hh:mm:ss |
| percentage | `25%` | that far through the video |

An offset past the end of a video is clamped to just inside it, so one command works
across clips of very different lengths.

### Options

| Option | Default | |
|--------|---------|---|
| `--offset`, `-o` | — | where to take the frame from |
| `--clear` | | remove custom icons instead |
| `--recursive`, `-r` | off | descend into subdirectories |
| `--ext` | `avi,m4v,mkv,mov,mp4,webm,wmv` | extensions to consider in a directory |
| `--icon-size` | `512` | pixel size of the square icon |
| `--dry-run` | off | report without changing anything |
| `--jobs`, `-j` | up to 8 | videos to decode in parallel |

A file named explicitly is always processed, whatever its extension.

Exit status is `0` on success, `1` if any file failed, `2` for bad arguments.

### Which decoder runs

macOS decodes video itself through AVFoundation, so that is tried first: no external
binary, no subprocess, and a video's preferred track transform is honoured — footage
shot in portrait comes out upright rather than on its side.

**ffmpeg is the last resort**, used only for the formats QuickTime never learned:
Matroska, WebM and WMV. If you have none of those, you never need it. When the
fallback is used the report says so:

```
     updated  holiday.mp4 (frame at 1.50s)
     updated  archive.mkv (frame at 1.50s, via ffmpeg)
```

If a file needs ffmpeg and it is not installed, only that file fails — everything
macOS can read is still done — and the message tells you to `brew install ffmpeg`.

### Disk space

Each icon is stored in its file's resource fork, so it does cost real bytes. Measured
on a detailed 1080p frame:

| `--icon-size` | per file |
|------|------|
| 256 | ~20 KB |
| **512** (default) | **~44 KB** |
| 1024 | ~100 KB |

Finder's icon slider tops out at 512pt, so the default covers every view without
storing pixels nobody sees. Drop to `--icon-size 256` for a large library, raise it if
you live in huge-icon view. (For reference, an unbounded full-resolution frame costs
about 180 KB a file.)

### If Finder keeps showing the old thumbnail

Finder caches aggressively. The tool touches the containing directory to prod it; if
a stale thumbnail persists, `killall Finder`.

## Development

```sh
uv sync
uv run pre-commit install   # ruff check, ruff format and hygiene hooks on every commit
uv run pytest
```

Linting and formatting are both ruff, run through pre-commit; CI runs the same hooks,
so `pre-commit run --all-files` is the whole check locally.

Tests run against real videos generated by `ffmpeg` and assert on the real
`com.apple.FinderInfo` flag, the real stored icon pixels, and real resource fork
sizes — nothing that matters here is mocked. Proportions are checked by putting a
known square in the frame and measuring whether it is still square in the icon.

Two things to know before changing this code:

- AppKit's `setIcon:forFile:` takes an internal IconServices lock and **deadlocks when
  called from several threads at once**. Frame extraction is parallel; icon writing is
  deliberately serial, and `test_icon_writes_never_happen_on_a_worker_thread` holds
  that line.
- PyObjC has no type metadata for the `CGImageRef` AVFoundation returns, so it arrives
  as an untyped pointer and is passed straight into `NSBitmapImageRep`. It works, but
  it is the fragile seam; `tests/test_backends.py` pins it.
