illustration

illustration — find existing images to illustrate narrated video.

A façade + (future) agentic layer for cross-modal text-to-image retrieval: given narration text, retrieve fitting images from stock / open-media corpora. It is not an image generator.

Quick start (no API key needed — Openverse is the default source):

>>> import illustration
>>> hits = illustration.search("a stormy harbour at dusk", n=10)
>>> hits[0].url, hits[0].license, hits[0].cacheable
('https://...', 'by-sa', True)

The first argument is the query; everything else is keyword. search returns a list of ImageResult (the normalized, license-carrying result schema). Results are cached (SHA-256 content-addressed), so an identical second call is free.

Adding a provider is open-closed — subclass RetrievalSource and register_source() it; the façade is untouched.

See misc/docs/design/illustration_design.md for the full design.

class illustration.BeatSelection(*, beat_index: int, chosen: ImageResult | None, relevance: float = 0.0, coherence: float = 0.0, redundancy: float = 0.0, forced_duplicate: bool = False, n_candidates: int = 0)[source]

The chosen image (and why) for one beat in a sequence.

model_config = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class illustration.Budget(max_iter: int = 3, max_search_calls: int = 8, max_caption_calls: int = 12, max_judge_calls: int = 8, accept_threshold: float = 0.62, correct_min_score: float | None = None, select_max_k: int = 3, select_rel: float = 0.9, min_score: float | None = None, max_cost: float | None = None, cost_estimator: Callable[[str, dict], float] | None = None)[source]

Hard loop bounds — the safety net, enforced in controller code (R2).

Counts and iterations are the deterministic, provider-independent caps. The optional cost_estimator / max_cost overlay lets a caller add a money ceiling on top; with no estimator the call/iteration caps alone bound spend.

accept_threshold: float = 0.62

Rubric overall (0-1) at/above which a judged candidate is accepted.

correct_min_score: float | None = None

Optional absolute relevance floor below which a Correct-graded dominant candidate is escalated to the VLM judge instead of auto-accepted. None (default) keeps the cheap fast-path; set it (on the reranker’s score scale) when a dominant-but-weak top must still be rubric-verified.

cost_estimator: Callable[[str, dict], float] | None = None

(call_type, info) -> cost estimate; call_type in {“search”, “caption”, “judge”}.

max_cost: float | None = None

Optional money ceiling overlay. The count/iteration caps above are the strict hard bound; max_cost is a soft ceiling — the run halts once accrued est_cost reaches it, so the one call already decided upon may push the total slightly past max_cost (effective bound ≈ max_cost + one call’s estimate).

max_search_calls: int = 8

Cap on individual (query, source) search requests across the whole run.

min_score: float | None = None

Optional absolute relevance floor for ir.select abstention. None = relative-only (the loop’s quality bar lives in the rubric, which is 0-1).

select_max_k: int = 3

ir.select tuning for the grade (conservative selector).

class illustration.Candidate(*, result: ImageResult, score: float | None = None, caption: str | None = None, rubric: RubricScore | None = None, rationale: str | None = None)[source]

One scored (and optionally inspected) candidate.

model_config = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

property quality: float

the rubric overall if judged, else 0.

Type:

A 0-1 comparable quality

exception illustration.CurateDependencyError(missing: list[str] | None = None, *, extra: str = 'curate', purpose: str = 'agentic curation')[source]

An optional Layer-2 (agentic curation) dependency is not installed.

The message names the missing packages and the extra that provides them, so the failure is actionable (e.g. pip install 'illustration[curate]').

class illustration.CurationResult(*, beat: str, best: Candidate | None, accepted: bool, grade: str, reason: str, candidates: list[Candidate] = <factory>, trace: list[IterationRecord] = <factory>, spend: dict = <factory>)[source]

The outcome of curating one beat.

model_config = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class illustration.Grade(value)[source]

CRAG’s three-way retrieval grade (R2 §1).

exception illustration.IllustrationError[source]

Base class for every error raised by illustration.

