PosterFrameKit is a native Swift package that selects one strong individual frame from a video for use as a poster or thumbnail. It runs locally on Apple platforms, remains free of external package dependencies in version 1.x, and can evaluate frames from custom decoders through a public protocol.
The package has no knowledge of any consuming application, media library, or third-party decoder. Those integrations remain at the application boundary.
A frame at a fixed position is quick to generate but often unsuitable:
- black fade, intro, or credits
- motion blur or crossfade
- title card instead of the subject
- burned-in subtitles
- flat, low-contrast, or technically damaged images
Existing tools usually solve a different problem: they extract frames, create a summary from multiple scenes, or bring large dependencies such as OpenCV and Python. PosterFrameKit focuses on the narrow task of finding “one good poster frame.”
PosterFrameKit aims to:
- select a reproducible, visually useful frame,
- make the result explainable through metrics and a score,
- remain fast enough for an interactive app when processing short local videos,
- support AVFoundation and custom decoders through the same analysis path,
- treat animation as a first-class profile.
It explicitly does not promise to understand the semantically most important moment in a film. Without a domain-specific model, the “best frame” remains a measurable, calibratable heuristic.
Since macOS 15 and iOS 18, Apple has demonstrated high-quality video-frame
selection using CalculateImageAestheticsScoresRequest, VideoProcessor, and
feature prints. This validates the use case but does not fully replace
PosterFrameKit:
- PosterFrameKit supports systems older than the Vision example APIs.
- A deterministic Accelerate path remains backward-compatible and measurable.
- An animation profile can prioritize differently from general photo aesthetics.
- Custom frame sources support MKV and legacy containers without making the package depend on FFmpeg.
On newer systems, the demo uses Vision as a benchmark reference and also shows
a transparent 50/50 ranking from normalized Vision and PosterFrameKit scores.
Vision's -1...1 range is mapped linearly to 0...1 and averaged with the
PosterFrameKit score. isUtility is visible but is not used as a hidden
penalty. Because the comparison seeks only one winner, Apple's feature-print
diversification for multiple thumbnails is outside its scope.
The library's base quality scoring remains independent of Vision aesthetics. An explicit option on macOS 15+, iOS 18+, tvOS 18+, and visionOS 2+ can use Vision-first aesthetic ranking for at most 24 leading candidates. Older systems and analysis errors preserve the preceding ranking.
The API remains small and system-oriented:
import PosterFrameKit
let result = try await PosterFrameKit.bestFrame(in: videoURL)
let artwork = try await PosterFrameKit.embeddedArtwork(in: videoURL)
var options = PosterFrameOptions(profile: .animation)
options.searchRange = 0.08...0.90
options.maximumFramesExamined = 40
options.excludedRanges = [0.46...0.54]
options.subtitleAvoidance = PosterFrameSubtitleOptions()
options.facePreference = PosterFrameFaceOptions()
options.aestheticPreference = PosterFrameAestheticOptions()
options.outputSize = CGSize(width: 640, height: 360)
let configured = try await PosterFrameKit.bestFrame(
in: videoURL,
options: options
)For already decoded frames,
PosterFrameKit.evaluate(pixelBuffer:profile:) returns the score and metrics
without video sampling. Custom decoders connect to the same selection path
through PosterFrameKit.bestFrame(from:options:).
PosterFrameOptions remains mutable after initialization. Before use,
normalized() therefore creates a validated, canonical copy:
- finite
searchRangebounds are clamped to0...1, excludedRangesare intersected with0...1, sorted, and merged when they overlap, and they must not fully cover the search range,maximumFramesExaminedmust be at least1,outputSizemust be eithernilor contain two finite, positive dimensions, and- custom weights must be finite and nonnegative, include at least one positive
value, and are scaled to sum to
1.
A search range entirely before or after the video collapses to the nearest
boundary timestamp, 0 or 1, respectively. Normalizing an already normalized
result does not change it. Invalid values deterministically produce
PosterFrameError.invalidOptions before a frame source is accessed. The
selection API enforces this boundary internally; the public method additionally
allows early validation.
The result contains at least:
CGImage- the frame's actual
CMTime - normalized overall score
- public individual metrics
Multiple candidates are a useful extension but are not part of the initial single-result contract without demonstrated external demand.
Embedded artwork is an input-level shortcut rather than another selection
policy. embeddedArtwork(in:) reads AVFoundation's normalized common artwork
metadata and returns the first image that ImageIO can decode. It performs no
video seeks, candidate analysis, or scoring. Missing, empty, unsupported, and
malformed artwork items are skipped; an asset metadata failure is still an
error, and cancellation is mapped to PosterFrameError.cancelled.
The API returns CGImage?, not PosterFrameResult?, because container artwork
has no actual video timestamp, quality metrics, or ranking evidence. Callers
choose the policy explicitly: use deliberate artwork when present, otherwise
call a normal bestFrame API. For custom containers such as Matroska, the
application-owned container adapter reads attachments before entering
PosterFrameKit's decoder-independent frame-source path.
general: balanced default weightinganimation: gives more weight to clear lines and handles flat color areas and cel-style composition cautiouslycustom: public weights for controlled special cases
Profiles initially adjust weights and soft thresholds. As few properties as possible are rejected outright so valid black-and-white, night, or sepia scenes are not categorically lost.
Sources/PosterFrameKit/
├── PosterFrameKit.swift
├── PosterFrameKit+EmbeddedArtwork.swift
├── Options.swift
├── PosterFrameProfile.swift
├── PosterFrameWeights.swift
├── FrameMetrics.swift
├── PosterFrameResult.swift
├── PosterFrameSample.swift
├── PosterFrameSubtitleOptions.swift
├── PosterFrameFaceOptions.swift
├── PosterFrameAestheticOptions.swift
├── PosterFrameError.swift
├── PosterFrameCancellation.swift
├── FrameSource.swift
├── Metadata/
│ └── AVFoundationEmbeddedArtworkLoader.swift
├── PosterFrameKit.docc/
├── Sampling/
│ └── CandidatePlanner.swift
├── Sources/
│ ├── AVFoundationFrameSource.swift
│ └── PixelBufferImageConverter.swift
├── Analysis/
│ ├── AnalysisBuffer.swift
│ └── FrameAnalyzer.swift
├── Performance/
│ └── PosterFramePerformanceRecorder.swift
├── Scoring/
│ ├── ProfileWeights.swift
│ └── FrameScorer.swift
├── Selection/
│ ├── CandidateAccumulator.swift
│ ├── CandidateAnalysisDependencies.swift
│ ├── CandidatePool.swift
│ ├── CandidateRefinementPipeline.swift
│ ├── EvaluatedCandidate.swift
│ ├── PosterFrameResultFactory.swift
│ └── RefinedCandidate.swift
├── Face/
│ ├── FaceAnalyzing.swift
│ ├── FaceCandidateReranker.swift
│ └── VisionFaceAnalyzer.swift
├── Aesthetics/
│ ├── AestheticAnalyzing.swift
│ ├── AestheticCandidateReranker.swift
│ └── VisionAestheticAnalyzer.swift
├── Subtitle/
│ ├── SubtitleAnalyzing.swift
│ ├── SubtitleBandHeuristic.swift
│ ├── SubtitleCandidateReranker.swift
│ └── VisionSubtitleAnalyzer.swift
├── Vision/
│ └── VisionRequestCancellation.swift
Application-owned container adapters implement PosterFrameSource and keep
their decoder types and dependencies outside PosterFrameKit. The public custom
source remains serialized until a reproducible external use case demonstrates
that a bounded concurrent contract can improve runtime without compromising
memory, cancellation, actual timestamps, or deterministic ranking.
CandidateAccumulator is deliberately the small stateful boundary shared by
all decoder paths. CandidatePool owns actual-time deduplication, deterministic
top-candidate retention, and decoder-error ordering. The stateless
CandidateRefinementPipeline composes optional face, subtitle, and aesthetic
policies with isolated rollback, while public result construction remains a
separate service. This keeps optional Apple adapters out of the core ranking
domain without introducing a generic service layer or a dependency-injection
framework.
The default path uses AVFoundation and distributes a bounded number of candidates evenly over the configured search range, including both range boundaries. It then removes timestamps inside normalized exclusion ranges. The number actually requested can therefore be lower than the configured maximum. A single candidate normally falls at the range midpoint. If that point is excluded, it uses the midpoint of the widest remaining interval, choosing the earlier interval in a tie.
The current AVAssetImageGenerator path uses exact time tolerances. Larger
tolerances remain a later optimization requiring benchmarks and are not
interpreted as a guarantee that only codec keyframes will be returned.
Every source therefore returns both the requested and actual time. Sampling deduplicates identical actual frames and prevents a decoder with coarse seek points from evaluating the same candidate repeatedly.
URL-based AVFoundation selection distributes contiguous groups of sorted
candidate timestamps over persistent AVAssetImageGenerator instances. The
pool is bounded to four lanes on macOS. iOS, tvOS, and visionOS remain
serialized until reproducible device-specific measurements justify a change.
The macOS limit is also capped by the number of candidates and available
processor capacity.
Decoded samples enter one shared accumulator. Analysis, actual-time
deduplication, scoring, and tie resolution therefore remain common with the
serialized custom-source path. Completion order cannot
change the winner because score and actual time form a deterministic ordering.
PosterFrameSource keeps its small single-frame contract and exposes no batch
API.
On the generated 30-second 1280×720 H.264 fixture, the Apple M1 Pro/macOS
15.7.4 report measured a warm median of 575.95 ms before the change and
233.63 ms with four lanes. The selected time and score were identical. The
historical report's process-lifetime peak RSS is not interpreted as
selection-scoped memory. A non-public
23-minute H.264 validation clip showed the same winner and a warm reduction
from approximately 1.12 s to 0.45 s; this second result is directional only
because the source cannot be redistributed.
The default mode examines all planned unique actual frames. When scores are
identical, the earlier actual timestamp deterministically wins. Early
termination may later be introduced as an explicit fast mode, not as silent
default behavior.
The first version uses a reusable luma buffer with at most 160 pixels on its
long edge and a small number of robust metrics:
| Metric | Purpose |
|---|---|
| Mean brightness | Detect black and white fades |
| Luma variance | Detect flat, low-information images |
| Entropy | Estimate information content |
| Sharpness | Penalize blur and many crossfades |
| Colorfulness | Soft profile signal, not a blanket filter |
| Visual noise | Detect dense, incoherent high-frequency patterns |
The analysis directly reads BGRA, ARGB, RGBA, 8-bit monochrome, and bi-planar
4:2:0 luma/chroma buffers. Unsupported formats return a defined error. The
AVFoundation path converts the transformed, display-ready CGImage to BGRA
once; after that, AVFoundation and custom sources use exactly the same analysis
and scoring logic. Reusable buffers avoid per-candidate allocations. Final
result images share one immutable, thread-safe CIContext rather than
recreating its render state for each conversion.
Version 0.1 measures sharpness with normalized Tenengrad edge energy. Compared with Laplacian variance, it responds directly to strong directional edges and therefore fits the initial animation weighting. Synthetic edge and flat-field tests support the choice; no quality advantage over Laplacian will be claimed before the shared fixture benchmark.
A separate visual-noise estimate measures four-neighbor Laplacian residual
energy and maps only the dense high-frequency end of that signal to 0...1.
The scorer applies a soft penalty capped at 25%. This prevents static-like
patterns from exploiting both entropy and sharpness while leaving ordinary
animation edges below the penalty range. Synthetic fixtures define the metric;
the local clip that exposed the failure is not redistributable benchmark data.
Raw histogram entropy remains a public deterministic metric, but its scoring contribution is structure-qualified. A smooth transition caps the trusted entropy only while coherent Tenengrad strength is below the range produced by ordinary edges; dense visual noise cannot supply that confidence. This keeps the analyzer explainable while preventing an evenly distributed grayscale gradient from outranking clearer structured content merely through histogram coverage.
A fixed central crop is not a universal default. It often removes letterbox bars but can also lose subjects deliberately placed near an edge. The order of work is therefore:
- use the full frame as the correct baseline,
- evaluate simple, robust bar detection,
- offer a central analysis region only as a profile or option decision.
The first 70%-height experiment reduced the analyzed pixels by 30% and direct analysis time by 26–27%, but that stage represented only a few milliseconds of the complete selection. Two reversed 15-run ARM64 batches showed no repeatable end-to-end improvement, and the generated fixture selected a different frame. The experimental implementation was removed rather than becoming public or profile behavior.
Subtitle avoidance, face preference, and aesthetic preference are modeled as separate optional domain policies rather than additional base image metrics:
PosterFrameSubtitleOptionsis the public value object that enables the policy and bounds its candidate count and maximum penalty;PosterFrameFaceOptionsindependently bounds face inspections and the maximum composition bonus;PosterFrameAestheticOptionsbounds Vision aesthetics inspections and the maximum absolute score adjustment;- the base scorer owns only deterministic image-quality metrics;
- internal analyzer ports isolate recognition and OS-model inference from ranking;
- Vision adapters implement those ports with Apple frameworks; and
- the re-ranker composes base candidates, recognition evidence, and the policy without duplicating decoding or scoring.
This boundary keeps the default selection path unchanged. When subtitle, face,
and aesthetic options are nil, PosterFrameKit constructs no recognizer,
retains no leading candidate group, and performs no Vision refinement.
Saliency remains a possible future refinement behind the same boundary, not an
additional responsibility of the base scorer.
The implemented subtitle flow is:
existing ranking → bounded leading group → lower-third recognition
→ internal geometry-weighted capped penalty → deterministic final ranking
The implemented face flow is:
existing ranking → bounded leading group → Vision face rectangles
→ deterministic size/placement/group score → capped bonus → final ranking
The implemented aesthetics flow is:
existing refined ranking → bounded leading group → Vision aesthetics score
→ align aesthetic base while preserving content adjustments → final ranking
Vision image aesthetics pins revision 1 and consumes the same display-oriented
pixel buffers as the base analyzer. Its -1...1 score is normalized to
0...1; the default maximum adjustment of 1 allows it to replace the
deterministic aesthetic base while retaining separate face and subtitle
effects. isUtility remains evidence and is not penalized twice. The default
checks at most 24 candidates. Unsupported OS versions and analysis failures
leave the preceding ranking unchanged; cancellation is propagated.
The face scorer ignores detections covering at most 1% of the image and
extreme close-ups covering at least 65%. It rewards a useful intermediate size,
central composition, and a small capped group signal. A frame without a face
receives neither a bonus nor a penalty. The default option checks at most eight
candidates and caps the bonus at 0.04. Analysis stops when the remaining
candidate cannot overtake the current winner even with that full bonus.
Face detection pins Vision face-rectangle revision 3, consumes existing display-oriented pixel buffers, and never causes another video seek. Failure discards the face stage without disturbing the base ranking, while cancellation is propagated. Strongly stylized faces remain a documented limitation until the reproducible quality set includes animation fixtures.
The adapter first runs VNRecognizeTextRequest in fast mode over the bottom
third. A cheap luma edge-band heuristic does not classify a frame itself; it
only requests an accurate retry when fast recognition found nothing but the
region remains suspicious. This hybrid addresses observed fast-mode misses
without paying accurate-mode cost for every inspected candidate. Recognized
regions are filtered and weighted by coverage, count, centrality, and lower
placement. General text outside the lower third is intentionally ignored, so
most signs remain valid. Credits or signs within the lower third can still be
treated as subtitle-like; this is a documented limitation rather than a hidden
content classifier.
The default refinement retains at most eight candidates and caps the penalty
at 0.08. Because the adjustment can only lower a score, recognition stops
once the next base score cannot beat the best adjusted candidate. If any
recognition call fails, the complete optional stage is discarded instead of
treating that candidate as text-free. Cancellation is always propagated. The
base metrics and PosterFrameResult.score remain unchanged;
subtitlePenalty and adjustedScore expose the refinement separately.
The former, ineffective PosterFrameWeights.subtitlePenalty placeholder was
removed. Subtitle avoidance is not a relative contribution to the base metric
sum and therefore belongs only in PosterFrameSubtitleOptions.
The generated subtitle fixture locks the intended dialogue/credit/sign boundary. A separate checksum-locked live-action calibration renders official English and German subtitle cues over matched Tears of Steel controls. Its first 52-sample run motivated a narrow bright-stroke retry signal that recovered four small outline misses without false positives in the reviewed set. This is still one film and two Latin-script languages, so the option remains disabled by default until broader native burned-in material covers animation, more typography and languages, lower-third signs, credits, and text-free scenes.
When multiple refinements are enabled, face bonuses are applied before subtitle penalties, followed by aesthetic adjustments. Each Vision stage rolls back independently if it fails.
Performance targets are defined per fixture class, not as a universal promise. Measurements include at least:
- time to first result
- direct end-to-end, decode/seek, conversion, and analysis timings
- number of unique actual frames decoded
- peak memory per selection
- cold and warm cache states
Stage timings are instrumented at their actual boundaries. A residual such as “end-to-end minus separately measured analysis” is not labeled decode time. Likewise, a process maximum after fixture generation is not treated as the memory cost of one selection.
PosterFrameKitBenchmark generates its fixture before launching isolated
measurement workers. It reports independent process-cold and process-warm run
groups and samples current resident memory every 2 ms only around the measured
selection. Starting, peak, ending, and peak-increase values are retained for
each run. The filesystem cache remains explicitly uncontrolled. Stage durations
are cumulative work; parallel stages overlap and are never summed to
manufacture an end-to-end result.
A provisional target for a local H.264 video of approximately 30 seconds is a result below 500 ms on a current Apple Silicon Mac. Long videos, legacy codecs, and custom decoders receive separate budgets. Only measured figures appear in the public README.
PosterFrameKit provides no persistent thumbnail or metric cache. The caller owns media identity, file invalidation, storage limits, eviction, and the relationship between cached images and its library. PosterFrameKit may reuse short-lived implementation resources such as immutable rendering contexts, but it does not persist results between selections.
- no video summaries or scene timelines
- no automatic cropping of the final image
- no dedicated container decoder
- no required cloud service or telemetry
- no required ML or Core ML model
- no UI components
PosterFrameKit is intended for release under the MIT license after validation against its public quality and platform criteria. The community version needs:
- reproducible tests using generated or freely licensed videos; the initial
checksum-locked download catalog and silent normalization recipes live under
Benchmarks/Fixtures - traceable benchmark and fixture-fetch scripts
- a results gallery with source and license information
- DocC for the public API
- Semantic Versioning and a changelog
The blog post and public promotion will follow only when the gallery shows that the package finds better poster frames more often than fixed timestamps.