class illustration.ImageResult(*, provider: str, id: str, url: str, thumbnail_url: str | None = None, width: int | None = None, height: int | None = None, title: str | None = None, description: str | None = None, tags: list[str] = <factory>, license: str | None = None, license_url: str | None = None, attribution: str | None = None, source_page_url: str | None = None, author: str | None = None, author_url: str | None = None, cacheable: bool = False, avg_color: str | None = None, query: str | None = None, score: float | None = None, raw: dict[str, ~typing.Any]=<factory>)[source]

One normalized image hit from any provider.

The first eight fields plus cacheable are the cross-provider minimum the design guarantees; the rest are populated when a provider supplies them.

model_config = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

to_search_hit()[source]

Adapt to an ir.SearchHit for Layer-2 cross-provider fusion.

Imported lazily so import illustration never requires ir.

class illustration.InspectReport(*, provider: str, id: str, mode: str, caption: str | None = None, rubric: RubricScore | None = None, rationale: str | None = None)[source]

A VLM inspection of one candidate (caption or judge mode; R2 INSPECT).

model_config = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class illustration.IterationRecord(*, iteration: int, queries: list[str], n_candidates: int, n_passed: int, grade: str, action: str, best_score: float | None = None, search_calls: int = 0, caption_calls: int = 0, judge_calls: int = 0, notes: str = '')[source]

One iteration of the loop — the run-log R2 requires for observability.

model_config = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

exception illustration.MissingCredentialError(provider: str, *, env_var: str | None = None, console_url: str | None = None)[source]

A source needs an API key that could not be resolved.

The message tells the user exactly what to do; key values are never logged.

class illustration.PrefilterReport(*, provider: str, id: str, passed: bool, reasons: list[str] = <factory>, values: dict[str, float]=<factory>)[source]

Per-candidate pre-filter verdict (the R2 PREFILTER stage record).

model_config = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class illustration.PrefilterResult(passed: list[ImageResult], reports: list[PrefilterReport])[source]

The outcome of pre-filtering a candidate set.

property dropped: list[PrefilterReport]

Reports for the candidates that failed at least one check.

exception illustration.ProviderError(provider: str, message: str, *, status: int | None = None)[source]

A provider’s HTTP API returned an error or an unusable response.

exception illustration.RateLimitError(provider: str, message: str, *, status: int | None = None)[source]

A provider returned HTTP 429 (rate limit exceeded).

exception illustration.RerankDependencyError(missing: list[str] | None = None)[source]

The optional local-rerank dependencies are not installed.

The message names the missing packages and the extra that provides them.

class illustration.RetrievalSource(*, session: Any = None)[source]

Abstract base for a pure image-search provider.

Subclasses set the class attributes below and implement _items() and _normalize() (and _auth_headers() if the provider needs a key). search() and raw_search() are template methods — do not override them: they enforce credential checks, canonical→native translation, pagination (capped by MAX_PAGES), and per-item normalization that skips rather than fails on a malformed item. Override a hook, not the template, so a provider can never silently lose those guarantees.

endpoint: str = ''

Search endpoint URL. Required.

fixed_params: Mapping[str, Any] = mappingproxy({})

Constant native params sent on every request (e.g. an API mode/format).

info: SourceInfo = SourceInfo(name='', description='', requires_key=False, homepage=None, default_cacheable=True, license_note='', rate_limit='', tags=())

Static metadata (a per-instance one is synthesized in __init__ if unset).

max_per_page: int = 20

Hard cap on results per page this provider allows.

min_per_page: int = 1

Floor on results per page this provider allows. Default 1 (no floor); raise it for a provider that rejects a small page (Pixabay’s documented minimum is 3), so search(q, n=1) asks for a page the API accepts and the extra rows are trimmed by the n slice rather than 400-ing.

name: str = ''

Registry key, e.g. "openverse". Required.

page_param: str = 'page'

Native name of the page-number parameter.

param_map: Mapping[str, Any] = mappingproxy({})

Canonical→native parameter spec (see illustration.translation). Immutable empty default so subclasses never share one mutable dict.

per_page_param: str = 'page_size'

Native name of the results-per-page parameter.

query_param: str = 'q'

Native name of the free-text query parameter.

Hit the endpoint with zero translation — the deepest escape hatch.

native_params are passed through verbatim as the provider’s own query parameters; the raw decoded JSON response is returned.

search(query: str, *, n: int = 10, api_key: str | None = None, native_params: Mapping[str, Any] | None = None, **canonical: Any) list[ImageResult][source]

Search query and return up to n normalized ImageResult.

canonical are façade-canonical filters (see the façade search and the design doc §2); each is translated to the provider’s native param via param_map, degrading gracefully where unsupported. native_params are raw provider-native params (the escape hatch) merged last, overriding translated ones.

class illustration.RubricScore(*, subject: float = 0.0, action: float = 0.0, setting: float = 0.0, mood: float = 0.0, style: float = 0.0, quality: float = 0.0, overall: float = 0.0, rationale: str = '', parsed: bool = True)[source]

A pointwise VLM judgement of one candidate (R2 §4 rubric dimensions).

model_config = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

recompute_overall() RubricScore[source]

Set overall to the mean of the six rubric dimensions.

class illustration.SearchCache(store: MutableMapping[str, Any] | None = None)[source]

A thin read/write facade over an injectable MutableMapping store.

Values are stored as a small JSON envelope {schema, source, query, stored_at, results: [ImageResult.model_dump(), ...]}.

get(source: str, query: str, params: Mapping[str, Any]) list[ImageResult] | None[source]

Return cached results for the key, or None on a miss.

put(source: str, query: str, params: Mapping[str, Any], results: Iterable[ImageResult]) str[source]

Store results under the key; return the key.

class illustration.SelectionBody(*, beat: str, beat_index: int, source: str, selected: _CandidateRef | None = None, candidates: list[_CandidateRef] = <factory>, forced_duplicate: bool = False, reason: str | None = None)[source]

The typed body of an illustration selection annotation (lacing body schema).

model_config = {'extra': 'forbid'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class illustration.SequenceResult(*, beats: list[str], selection: SequenceSelection)[source]

The result of curating a whole sequence of beats.

model_config = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class illustration.SequenceSelection(*, selections: list[BeatSelection] = <factory>, objective: float = 0.0, notes: list[str] = <factory>)[source]

One image chosen per beat, optimized for the cross-shot objective.

property chosen: list[ImageResult | None]

The chosen image per beat, in order (None where a beat was empty).

model_config = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class illustration.SourceInfo(name: str, description: str = '', requires_key: bool = False, homepage: str | None = None, default_cacheable: bool = True, license_note: str = '', rate_limit: str = '', tags: tuple[str, ...]=<factory>)[source]

Static, human-facing metadata about a source (for discovery + the gate).

class illustration.SourcesView[source]

A live Mapping over the registry with dict- and attribute-access.

Attribute access (sources.openverse) is a convenience; dict access (sources["openverse"]) is the canonical form and the only one that works for a source whose name collides with a Mapping method (get, keys, values, …).

exception illustration.UnknownSourceError(name: str, known: list[str] | None = None)[source]

A source name was requested that is not in the registry.

illustration.blur_check(*, min_variance: float = 100.0) Callable[[ImageResult, Callable[[], Any]], CheckOutcome][source]

Reject blurry images via variance of the Laplacian (R2 §3; OpenCV-free).

The 3x3 Laplacian is applied with NumPy (no OpenCV) on the grayscale image; a low response variance means few sharp edges, i.e. blur.

illustration.brightness_check(*, min_brightness: float = 0.1, max_brightness: float = 0.95) Callable[[ImageResult, Callable[[], Any]], CheckOutcome][source]

Reject near-black / blown-out images. Mean luminance, normalized to [0, 1].

illustration.cache_dir(*, ensure: bool = False) Path[source]

Directory for regenerable caches (the default search-result store).

illustration.check_requirements(provider: str, *, api_key: str | None = None) str | None[source]

Return the key for provider, raising if a required key is missing.

A provider with no entry in PROVIDER_ENV_VARS needs no key and returns None. Otherwise a missing key raises MissingCredentialError.

illustration.check_rerank_requirements() None[source]

Raise RerankDependencyError if the rerank extra isn’t installed.

illustration.curate(beat: str, *, sources: str | list[str] | None = None, n: int = 12, budget: Budget | None = None, expander: Callable[[str], Sequence[str]] | None = None, refiner: Callable[[str, str], str] | None = None, grader: Callable[[Sequence[ImageResult], Any], Grade] | None = None, describe: Callable[[Any, str], str] | None = None, scorer: Callable | None = None, checks: Sequence[Callable[[ImageResult, Callable[[], Any]], CheckOutcome]] | None = None, model: str | None = None, search_fn: Callable | None = None, fetch: Callable | None = None) CurationResult[source]

Curate the single best image for a narration beat via the CRAG loop.

Parameters:
  • beat – The narration beat / scene description to illustrate.

  • sources – Source name(s) to search, or None for the default set.

  • n – Candidates requested per source per query (recall width).

  • budget – Hard loop bounds (defaults to Budget).

  • refiner (expander /) – Query generation / refinement seams (default: aix).

  • grader(results, selection) -> Grade (default score_grade()).

  • describe – VLM (image, prompt) -> text seam (default: aix).

  • scorer – SigLIP-style (beat, results) -> scores reranker. None uses the local SigLIP scorer when its deps are present, else falls back to the rank-fused order.

  • checks – Classical-CV pre-filter checks (default: dependency-aware set).

  • model – LLM model id passed to the default expander / refiner / describe.

  • search_fn(query, *, source, n, ...) -> [ImageResult] (default: illustration.search()). Inject a stub to test offline.

  • fetch – Image fetch override for the pre-filters (test double).

Returns:

A CurationResult — the accepted (or best-so-far) candidate, the full candidate set, the per-iteration trace, and the spend accounting.

illustration.curate_sequence(beats: Sequence[str], *, sources: str | list[str] | None = None, n: int = 12, per_beat: Callable[[str], Sequence[ImageResult]] | None = None, **select_kwargs: Any) SequenceResult[source]

Curate a whole sequence: gather a candidate pool per beat, then select.

per_beat produces the candidate pool for one beat — a sequence of ImageResult (default: a recall + SigLIP rerank via illustration.search(), so pools carry relevance .score). For the full per-beat CRAG loop, unwrap the loop’s Candidate envelopes: per_beat=lambda b: [c.result for c in illustration.curate(b).candidates]. Remaining keyword args pass through to select_sequence().

illustration.default_search_store() MutableMapping[str, Any][source]

A JsonFiles store under <cache_dir>/search (created on demand).

illustration.default_sources() list[str][source]

The default source set (config DFLT_SOURCES), filtered to registered.

Falls back to all registered sources if none of the configured defaults are present, so the façade always has something to query.

illustration.expand_query(beat: str, *, n: int = 3, expander: Callable[[str], Sequence[str]] | None = None, model: str | None = None, include_verbatim: bool = True) list[str][source]

Expand a narration beat into a deduped list of image-search queries.

The verbatim beat is included first by default (so the literal phrasing is never lost), followed by the expander’s suggestions. expander defaults to an aix.prompt_func()-backed generator (built lazily); inject a beat -> [query, ...] callable to override it or to test offline.

illustration.export_otio(store: Any, target: str | None = None) bytes | None[source]

Export the annotation store to OpenTimelineIO (needs lacing[otio]).

Thin passthrough to lacing’s OTIO adapter, so selections can flow into video tools. Returns the bytes when target is None, else writes the file.

illustration.get_source(name: str) RetrievalSource[source]

Return the registered source named name (raises if unknown).

illustration.hamming_distance(a: int, b: int) int[source]

Number of differing bits between two perceptual hashes.

illustration.inspect_candidate(query: str, result: ImageResult, *, mode: str = 'caption', describe: Callable[[Any, str], str] | None = None, model: str | None = None, max_tokens: int | None = None) InspectReport[source]

Inspect one candidate: a cheap caption (default) or a full rubric judge.

mode="caption" (the Correct-grade path) produces a one-sentence caption from the thumbnail; mode="judge" (the Ambiguous path) delegates to judge_candidate(). describe defaults to a lazy aix.describe_image() bound to model and capped at max_tokens (default DFLT_CAPTION_MAX_TOKENS).

illustration.judge_candidate(query: str, result: ImageResult, *, describe: Callable[[Any, str], str] | None = None, model: str | None = None, max_tokens: int | None = None) InspectReport[source]

Score one candidate against query with a pointwise VLM rubric.

Uses the full-resolution image for accuracy. describe defaults to a lazy aix.describe_image() capped at max_tokens (default DFLT_JUDGE_MAX_TOKENS). The reply is parsed into a RubricScore (overall = mean of the six dimensions); an unparseable reply yields a neutral score flagged parsed=False so the loop can treat it as ambiguous rather than wrongly accept or reject.

illustration.license_allowlist(results: Iterable[ImageResult], *, allow: Iterable[str] | None = None) list[ImageResult][source]

Keep only results whose license is on the allowlist (R3’s license gate).

The mandatory per-file license-verification gate for commercial-adjacent use: aggregators disclaim license accuracy, so callers should gate on a known-good set. Both sides are run through illustration.licensing.normalize_license() first, so a provider’s own spelling (cc-by-sa-4.0 from Commons, Pixabay License) matches the canonical code without the allowlist having to enumerate every dialect — and without ever dropping an nc/nd restriction. Results with no license are dropped (unknown == not allowed).

>>> a = ImageResult(provider="p", id="1", url="u", license="cc0")
>>> b = ImageResult(provider="p", id="2", url="u", license="by-nc")
>>> c = ImageResult(provider="p", id="3", url="u", license=None)
>>> [r.id for r in license_allowlist([a, b, c])]
['1']
>>> [r.id for r in license_allowlist([a, b, c], allow={"by-nc"})]
['2']

Provider dialects pass the same gate:

>>> w = ImageResult(provider="wikimedia", id="4", url="u", license="cc-by-sa-4.0")
>>> p = ImageResult(provider="pixabay", id="5", url="u", license="Pixabay License")
>>> nd = ImageResult(provider="wikimedia", id="6", url="u", license="cc-by-nd-4.0")
>>> [r.id for r in license_allowlist([w, p, nd])]
['4', '5']
illustration.list_sources() list[str][source]

Sorted names of all registered sources.

illustration.make_param_translator(param_map: Mapping[str, Any], *, on_unsupported: str = 'ignore', source_name: str = '') Callable[[Mapping[str, Any]], Tuple[dict, list]][source]

Build a translator from a param_map (see module docstring).

on_unsupported governs what happens when a canonical param has no native equivalent: 'ignore' (drop silently, the graceful default), 'warn' (drop + warnings.warn()), or 'raise' (raise ValueError). Parameters whose value is None are skipped entirely (an unset filter).

illustration.make_phash_hasher(*, field: str = 'thumbnail_url', fetch: Callable | None = None) Callable[[ImageResult], int | None][source]

A pHash hasher that fetches each result’s image once (cached for the pass).

Returns result -> int | None (None when the image can’t be fetched or Pillow/NumPy aren’t installed), suitable as select_sequence()’s hasher seam. The fetch is content-deduped within the pass.

illustration.make_siglip_scorer(*, model: str = 'google/siglip2-base-patch16-224', cache: MutableMapping[str, Any] | None = None, device: str | None = None, image_field: str = 'thumbnail_url') SiglipScorer[source]

Build a SigLIP SiglipScorer (raises if the extra is missing).

illustration.normalize_license(value: str | None) str | None[source]

Fold a provider’s licence spelling onto one canonical, comparable code.

Lower-cases, unifies separators to -, strips a trailing version and a leading cc-, then applies LICENSE_ALIASES. Returns None for None/blank — an absent licence is never a code.

>>> normalize_license("by-sa"), normalize_license("cc0"), normalize_license("CC0 1.0")
('by-sa', 'cc0', 'cc0')
>>> normalize_license("Pexels License"), normalize_license("  BY  ")
('pexels-license', 'by')
>>> normalize_license("public domain"), normalize_license("cc-by-3.0")
('pdm', 'by')
>>> normalize_license("cc-0")  # the version strip would otherwise eat "-0"
'cc0'
>>> normalize_license("")  is None
True
illustration.nsfw_check(*, max_prob: float = 0.5, classifier: Callable[[Any], float] | None = None) Callable[[ImageResult, Callable[[], Any]], CheckOutcome][source]

Hard-drop unsafe images (R2: NSFW is a non-negotiable drop).

classifier is image -> nsfw_probability; it defaults to the Apache-2.0 Falconsai/nsfw_image_detection ViT (torch/transformers — the [rerank] extra). Fails closed: a candidate whose image can’t be fetched or classified is dropped, never passed.

illustration.package_version() str[source]

The installed package version, or '0+unknown' if not installed.

>>> isinstance(package_version(), str)
True
illustration.persist_sequence(result: Any, *, store: Any = None, actor: str = 'agent:illustration-curate', activity: str = 'infer', at_time: Any = None) Any[source]

Persist a SequenceResult as lacing annotations.

One selections-tier annotation per beat (machine choices), keyed on the ordinal beat timeline. Returns the store (a fresh lacing.MemoryStore when store is None). at_time (a lacing.RationalTime) overrides the provenance timestamp — pass it for deterministic ordering in tests; defaults to wall-clock RationalTime.now().

illustration.phash(image, *, hash_size: int = 8, highfreq_factor: int = 4) int[source]

A DCT perceptual hash of a PIL image, as a hash_size**2-bit integer.

The standard pHash: resize to grayscale, take the low-frequency DCT block, threshold against its median (excluding the DC term), pack into bits. Implemented with a NumPy DCT matrix so it needs only Pillow + NumPy.

illustration.prefilter(results: Sequence[ImageResult], *, checks: Sequence[Callable[[ImageResult, Callable[[], Any]], CheckOutcome]] | None = None, field: str = 'thumbnail_url', fetch: Callable | None = None) PrefilterResult[source]

Run checks over results, keeping only candidates that pass all.

Checks run cheapest-first and short-circuit on the first failure, so a candidate dropped on its (free) metadata never triggers an image fetch. Images are fetched at most once per URL (in-memory, for this call only); fetch overrides the fetch function (a test double avoids the network). checks defaults to default_checks() (core CV + NSFW where its deps are available).

Returns a PrefilterResult with the surviving passed results and a PrefilterReport per input candidate.

illustration.record_override(store: Any, beat_index: int, chosen: ImageResult | None, *, beat: str | None = None, actor: str = 'user:director', reason: str | None = None, at_time: Any = None) Any[source]

Append a director override for beat_index (a new, superseding annotation).

Never mutates the machine’s annotation — the override is a fresh annotation on the same beat, with provenance.was_derived_from pointing at the most-recent prior selection (STAM-style append-only layering). Returns the new annotation.

illustration.refine_query(beat: str, critique: str, *, refiner: Callable[[str, str], str] | None = None, model: str | None = None) str[source]

Refine a query for beat given a short critique of the last round.

refiner defaults to an aix.prompt_func()-backed refiner (lazy); inject a (beat, critique) -> query callable to override or test offline. Falls back to the verbatim beat if the refiner errors or returns nothing.

illustration.register_source(source: RetrievalSource, *, name: str | None = None) RetrievalSource[source]

Register a source instance under name (default source.name).

Returns the source, so it can be used as SRC = register_source(MySource()).

illustration.render_sequence_video(selections: Any, *, saveas: str, durations: float | Sequence[float] = 4.0, narration_audio: str | None = None, fps: int = 30, style: str = 'push', output_aspect: float | None = None, image_loader: Callable[[ImageResult], Any] | None = None, render: Callable[[...], Any] | None = None) Any[source]

Render chosen images into a single Ken-Burns film via burns.

selections may be a SequenceResult, a SequenceSelection, or a plain list of ImageResult. Each image gets an auto motion path (burns.ken_burns_path, alternating push/pull for rhythm) and its durations slice; narration_audio (a pre-built track) is muxed in. Beats with no chosen image are skipped.

Seams: image_loader fetches an image to a PIL image (default: the shared cached fetch — burns decodes PIL, not URLs); render is the renderer (default: burns.ken_burns_film) — inject a stub to test without ffmpeg. Returns whatever render returns (the output path for the default).

illustration.requires_credentials(provider: str) Callable[source]

Decorator separating credential-checking from a function’s business logic.

Runs check_requirements() for provider before the wrapped function body, so the function never inlines key handling. (The built-in sources call check_requirements() directly; this decorator is the functional-style equivalent for Layer-2 helpers.)

>>> @requires_credentials("pexels")
... def fetch(): return "ok"
>>> with using_credentials(pexels="k"):
...     fetch()
'ok'
illustration.rerank(query: str, results: Sequence[ImageResult], *, scorer: Callable[[str, Sequence[ImageResult]], Sequence[float]] | None = None, descending: bool = True) list[ImageResult][source]

Re-score results against query and return them sorted by score.

Each returned ImageResult is a copy with .score populated. scorer defaults to the SigLIP scorer (needs the [rerank] extra); inject any (query, results) -> scores callable to use a different model or a test double. An empty results returns [] without loading a model.

illustration.resolve_api_key(provider: str, *, api_key: str | None = None) str | None[source]

Resolve the API key for provider by precedence, or None if absent.

Does not raise — callers that require a key use check_requirements(). Reads are non-interactive (never prompts).

illustration.resolve_selection(store: Any, beat_index: int) dict | None[source]

The active selection body for a beat — the latest annotation wins.

Resolves machine choice vs. director override by provenance timestamp, so a later override supersedes the machine’s choice without deleting it.

illustration.resolved_selections(store: Any) dict[int, dict][source]

The active selection body per beat index (resolved over all overrides).

illustration.score_grade(results: Sequence[ImageResult], selection: Any) Grade[source]

The default, model-free grader — scale-robust via ir.select structure.

Maps the conservative selection’s shape (not an absolute score) onto CRAG’s grade: nothing/abstained → Incorrect; a single dominant pick → Correct; several comparable picks → Ambiguous. Because it reads relative structure, it works regardless of the reranker’s score magnitude — the absolute quality bar lives in the rubric (0-1), applied only on the Ambiguous path.

illustration.search(query: str, *, n: int = 10, source: str | list[str] | None = None, orientation: str | None = None, size: str | None = None, safe: bool = True, license_type: str | None = None, color: str | None = None, content_type: str | None = None, license_allow: bool | Iterable[str] = False, rerank: bool | Callable = False, provider_params: Mapping[str, Mapping[str, Any]] | None = None, api_key: str | None = None, cache: bool | SearchCache = True, refresh: bool = False, **provider_kwargs: Any) list[ImageResult][source]

Search for up to n images matching query from one or more sources.

Parameters:
  • query – The free-text query (first positional; required).

  • n – Number of results wanted per source (default DFLT_N).

  • source – A source name, list of names, or None for the default set.

  • orientationlandscape | portrait | square.

  • sizelarge | medium | small (minimum-size filter).

  • safe – Exclude mature content where the provider supports it (default True).

  • license_typecommercial | all-cc | modification | all (honored by providers with license filtering, e.g. Openverse).

  • color – A named color or #hex (Pexels, Pixabay).

  • content_typephoto | illustration | vector (Openverse, Pixabay; providers map/skip values they don’t support).

  • license_allow – License gate (R3). False (default) = no gate; True = keep only commercial-safe licenses (CC0/PD/BY/BY-SA + Pexels); an iterable of license codes = keep only those. Aggregators disclaim license accuracy, so gate when commercial use matters.

  • rerank – Local cross-modal precision rerank (R1). False (default) = off; True = SigLIP-2 (needs the illustration[rerank] extra); a (query, results) -> scores callable = a custom scorer. Applied to the assembled results, which it re-scores (populating .score) and sorts. Use the recall→rerank pattern: search(q, n=50, rerank=True)[:10].

  • provider_params – Per-source native params, e.g. {"pexels": {"color": "blue"}} — used when fanning out to multiple sources so each gets the right native overrides.

  • api_key – An explicit API key. Single-source only — raises if combined with multiple sources; use using_credentials() for keyed fan-out.

  • cacheTrue to use the default cache, False to bypass, or a SearchCache instance to inject one.

  • refresh – If True, ignore any cached entry and re-fetch (then re-store).

  • **provider_kwargs – Flat native params (escape-hatch rung 3a). Single- source only — raises if combined with multiple sources; use provider_params={source: {...}} for fan-out.

Returns:

for multiple sources the per-source lists are concatenated (up to n × len(sources)) and Layer-2 adds rank fusion via ir.

Return type:

A list of ImageResult. n is per source

>>> isinstance(search.__doc__, str)
True
illustration.search_cache_key(source: str, query: str, params: Mapping[str, Any]) str[source]

Content-addressed SHA-256 key for (source, query, params).

params should be the normalized request parameters (the canonical args actually sent, including n), so two calls that differ only cosmetically share a key.

illustration.select_sequence(per_beat_candidates: Sequence[Sequence[ImageResult]], *, relevance: Callable[[ImageResult], float] | None = None, embed: Callable[[Sequence[ImageResult]], Sequence[Any]] | None = None, hasher: Callable[[ImageResult], int | None] | None = None, shortlist: Callable[[Sequence[ImageResult]], Sequence[ImageResult]] | None = None, alpha: float = 0.3, beta: float = 0.5, phash_threshold: int = 6) SequenceSelection[source]

Choose one image per beat optimizing relevance + coherence − redundancy.

A greedy left-to-right pass: at each beat, pick the candidate maximizing rel + α·coherence(prev) β·max_redundancy(chosen), excluding any near-duplicate (pHash Hamming < phash_threshold) of an already-chosen image. If every candidate for a beat is a near-duplicate, the constraint is relaxed for that beat and the choice is flagged forced_duplicate.

Seams (all default to the lean in-house / M2b path, injectable for tests or upgrades): relevance (default: candidate .score or 0), embed (default: cached SigLIP embeddings via [rerank]; coherence/redundancy are skipped when unavailable), hasher (default: in-house DCT pHash; dedup is skipped when Pillow/NumPy are unavailable), shortlist (optional per-beat pre-filter, e.g. an apricot submodular representative set).

illustration.to_search_hit(result: ImageResult)[source]

Map an ImageResult to an ir.SearchHit for Layer-2 fusion.

The bridge into the ir retrieval substrate so the agentic layer can ir.fuse_hits across providers. ir is imported here, not at module top, to keep the base façade dependency-light. Identity follows ir’s (source, artifact_id) keying: source is the provider and artifact_id is the provider-native id. The image URL is placed under the path metadata key so SearchHit.pointer (which scans ir.base.POINTER_KEYS) resolves to it; the full normalized result rides along in metadata too.

score is 0.0 for any hit not yet reranked (Layer 1 leaves ImageResult.score as None) — rely on rank, not magnitude, until a Layer-2 reranker populates it; ir.fuse_hits (RRF) is rank-based, so this is correct for fusion.

illustration.to_walkthru_document(selections: Any, *, narration: Sequence[str] | None = None, durations: float | Sequence[float] = 4.0, doc_id: str = 'illustration-storyboard', title: str | None = None) Any[source]

Build a walkthru.DemoDocument from the selections (pure data, no render).

Emits one b-roll beat per chosen image (poster = the image URL, timing from durations) and, if narration is given (one string per beat), a narration track anchored to each beat. The consumer then runs walkthru.realize_narration / pace_steps_to_narration / its renderer. Beats with no chosen image are skipped. Needs the [video] extra.

illustration.unregister_source(name: str) None[source]

Remove a source from the registry (no error if absent).

illustration.using_credentials(**provider_keys: str)[source]

Bind per-request provider API keys for the duration of the with block.

Falsy values are ignored (so an optional request header passes straight through). Bindings nest: an inner block overlays the outer.

>>> with using_credentials(pexels="k1"):
...     with using_credentials(pexels="k2"):
...         inner = resolve_api_key("pexels")
...     outer = resolve_api_key("pexels")
>>> inner, outer
('k2', 'k1')