falaw

falaw: agent-friendly Python facade over fal.ai for AI media generation.

Quick start (single-shot generation):

>>> from falaw import generate_image
>>> r = generate_image("a tiger eye, macro, 35mm", quality="fast")
>>> r.first.download(to="./tiger.png")

Directorial workflow (Scene IR + caching):

>>> from falaw import Scene, cast_character, render_scene
>>> sarah = cast_character("Sarah", "mid-30s, dark curly hair")
>>> # ... build a Scene with characters/beats/shots ...
>>> manifest = render_scene(scene)            # caches per-beat
>>> # edit one beat, re-render: only that beat re-fires.

Leave notes for future sessions:

>>> from falaw import journal
>>> journal.note("Sarah's voice clone needs ~10s reference for stability")
class falaw.AccountStatus(ok: bool, locked: bool, unauthorized: bool, status_code: int | None, detail: str, url: str | None, error: str | None)[source]

Outcome of health_check().

detail: str

Server-supplied detail string (best-effort), or ‘’ if unavailable.

error: str | None

repr of the underlying exception when ok is False and the failure isn’t a recognized lock/unauth (e.g. network error).

locked: bool

True iff the response indicated a locked / unverified account.

message_for_user() str[source]

Single human-readable line explaining the status. Stable for logging.

ok: bool

True iff the account responded without an auth/lock error.

status_code: int | None

HTTP status from the probe (None if no HTTP exchange happened).

unauthorized: bool

True iff credentials are missing or invalid.

url: str | None

If actionable (e.g. billing dashboard), URL the user should visit.

class falaw.AreaUsage(name: str, path: str | None, entries: int, bytes: int)[source]

Disk usage of one cache area.

entries counts the area’s own unit — cache entries for manifests, blobs for content, files for assets and url_index — not files on disk, which is why it is reported next to bytes rather than derived from it.

path is None when the area has no directory of its own. That is the case for manifests (scattered across two-character shard directories at the cache root) and for other. It is deliberately not the cache root: a caller reaching for rmtree(usage.area(...).path) would then destroy the content store along with it.

class falaw.Asset(url: str, kind: str, content_type: str = '', width: int = 0, height: int = 0, duration_s: float = 0.0, metadata: dict = <factory>)[source]

A single piece of generated media.

Holds the URL plus minimal typed metadata. download materializes it.

download(*, to: str | None = None) str[source]

Download the asset to a file. Returns the local path.

class falaw.Beat(*, id: str, speaker: str = '', line: str = '', action: str = '', emotion: str = '', shot_id: str = '', duration_s: float | None = None, notes: str = '')[source]

The atomic unit of a scene: who, says what, with what intent.

A Beat that has only action (no line) is a non-verbal beat.

class falaw.CacheUsage(root: str, areas: tuple[AreaUsage, ...])[source]

Where the falaw cache’s disk is going, by area.

The whole point of the breakdown: size_bytes alone cannot tell you whether you are looking at gigabytes of irreplaceable blobs or gigabytes of assets/ copies that cost nothing to regenerate.

area(name: str) AreaUsage[source]

The AreaUsage named name.

Raises:

KeyError – no such area. The valid names are AREA_NAMES.

summary() str[source]

One human-readable line per area, largest first.

property total_bytes: int

Every byte under the cache root.

Equal to a full walk of the root, because other absorbs whatever the named areas do not claim. That identity is the point: a capacity report whose total is “the sum of the areas I thought to enumerate” silently under-reports the moment falaw grows a directory nobody added here, and under-reporting is the one direction a capacity tool cannot afford.

class falaw.CallOutcome(*, index: int, call: CallPlan, status: CallStatus, artifact: 'Artifact' | None = None, error: BaseException | None = None, cache_hit: bool = False, blocked_by: tuple[int, ...] = (), reason: str = '')[source]

What happened to one CallPlan, at its position in the Plan.

index is the call’s position in plan.calls and is the identity a caller retries or re-plans by — a report always carries exactly one outcome per call, in plan order, so index is also the safe key for zipping a Plan against anything the caller built alongside it.

artifact: 'Artifact' | None

The materialized artifact. Set if and only if status == "succeeded".

blocked_by: tuple[int, ...]

Indices of the calls whose non-success blocked this one. () when the call was blocked for a run-level reason rather than a dependency.

cache_hit: bool

Whether the result came from the cache as observed at run time.

Not the same thing as falaw.CallPlan.cache_status, which is a prediction made at plan time and can be wrong in both directions (a concurrent run filled the entry; a hit turned out to be unusable and was re-executed). Run-level cost accounting reads this one.

call: CallPlan

The call this outcome is about — enough to retry it verbatim.

error: BaseException | None

The exception the call raised. Set if and only if status == "failed".

Kept as the exception object rather than a string so the caller can use falaw’s typed hierarchy (falaw.errors) to decide between backing off, switching models, and giving up.

index: int

Position in plan.calls. Stable, and unique within a report.

property ok: bool

Shorthand for status == "succeeded".

reason: str

Human-readable explanation. Required for blocked; free otherwise.

status: CallStatus

"succeeded" / "failed" / "blocked". See the module docstring.

class falaw.CallPlan(*, tool: str, application: str, backend: str = 'fal', arguments: dict, output_kind: ~typing.Literal['image', 'video', 'audio', 'json', 'text', 'binary'], estimated_cost_usd: float | None = None, cache_status: ~typing.Literal['hit', 'miss', 'stale', 'unknown'] = 'unknown', expected_duration_s: tuple[float, float] | None = None, metadata: dict = <factory>, key_extra: dict = <factory>)[source]

A single planned fal call. Pure data — no API contact yet.

application and arguments are the exact tuple cached_call_fal(application, arguments) would take, so a Plan can be cache-checked, executed, or replayed without ambiguity.

application: str

The fal model id that will be invoked (e.g. "fal-ai/flux/dev"). Backend-scoped: what it names depends on backend.

arguments: dict

Keyword arguments to pass to fal. Will be JSON-canonicalized for cache key computation; should be JSON-serializable.

backend: str

Which execution backend execute_plan() dispatches this call to (falaw#15) — see falaw.backends. Defaults to falaw.canonical.DFLT_BACKEND ("fal"), the only backend until a second one lands. Enters the per-call cache key and plan_hash only when it is not the default, so every call falaw has ever planned or cached keeps its exact pre-#15 digest — see falaw.canonical.

property billable_cost_usd: float

Cost that will actually be billed (0 on cache hit, estimate otherwise).

Returns 0.0 (not None) on cache hit or unknown estimate so sums are well-defined; use estimated_cost_usd is None to check unknown status explicitly.

cache_status: Literal['hit', 'miss', 'stale', 'unknown']

Whether the cache will short-circuit this call. "hit" means execute won’t bill, so Plan.total_cost_usd and Plan.cache_hit_savings_usd reflect that.

estimated_cost_usd: float | None

Predicted cost in USD. None when the model has no cost_estimate populated (callers can distinguish “free” from “unknown”).

expected_duration_s: tuple[float, float] | None

(min, max) duration the model can produce, or None if no duration contract is known. Plan-level validators can check that the requested duration fits this range and raise FalDurationOutOfRange before the call instead of letting it silently truncate.

key_extra: dict

Identity beyond the wire arguments — entries that change what the call produces without appearing in arguments. Participates in the per-call cache key AND plan_hash, under omit-if-empty (an empty dict leaves every existing key byte-identical). Unlike metadata, which is deliberately identity-free labelling, putting something here says “a cached result minted without this value must not be reused”. First customer: nw’s Transform impl_version (nw#27) — “same interface, changed behaviour” must miss the cache without renaming anything.

metadata: dict

Free-form labels for downstream consumers. Conventional keys: shot_id, beat_id, character_name, strategy.

output_kind: Literal['image', 'video', 'audio', 'json', 'text', 'binary']

What kind of Artifact this call will produce.

tool: str

High-level tool name — "generate_image", "image_to_video", etc. Distinct from application because one tool may dispatch to several fal models depending on quality tier.

class falaw.Character(*, name: str, description: str = '', reference_image_url: str = '', voice: Voice | None = None, style_notes: str = '')[source]

A reusable character: stable face, stable voice, stable style.

class falaw.ContentRef(content_hash: str, bytes_size: int)[source]

A content-addressed handle on some bytes falaw has materialized.

content_hash is the SHA-256 hex digest of the bytes — the value lacing.Artifact.asset_id is contractually required to hold, and the value that goes into a downstream cache key in place of a URL.

class falaw.CostEstimate(*, kind: Literal['per_call', 'per_image', 'per_second', 'per_token', 'per_megapixel'], amount: float, currency: str = 'USD', notes: str = '', source: str = 'approximate')[source]

Quantitative cost of one fal call against this model.

kind

How the price scales. per_call is the simplest: constant per invocation regardless of size. per_second applies to video / TTS where output length matters. per_image covers batch image gen. per_megapixel covers high-res image generation. per_token covers LLM-style endpoints.

Type:

Literal[‘per_call’, ‘per_image’, ‘per_second’, ‘per_token’, ‘per_megapixel’]

amount

USD (or currency) per unit defined by kind.

Type:

float

currency

ISO 4217 code. "USD" is the only supported value today; the field exists so we can extend without a schema migration.

Type:

str

notes

Free-form caveats — “rounded up to next second”, etc.

Type:

str

source

How the estimate was obtained — "docs", "empirical", "approximate". Lets us flag stale or unverified entries in audits.

Type:

str

class falaw.CostLine(*, kind: str, item_id: str, model_id: str, amount: float, currency: str, note: str = '')[source]

One line item in a scene rollup.

class falaw.CostRollup(*, total_amount: float, currency: str = 'USD', lines: tuple[CostLine, ...] = (), skipped: tuple[str, ...] = ())[source]

Result of estimate_scene_cost().

by_kind() dict[str, float][source]

Sum per kind for quick inspection.

class falaw.Environment(*, name: str, description: str = '', reference_image_url: str = '', time_of_day: str = '', lighting: str = '')[source]

A reusable location/setting.

class falaw.ExecutionReport(outcomes: tuple[CallOutcome, ...] = ())[source]

The result of running a Plan: one CallOutcome per call, in order.

len(report.outcomes) == len(plan.calls) always, including on a run where most calls failed. That invariant is the whole point: a consumer that built something per call (nw builds one skeleton annotation per call) can zip against outcomes and stay aligned. Zipping against a shorter list of successes is the silent mis-pairing this type exists to prevent.

artifacts_or_raise() list['Artifact'][source]

Every artifact in plan order, or re-raise the first failure’s exception.

The bridge back to the plain list[Artifact] contract of falaw.execute_plan(). The exception raised is the original one the call raised, unwrapped — falaw’s typed error hierarchy (falaw.errors) is only useful to a caller if it survives the trip through the executor.

A run with no failures but some blocked calls raises too: the list would otherwise be short, and a short list is exactly the silent mis-pairing this type exists to prevent.

property blocked: tuple[CallOutcome, ...]

Outcomes whose call never ran, in plan order.

property cache_hit_savings_usd: float

Estimated USD not spent because a succeeded call was served from cache.

The run-time counterpart of falaw.Plan.cache_hit_savings_usd (which is a plan-time prediction).

property estimated_spend_usd: float

succeeded calls that were not cache hits.

Estimated, because it sums falaw.CallPlan.estimated_cost_usd — falaw does not read fal’s invoice. Two deliberate exclusions:

  • Cache hits cost nothing, and this reads the observed CallOutcome.cache_hit, not the plan-time prediction.

  • Failed calls are not counted. The vendor may or may not have billed a call that raised, and falaw cannot know which; adding an estimate for it would be inventing a number. Read failed to see how many calls are unaccounted for.

Type:

Estimated USD billed by this run

property failed: tuple[CallOutcome, ...]

Outcomes whose call raised, in plan order.

property has_unknown_costs: bool

True when a call that actually billed has no price estimate.

The run-level twin of falaw.Plan.has_unknown_costs, and the reason estimated_spend_usd must never be read on its own: an unpriced call contributes 0.0 to the sum, so a report reading $0.00 means either “nothing was spent” or “we do not know what was spent”. Those are not the same answer, and a budget gate that cannot tell them apart approves the second one.

property is_complete: bool

True when every call succeeded.

property produced: tuple['Artifact', ...]

The artifacts that were made, in plan order.

Shorter than the Plan when anything failed — deliberately named so it does not read like something to zip a per-call sequence against. Use outcomes for anything positional.

property succeeded: tuple[CallOutcome, ...]

Outcomes that produced an artifact, in plan order.

summary() dict[source]

A small JSON-able digest — counts, spend, and which indices failed.

For logs, telemetry and run records, where the artifacts and exception objects themselves are not serializable.

exception falaw.FalAccountLocked(message: str, *, status_code: int, detail: str = '', body: Any = None, headers: dict[str, str] | None = None, application: str | None = None, url: str | None = None, cause: BaseException | None = None)[source]

fal account is locked / suspended / awaiting verification.

Typical 403 with a body indicating the account is not in good standing. No amount of retrying or model-switching will fix this — the user has to act (verify email, top up billing, contact support).

exception falaw.FalAssetFetchError(message: str, *, url: str, cause: BaseException | None = None)[source]

The bytes behind a fal-served asset URL could not be retrieved.

Raised by falaw.content when content-addressing an artifact fails — the URL 404s (fal deletes expired files permanently), the transfer errors, or the response is empty. It is deliberately loud: returning an Artifact whose asset_id is not the SHA-256 of any bytes would break lacing.Artifact’s content-hash contract and poison every downstream cache key derived from it.

exception falaw.FalBadRequest(message: str, *, status_code: int, detail: str = '', body: Any = None, headers: dict[str, str] | None = None, application: str | None = None, url: str | None = None, cause: BaseException | None = None)[source]

Server rejected the request payload — typical 400 / 422.

exception falaw.FalDurationOutOfRange(message: str, *, model_id: str, requested: float, valid_range: tuple[float, float])[source]

The requested duration is outside what the model can produce.

Raised by Plan/Execute when the caller asks for duration_s that the model’s declared expected_duration_range cannot satisfy. Callers can catch this to split the shot, repeat it, or pick a different model.

exception falaw.FalError[source]

Base for all falaw-raised exceptions.

exception falaw.FalHTTPError(message: str, *, status_code: int, detail: str = '', body: Any = None, headers: dict[str, str] | None = None, application: str | None = None, url: str | None = None, cause: BaseException | None = None)[source]

Wraps an HTTP error from fal.ai with the original status, body, and headers.

Subclasses pick out specific status codes / patterns. Use this base when you want to catch any HTTP failure (e.g. for retry).

exception falaw.FalInsufficientFunds(message: str, *, status_code: int, detail: str = '', body: Any = None, headers: dict[str, str] | None = None, application: str | None = None, url: str | None = None, cause: BaseException | None = None)[source]

Account balance is insufficient — typical 402.

exception falaw.FalModelHung(message: str, *, model_id: str, elapsed_s: float)[source]

A model was queued but never returned — distinct from a network timeout.

Raised by higher-level orchestration that sets a per-call wall-clock budget (e.g. “give up on this lipsync after 5 minutes and pick another model”).

exception falaw.FalNonCanonicalArgument(message: str, *, path: str)[source]

An argument cannot be canonicalised into falaw’s hashed JSON form.

Raised by falaw.canonical when a value reaches a key-composition site (the per-call cache key, plan_hash, the dry-run artifact id) that JSON cannot represent faithfully: a non-JSON object, a non-finite float, or a non-string mapping key. Deliberately loud: the old behaviour — json.dumps(..., default=str) — silently collided structurally different calls into one cache key, handing back the wrong artifact as a supposed saving (falaw#17).

path names the offending node, e.g. arguments.extra.ref.

exception falaw.FalRateLimited(message: str, *, retry_after_s: float | None = None, **kwargs)[source]

fal is throttling requests — typical 429.

retry_after_s is parsed from the Retry-After header if present, else None (caller decides backoff).

exception falaw.FalServerError(message: str, *, status_code: int, detail: str = '', body: Any = None, headers: dict[str, str] | None = None, application: str | None = None, url: str | None = None, cause: BaseException | None = None)[source]

fal-side server error — typical 5xx. Generally retryable.

exception falaw.FalTimeout(message: str, *, elapsed_s: float, application: str | None = None)[source]

The fal call timed out before producing a result.

exception falaw.FalUnauthorized(message: str, *, status_code: int, detail: str = '', body: Any = None, headers: dict[str, str] | None = None, application: str | None = None, url: str | None = None, cause: BaseException | None = None)[source]

Missing or invalid API credentials — typical 401.

class falaw.ModelRecord(*, id: str, category: str, description: str = '', aliases: tuple[str, ...] = (), quality_tier: str = '', cost_hint: str = '', cost_estimate: CostEstimate | None = None, docs_url: str = '', max_clip_seconds: float | None = None, single_character_recommended: bool = False, supported_resolutions: tuple[str, ...] = (), default_negative_prompt: str = '')[source]

One entry in the fal model catalog.

default_negative_prompt: str

Quality/realism negatives worth appending by default (e.g. to avoid the plastic-skin look). Empty when none.

max_clip_seconds: float | None

Practical max length of a single generated clip, in seconds (e.g. ~10 for Seedance). Drives the “this shot is too long, split it” warning.

True when the model handles a single character per shot far better than multiple interacting ones — drives the “two characters, consider shot/reverse-shot” warning.

supported_resolutions: tuple[str, ...]

Resolutions the model offers, cheap→expensive (e.g. (“720p”, “1080p”)).

class falaw.Plan(calls: tuple[CallPlan, ...] = ())[source]

An ordered sequence of CallPlan — a render plan, in essence.

Plans compose: a + b returns a new Plan with a.calls followed by b.calls. Plan(calls=()) is the identity. Plans are frozen, so edits return new Plans (use with_call_replaced() for in-place-feel).

property cache_hit_savings_usd: float

USD that would have been spent without the cache.

Equal to sum(c.estimated_cost_usd for c in calls if c.cache_status == "hit" and c.estimated_cost_usd is not None).

property has_unknown_costs: bool

True if any non-cache-hit call has no cost estimate.

Use this to refuse to gate on a budget when the estimate is incomplete.

property known_cost_usd: float

The priced part of total_cost_usd — same number today, but honest by construction.

total_cost_usd coerces unpriced calls to $0.00 so sums stay well-defined; a cumulative budget gate that reads it alone will under-quote (400 unpriceable video calls total $0.00). Read this together with unknown_call_count: the true cost is known_cost_usd plus an unknown amount spread over that many calls, and a correct gate refuses when the count is nonzero rather than pretending the unknown part is free (falaw#18).

property total_cost_usd: float

Sum of CallPlan.billable_cost_usd across all calls.

property unknown_call_count: int

How many billable calls carry no price at all.

The countable form of has_unknown_costs — see known_cost_usd for the budget-gate arithmetic it enables.

with_call_replaced(index: int, new_call: CallPlan) Plan[source]

Return a new Plan with calls[index] replaced.

class falaw.ProgressEvent(*, kind: Literal['queued', 'progress', 'log', 'done', 'error', 'cache_hit'], application: str, call_id: str, message: str = '', pct: float | None = None, elapsed_s: float = 0.0)[source]

One step in the lifecycle of a fal call.

kind

Lifecycle stage. See EventKind.

Type:

Literal[‘queued’, ‘progress’, ‘log’, ‘done’, ‘error’, ‘cache_hit’]

application

fal model id (e.g. "fal-ai/flux/dev").

Type:

str

call_id

A short hex string that uniquely identifies the call. All events for one call_fal invocation share the same call_id.

Type:

str

message

Free-form text. For "log" events this is the log line; for "error" it’s repr(exc); otherwise empty.

Type:

str

pct

Optional progress percentage in [0.0, 100.0]. fal’s current API doesn’t surface this; included for forward compatibility.

Type:

float | None

elapsed_s

Seconds since the call started.

Type:

float

class falaw.PruneCandidate(key: str, path: str, bytes: int, last_modified: float | None = None)[source]

One thing a prune would delete (or did).

last_modified is None when the age could not be determined — a non-filesystem blob backend, or a manifest too corrupt to read. Such a candidate is never selected by older_than (an unprovable age is not evidence of staleness) and is evicted last under max_bytes.

class falaw.PruneReport(area: str, dry_run: bool, candidates: tuple[PruneCandidate, ...] = (), deleted: tuple[PruneCandidate, ...] = (), kept_entries: int = 0, kept_bytes: int = 0, rebillable_entries: int = 0, unreferenced_candidates: int = 0, errors: tuple[str, ...] = ())[source]

What a prune removed, or — with dry_run=True — would remove.

rebillable_entries is the number this exists for: cache entries that will cost money again because of this prune. Its meaning is exact per area, and each is a different claim:

  • manifests — every dropped entry re-bills, so it equals the candidate count.

  • content — entries whose recorded response names an asset that resolves (through the url -> hash index) to a blob being dropped. Those become unmaterializable from cache; they re-download if fal still serves the URL and re-render if it does not, and falaw cannot tell which from here. It mirrors the predicate falaw.plan.execute() applies to a hit from the built-in converter. It is therefore an upper bound, not an exact population: with a custom artifact_converter= the entry is never dropped (it returns a broken artifact instead), and with fetch_bytes=False dropping the blob changes nothing. Both errors are overcounts — the safe direction for a number you read before spending.

  • assets — only the copies whose blob is also gone. While the blob survives, re-materializing is a local copy and costs nothing.

unreferenced_candidates is the other half of the story, and only content populates it: blobs that no manifest points at. falaw cannot tell whether such a blob is garbage or the last copy of something irreplaceable — falaw.materialize_asset() puts reference images and locally-rendered file:// media in the same store, and those never had a fal response behind them. Counting them as rebillable would be wrong (no cache entry re-bills), but reporting nothing would tell an operator the prune is free when it may be destroying the only copy of a reference image.

property freed_bytes: int

Bytes actually freed — or, under dry_run, that would be freed.

Sums deleted, not candidates. A deletion that failed (a read-only volume, a permission error) leaves its bytes on disk, and a capacity tool that reports them as reclaimed tells an operator staring at a full disk that the problem is solved when it is not.

summary() str[source]

A human-readable line, phrased in the tense the run actually was.

class falaw.Result(assets: list[Asset] = <factory>, raw: dict = <factory>, application: str = '', arguments: dict = <factory>)[source]

A fal call result with parsed assets and the original raw response.

The raw response is kept so callers can inspect provider-specific fields (timings, seed, has_nsfw_concepts, …) that we do not normalize.

class falaw.Scene(*, title: str, style: str = '', characters: tuple[Character, ...] = (), environments: tuple[Environment, ...] = (), shots: tuple[Shot, ...] = (), beats: tuple[Beat, ...] = (), notes: str = '')[source]

The whole editable structure: cast, locations, shots, beats.

with_beat(beat: Beat) Scene[source]

Return a new Scene with beat replacing any existing beat with the same id.

class falaw.Session(output_dir: str = <factory>, journal: Journal = <factory>, history: list[Result] = <factory>)[source]

A working session for a sequence of falaw operations.

>>> import tempfile
>>> s = Session(output_dir=tempfile.mkdtemp())
>>> s.history
[]
class falaw.Shot(*, id: str, description: str = '', framing: str = 'medium', environment: str = '', characters: tuple[str, ...] = (), camera: str = '', notes: str = '')[source]

A visual frame: framing + environment + characters in view.

Beats anchor to a Shot via shot_id. The Shot itself has its own rendered output (still or short clip used as the anchor for beat lipsync renders).

class falaw.ToolSpec(*, name: str, description: str, func: ~typing.Callable[[...], ~typing.Any], input_schema: ~typing.Mapping[str, ~typing.Any] = <factory>, output_schema: ~typing.Mapping[str, ~typing.Any] = <factory>, tags: tuple[str, ...] = (), examples: tuple[~typing.Mapping[str, ~typing.Any], ...] = (), version: str = '0.0.1')[source]

Single source of truth for a tool exposed by falaw.

A ToolSpec is what the registry stores. Bridges read it to produce Claude-skill instructions, MCP tool descriptors, HTTP endpoints, etc.

class falaw.Voice(*, name: str, voice_id: str = '', reference_audio_url: str = '', model_id: str = '', style_notes: str = '')[source]

A character’s voice spec.

Three modes, choose any: * voice_id — model-side voice id (e.g. ElevenLabs voice). * reference_audio_url — a few seconds of audio to clone. * model_id — override the default TTS model for this voice.

Always provide name for stable, human-readable referencing.

falaw.animate_face(image_url: str, audio_url: str, *, prompt: str = '', quality: str = 'balanced', model_id: str | None = None, extra: dict | None = None) Result[source]

Animate a still face from audio. Image + audio → talking video.

falaw.apply_note_to_beat(beat: Beat, note: str, *, model: str = 'anthropic/claude-sonnet-4.5') Beat[source]

Use the LLM to apply a directorial note to a Beat.

falaw.apply_note_to_scene(scene: Scene, note: str, *, model: str = 'anthropic/claude-sonnet-4.5') Scene[source]

Apply a cross-cutting note: LLM proposes per-beat edits, we apply them.

falaw.beat_content_hash(beat: Beat, *, character: Character | None = None) str[source]

Hash everything that affects how the beat renders.

Includes the beat’s content + the character’s identity anchors (face image, voice spec). Style/emotion changes invalidate the cache; pure id renames do not.

falaw.cache_get(application: str, arguments: Mapping[str, Any], *, backend: str = 'fal', key_extra: Mapping[str, Any] | None = None) dict | None[source]

Return the raw fal response if cached, else None.

falaw.cache_put(application: str, arguments: Mapping[str, Any], raw: dict, *, note: str = '', wire_arguments: Mapping[str, Any] | None = None, backend: str = 'fal', key_extra: Mapping[str, Any] | None = None) str[source]

Persist a fal response. Returns the entry directory path.

Parameters:
  • application – fal model id.

  • arguments – the arguments the entry is keyed on.

  • raw – the fal response to store.

  • note – free-form label recorded in the manifest.

  • wire_arguments – the arguments actually sent to fal, when they differ from the key arguments (chained calls send URLs but are keyed on content hashes). Recorded for debugging only — it never affects the key.

  • backend – which execution backend produced raw (falaw#15). Joins the cache key only when non-default, same rule as falaw.canonical.cache_key_payload(); recorded in the manifest under the same condition, for debugging.

The manifest is written to a temporary file and moved into place with os.replace(), so a reader never sees a half-written entry. That is not hypothetical since execute_plan(concurrency=N): two calls of one Plan that are structurally identical land on the same key, and an interleaved json.dump would leave a permanently unparseable entry — a cache that poisons itself under exactly the fan-out it exists to make cheap.

falaw.cache_stats() dict[source]

Quick summary of the cache: entry count, disk usage, and where it went.

areas is the part worth reading. Since falaw#14 the cache holds the bytes of every generated asset, so a total on its own cannot distinguish gigabytes of irreplaceable blobs from gigabytes of assets/ copies that cost nothing to regenerate — and only the second is safe to reclaim without thinking. Each area’s economics, and the primitives that reclaim it, are in falaw.prune.

size_bytes is every byte under the cache root, unchanged in meaning from before the breakdown existed: the other area absorbs whatever the named areas do not claim, so the areas always sum to the whole.

(+SKIPed — it reads the caller’s real cache, a multi-gigabyte walk on the production box. tests.test_prune pins this against a throwaway cache instead.)

>>> stats = cache_stats()
>>> sorted(stats["areas"])
['assets', 'content', 'manifests', 'other', 'scenes', 'url_index']
falaw.cache_usage() CacheUsage[source]

Disk usage of the falaw cache, broken down by area.

The structured form behind falaw.cache_stats(). Read the module docstring for what each area costs to reclaim.

falaw.cached_call_fal(application: str, arguments: Mapping[str, Any], *, key_arguments: Mapping[str, Any] | None = None, refresh: bool = False, on_event=None, backend: str = 'fal', key_extra: Mapping[str, Any] | None = None) dict[source]

Call a fal model, but reuse the cached response when present.

Parameters:
  • application – fal model id.

  • arguments – model input dict — what is sent on the wire.

  • key_arguments – what the cache entry is keyed on, when that differs from what goes on the wire. Defaults to arguments. The split exists because a chained call must send fal an expiring URL for its upstream input while being keyed on that input’s content hash, so a byte-identical upstream regeneration hits instead of re-billing.

  • refresh – if True, bypass the cache and overwrite it with a fresh result.

  • on_event – Per-call subscriber for falaw.events.ProgressEvent. On a cache hit, a synthetic cache_hit event is emitted so UIs can show “skipped” instead of “running”.

  • backend – which execution backend serves this call (falaw#15) — resolved via falaw.backends. Also joins the cache key (non-default only), so two backends never share an entry. Despite the name, this function is no longer fal-specific; the name is kept because “fal” is still the only backend and every existing call site already spells it this way.

Returns:

Raw response (whether from cache or network).

falaw.call_fal(application: str, arguments: Mapping[str, Any], *, on_log: Callable[[str], None] | None = None, on_event: Callable[[ProgressEvent], None] | None = None, with_logs: bool = True, journal_errors: bool = True, api_key: str | None = None) dict[source]

Call a fal model via fal_client.subscribe.

Parameters:
  • application – fal model id (e.g. "fal-ai/flux/dev").

  • arguments – Input arguments. Keys depend on the model.

  • on_log – Legacy log callback — receives raw log lines as strings. Defaults to no-op (use on_event for structured access).

  • on_event – Per-call subscriber for ProgressEvent`s. Fires in addition to the global subscribers registered via :func:`falaw.events.subscribe.

  • with_logs – Pass through to fal_client; when True the model streams logs.

  • journal_errors – When True, exceptions are recorded as journal issues before being re-raised. The journal entry includes the application id and arguments so future agents can recognize the same trap.

  • api_key – Explicit fal key for this call. When None (default) the key bound via using_fal_credentials() is used; when that is also unset, the fal SDK’s own FAL_KEY env-var lookup applies (the historical behaviour). A resolved key is used per-call via a dedicated fal_client.SyncClient — it is never written to a global or an env var.

Returns:

The raw response dict from the model.

falaw.call_plan_from_dict(d: dict) CallPlan[source]

Rebuild a CallPlan from a call_plan_to_dict() dict.

arguments / metadata are copied (a deserialized plan owns its own data); expected_duration_s is re-tupled. backend defaults to DFLT_BACKEND when absent — a dict written before falaw#15 names no backend, and it always meant "fal".

falaw.call_plan_to_dict(call: CallPlan) dict[source]

Convert a CallPlan to a plain JSON-serializable dict.

The inverse of call_plan_from_dict(). expected_duration_s (a tuple) becomes a 2-element list since JSON has no tuple type; everything else is already JSON-native.

backend (falaw#15) is a tolerated-default addition, not a PLAN_DICT_SCHEMA bump: it is always written, but call_plan_from_dict() defaults it to DFLT_BACKEND when absent, so a dict from before this field existed still parses, and a dict written by this version still parses under older falaw (the extra key is simply never read there). No migration needed either direction.

falaw.cast_character(name: str, description: str, *, image_url: str = '', style: str = '', quality: str = 'high', voice_id: str = '', reference_audio_url: str = '', voice_style: str = '') Character[source]

Create a Character with a canonical face (and optional voice).

If image_url is given, we skip face generation and use that image directly. Otherwise we run text-to-image with the description (+ optional style suffix), cache the result, and use the URL.

falaw.cast_voice(character: Character, *, voice_id: str = '', reference_audio_url: str = '', style_notes: str = '', model_id: str = '') Character[source]

Attach or update the Voice on a Character.

falaw.clear_subscribers() None[source]

Drop all registered subscribers. Mostly for tests.

falaw.composite_character_in_environment(character_image_url: str, environment_image_url: str, *, prompt: str = '', quality: str = 'balanced', model_id: str | None = None, extra: dict | None = None) Result[source]

Place a character into an environment as one composited still.

The single most user-visible primitive missing from muvid (per interface_design_plan item E). With this, an agent can produce “Thor in a bell tower” — the character anchor for a downstream omnihuman lipsync — without manually compositing in an image editor.

Defaults to fal-ai/flux-kontext/dev (image_edit category at balanced/high tier). Pass model_id to override (e.g. "fal-ai/flux-pro/kontext/max" for highest quality, or "fal-ai/bytedance/seededit/v3/edit-image" for SeedEdit).

falaw.content_ref_for_url(url: str, *, store=None, fetcher: Callable[[str], Iterable[bytes]] | None = None, refresh: bool = False, assume_immutable: bool | None = None) ContentRef[source]

Materialize url’s bytes into store and return their content hash.

Idempotent and cheap on repeat, but how cheap depends on whether the URL can change behind falaw’s back — and falaw decides that rather than asking the caller to know (thorwhalen/falaw#23):

  1. Immutable URL (fal’s own — see is_immutable_url()) with a remembered hash whose blob is present: returned immediately, no network. This is what makes re-executing an already-cached plan free rather than re-downloading every clip.

  2. Mutable URL with a remembered hash: falaw revalidates — a conditional GET replaying the recorded ETag / Last-Modified (for file://, a (mtime, size) comparison). A 304 costs one round-trip and no payload; a 200 means the bytes really changed and the new ones are stored, so the content hash changes with them.

  3. No usable answer — nothing remembered, no validators recorded, or a transport that cannot make conditional requests: a plain fetch.

Step 3 is the important default. A transport that cannot revalidate makes falaw re-fetch, never trust: an unverifiable hint is not evidence.

Parameters:
  • url – The asset URL. file:// is supported and treated as mutable.

  • store – Injected lacing.ArtifactStore; defaults to default_content_store().

  • fetcher – Injected byte source; defaults to default_url_fetcher() (the built-in urllib transport unless using_url_fetcher() has installed another). To support revalidation, a custom transport exposes a conditional_fetch(url, validators) -> ConditionalOutcome attribute; without one it is simply never asked.

  • refresh – Skip every shortcut and re-fetch unconditionally. Rarely needed now that mutable URLs revalidate on their own — keep it for a URL whose origin lies about its validators.

  • assume_immutable – Override the host-based decision. True restores the old unconditional trust (use for a host you mint yourself, and prefer adding it to IMMUTABLE_URL_HOSTS); False forces revalidation even for fal.

Raises:

FalAssetFetchError – the bytes could not be retrieved, or the response was empty. Never returns a reference it could not back with bytes — a silent zero-byte “artifact” is the failure mode this guards.

falaw.current_fal_key() str | None[source]

The fal API key bound for the current context, or None.

Resolution order callers should mirror: an explicit api_key argument to call_fal() wins over this context value, which in turn wins over the fal SDK’s own FAL_KEY env-var lookup.

falaw.default_content_store()[source]

The falaw-cache-rooted lacing.ArtifactStore.

Rooted at <falaw cache dir>/content, so it moves with $FALAW_CACHE_DIR / $FALAW_DATA_DIR like every other piece of falaw state. Constructed per call (the constructor only ensures directories exist) so a test that re-points the cache dir gets a fresh store.

falaw.default_url_fetcher() Callable[[str], Iterable[bytes]][source]

The transport used when no fetcher= argument is given.

using_url_fetcher()’s installed fetcher if there is one, else the built-in urllib-based one. Resolved at call time, so every falaw entry point that reads asset bytes — content_ref_for_url(), falaw.materialize_asset(), falaw.execute_plan() and falaw.execute_plan_isolated() — honours an override installed after they were imported.

falaw.drop_cache_entry(application: str, arguments: Mapping[str, Any], *, backend: str = 'fal', key_extra: Mapping[str, Any] | None = None) bool[source]

Delete the cache entry for (application, arguments). Returns whether one existed.

The counterpart to cache_put(), and the mechanism that keeps a cache from becoming a trap. An entry whose response can no longer be turned into a usable artifact — fal deleted the URL and the bytes are not in the content store — must be a miss, not a permanent failure: without this, the only escape is use_cache=False, which re-bills the whole plan rather than the one dead call. falaw.plan.execute() calls it.

Only the manifest is removed. Blobs in the content store are shared by content hash across entries and are never dropped from here.

falaw.edit_image(image_url: str, prompt: str, *, quality: str = 'balanced', model_id: str | None = None, extra: dict | None = None) Result[source]

Edit an image with a natural-language instruction.

falaw.establish_environment(name: str, description: str, *, time_of_day: str = '', lighting: str = '', image_url: str = '', quality: str = 'high') Environment[source]

Create an Environment with a canonical establishing image.

falaw.estimate_call_cost(record: ModelRecord, *, count: int = 1, seconds: float | None = None, megapixels: float | None = None, tokens: int | None = None) float | None[source]

Cost of one fal call against record.

Returns None when the cost is unknown, so callers can distinguish “free” from “we can’t say”. That happens when the record carries no cost_estimate at all, or when its pricing is quantity-based (per_second / per_token) and the caller did not supply the quantity — an unpriceable call, not a free one.

Returning 0.0 for a missing quantity would be actively dangerous: a per_second clip is the single most expensive thing fal bills for, and a caller gating a budget on the answer would read “$0.00” and spend real money without a prompt. None instead propagates to CallPlan.estimated_cost_usd and lights up Plan.has_unknown_costs, which exists for exactly this case. Callers that know the quantity should pass it; per_second callers can fall back to record.max_clip_seconds for an upper bound.

per_megapixel is deliberately different: an image’s pixel budget has a sane house default (below), so it stays priceable.

falaw.estimate_scene_cost(scene: Scene, *, tts_quality: str = 'balanced', lipsync_quality: str = 'high', shot_quality: str = 'balanced', shots_as_video: bool = False, shot_seconds: float | None = None) CostRollup[source]

Estimate the USD cost of a full render_scene() invocation.

Walks every shot + beat with the same pick_model semantics the renderer uses, then sums per-call costs. Returns a structured CostRollup with per-line breakdowns, plus a list of “skipped” entries the caller should surface (typically: a model with no cost_estimate populated).

shot_seconds is the assumed clip length used to price shots_as_video. A Shot carries no duration of its own — screen time comes from the renderer’s per-shot run — and image-to-video models bill per second, so without this the video lines are genuinely unpriceable and are reported in CostRollup.skipped rather than silently priced at $0.00. Pass the length you expect (the beat path has the same knob as estimated_seconds).

falaw.execute_plan(plan: Plan, *, on_event: Callable | None = None, dry_run: bool = False, use_cache: bool = True, artifact_converter: ResultToArtifact | None = None, content_store=None, fetch_bytes: bool | None = None, asset_fetcher=None, concurrency: int = 1) list

Execute a Plan, returning a list of materialized :class:`lacing.Artifact`s.

This is the halt policy: the first call that raises ends the run, and its exception propagates unchanged (falaw’s typed hierarchy — see falaw.errors — is what a caller classifies on, so it is never wrapped). Use execute_isolated() when one bad call must not discard the rest of a fan-out; it returns an ExecutionReport with one outcome per call instead of raising.

Parameters:
  • plan – The Plan to execute.

  • on_event – Optional per-call event subscriber (passed to call_fal).

  • dry_run – When True, no fal calls are made; synthetic Artifacts are returned with placeholder asset_id and url=None. Useful for exercising downstream composition without an API key.

  • use_cache – When True (default), executes via cached_call_fal so cache hits skip the network. When False, every call is fresh.

  • artifact_converter – Per-CallPlan converter from raw fal response to lacing.Artifact. When None (default), a built-in converter handles the common shapes ({images: [{url}]}, {video: {url}}, {audio: {url}}). Mutually exclusive with content_store / fetch_bytes / asset_fetcher, which configure the built-in converter only — passing both raises, rather than silently ignoring the ones a custom converter cannot honour. Converters do not own cost_usd: the executor stamps it from the observed run outcome after conversion, overwriting whatever the converter set (falaw#26).

  • content_store – Injected lacing.ArtifactStore that media bytes are materialized into. Defaults to falaw.content.default_content_store() (a directory store rooted in the falaw cache). Point this at an S3-backed store to share content — and therefore cache hits — across machines.

  • fetch_bytes – Whether to download each media result so its asset_id is the SHA-256 of its bytes. Defaults to DFLT_FETCH_BYTES (true), overridable process-wide via FETCH_BYTES_ENVVAR. Opting out forfeits caching for chained calls: without bytes there is no content hash, so downstream calls fall back to keying on the upstream URL — which fal mints fresh per upload, so the downstream entry can never be reused across runs or machines. It also means asset_id is not a content hash, in violation of lacing.Artifact’s contract. Use it only when you genuinely want URL-only artifacts and no reuse.

  • asset_fetcher – Injected byte source (url -> Iterable[bytes]) used to read media results; defaults to falaw.content.default_url_fetcher(). This is the per-call transport seam. A hermetic test suite usually wants the process-wide one instead — falaw.testing.fake_assets(), built on falaw.content.using_url_fetcher() — because a suite reaching falaw through its own public API has no execute call site to pass this to. Passing it here still wins over any installed default. ($FALAW_FETCH_ARTIFACT_BYTES=0 also silences the network, but by turning content addressing off — see FETCH_BYTES_ENVVAR.)

  • concurrency – How many calls may be in flight at once. 1 (DFLT_CONCURRENCY) runs the Plan sequentially, on the calling thread, exactly as it always has. Above 1, independent calls run on a thread pool bounded by this number — a Plan is I/O-bound (an HTTP request that fal takes tens of seconds to answer), so threads are the right tool and the bound is what keeps a 200-call fan-out from becoming 200 simultaneous paid requests. Chained calls are never parallelised with their producers: a call holding a "<from N>" placeholder waits for call N. Two things to weigh before raising it: the vendor’s rate limit, and memory — materializing a media result peaks at roughly twice the asset’s size (thorwhalen/lacing#25), so concurrency multiplies the peak.

Failure handling — a paid result is never discarded

Two different things can go wrong when reading a result’s bytes, and they get two different answers:

  • A fresh call whose bytes cannot be fetched. fal has already run — and billed — the generation. Raising would throw away a result we paid for, typically over a transient network failure. So the artifact degrades: url is kept, bytes_size stays 0, asset_id is a digest of the response and is not claimed to be a content hash, and a UserWarning is emitted. Downstream key resolution reads bytes_size == 0 and falls back to the URL — a guaranteed cache miss, never a wrong hit.

  • A cache hit that cannot be materialized — fal deleted the URL and the bytes are not in the content store. The entry is unusable, so it is treated as a miss: it is invalidated and the call re-executed once. A cache must never become a trap whose only escape is re-billing the whole plan with use_cache=False.

Placeholder resolution — the wire/key split

Any string argument equal to "<from N>" (for an integer N) is rewritten just before the call is made — so a multi-step plan (e.g. generate_image → image_to_video) can reference the upstream output without the planner needing to know its URL. The rewrite happens after the upstream call has executed; planning itself is unaffected.

It happens twice, into two different argument sets, because the same value cannot serve both jobs:

  • the wire arguments get artifacts[N].url — what fal needs in order to fetch the input;

  • the key arguments get sha256:<artifacts[N].asset_id> — the upstream’s content hash, so a byte-identical upstream regeneration produces a downstream cache hit instead of re-billing the expensive call. Keying on the URL instead is the defect this split exists to fix (falaw#14): fal mints a unique URL per upload, so a URL-keyed downstream entry is unreachable the moment the upstream genuinely re-runs.

An upstream artifact with no materialized bytes has no content hash, so its key ref falls back to the URL — a guaranteed miss, never a wrong hit.

returns:

One lacing.Artifact per CallPlan in plan.calls, in the same order.

falaw.execute_plan_isolated(plan: Plan, *, on_event: Callable | None = None, dry_run: bool = False, use_cache: bool = True, artifact_converter: ResultToArtifact | None = None, content_store=None, fetch_bytes: bool | None = None, asset_fetcher=None, concurrency: int = 1, halt_on_failure: bool = False) ExecutionReport

Execute a Plan with per-call failure isolation, returning a report.

The fan-out counterpart of execute(). Where execute raises on the first failure — discarding every artifact produced before it, each of which fal has already billed — this returns an ExecutionReport carrying one CallOutcome per call: the successes with their artifacts, the failures with their exceptions, and the calls that never ran with the reason why.

len(report.outcomes) == len(plan.calls) always, so a caller that built something per call can zip against report.outcomes and stay aligned.

Parameters:

halt_on_failure – When True, stop submitting work as soon as any call fails; everything not yet started is reported blocked with a run-level reason. This is what execute() uses, and at concurrency=1 it reproduces the historical sequential behaviour exactly. Note that at concurrency > 1 calls already in flight are not cancelled — a fal request cannot be recalled once made, and pretending otherwise would discard results that were billed anyway.

:param All other arguments are as execute().:

Three outcome states, not two

failed and blocked are different questions for the caller. A failed call can be retried verbatim. A blocked one cannot: its input does not exist, so it has to be re-planned after its producer succeeds. Any call holding a "<from N>" placeholder whose call N did not succeed is blocked, transitively.

Examples

>>> from falaw import CallPlan, Plan, execute_plan_isolated
>>> plan = Plan(calls=(CallPlan(tool="t", application="m",
...                             arguments={}, output_kind="image"),))
>>> report = execute_plan_isolated(plan, dry_run=True)
>>> report.is_complete, len(report.outcomes)
(True, 1)
falaw.extract_models_from_corpus(path: str) Iterator[dict][source]

Yield ModelRecord-shaped dicts parsed from llms-full.txt.

falaw.fetch_model_prices(endpoint_ids: list[str], *, api_key: str | None = None, http_get: Callable[[str, list[tuple[str, str]], dict], dict | None] | None = None, batch_size: int = 50) dict[str, dict][source]

{endpoint_id: {"unit_price", "unit", "currency"}} from fal’s API.

Batches requests at batch_size ids (the endpoint caps at MAX_IDS_PER_REQUEST). Ids the API does not know are simply absent from the result — the caller decides what absence means. That takes work: the live endpoint answers a batch containing even one unknown id with a blanket 404, so a failed batch is bisected down to the ids that actually price (O(unknown x log batch) extra requests).

falaw.generate_audio(prompt: str, *, kind: str = 'ambient', duration_s: float | None = None, model_id: str | None = None, extra: dict | None = None) Result[source]

Generate ambient/SFX/music from a prompt. Argument shape mirrors falaw.plan_generate_audio() exactly, so planned and eager calls with identical inputs collapse to the same cache entry.

falaw.generate_image(prompt: str, *, quality: str = 'balanced', image_size: str = 'landscape_4_3', model_id: str | None = None, extra: dict | None = None) Result[source]

Generate an image from a text prompt.

falaw.generate_image_with_refs(prompt: str, reference_image_urls: list[str], *, quality: str = 'balanced', model_id: str | None = None, extra: dict | None = None) Result[source]

Generate a new image conditioned on one or more reference images.

The missing twin of generate_image(): text-to-image models silently ignore reference images, so callers wanting a recurring subject to stay consistent (a character’s face across storyboard panels) need a model that actually ingests references. This routes to the image_edit category (Flux Kontext et al.) and threads the references as image_url (first) + image_urls (all) — the same wire shape image-edit models understand.

Pass model_id to override the picked model.

falaw.image_to_video(image_url: str, prompt: str = '', *, quality: str = 'high', model_id: str | None = None, extra: dict | None = None) Result[source]

Animate a still image into a video.

falaw.iter_render_scene(scene: Scene, *, tts_quality: str = 'balanced', lipsync_quality: str = 'high', shot_quality: str = 'balanced', shots_as_video: bool = False, force: bool = False, concurrency: int = 1)[source]

Yield (kind, result) pairs as each shot/beat finishes.

kind{"shot", "beat"}. With concurrency=1 results arrive in submission order (shots before beats). With concurrency > 1 they arrive in completion order (use the "shot_id" / "beat_id" keys to re-key by identity).

Cache hits are immediate: a fully-cached scene yields all results in close succession even at concurrency=1.

falaw.lipsync(video_url: str, audio_url: str, *, quality: str = 'high', model_id: str | None = None, extra: dict | None = None) Result[source]

Re-sync mouth motion in an existing video to a new audio track.

falaw.llm_complete(prompt: str, *, system: str = '', model: str = 'anthropic/claude-sonnet-4.5', temperature: float = 0.7, extra: dict | None = None) str[source]

Single-shot LLM completion. Returns the assistant text.

falaw.make_call_plan(*, tool: str, application: str, arguments: dict, output_kind: Literal['image', 'video', 'audio', 'json', 'text', 'binary'], backend: str = 'fal', estimated_cost_usd: float | None = None, expected_duration_s: tuple[float, float] | None = None, metadata: dict | None = None, consult_cache: bool = True) CallPlan[source]

Build a CallPlan and (optionally) check the cache.

When consult_cache=True (the default), the cache is peeked using the same key the eventual call would produce; cache_status is set to "hit" if a cached entry exists, "miss" otherwise. This makes Plan.total_cost_usd honest: a fully-cached Plan reports $0.

When consult_cache=False (e.g. for unit tests or “what would a fresh run cost?” reporting), cache_status is "unknown".

A chained call — arguments still holding a "<from N>" placeholder, because the upstream call has not executed yet — is never peeked (falaw#15, D2): its resolved key is not knowable at plan time, and the unresolved key is never written by anything (execute() always keys on the resolved form), so peeking it can only ever report a false "miss" — never a real "hit". That silently over-quotes cost and under-reports the plan’s cache_hit_savings_usd on every chained call, which under prepaid billing (a quote that may be deducted) is a billing bug. cache_status is "unknown" here instead, exactly the case CacheStatus documents it for.

Raises:

falaw.errors.FalNonCanonicalArgument – an argument cannot be hashed faithfully (non-JSON object, non-finite float, non-string mapping key). Raised here — while planning is still free — rather than at key-composition time on the way to the network (falaw#17).

falaw.materialize_asset(url: str, *, key_hint: str = '', store=None, fetcher=None, refresh: bool = False) str[source]

Download a remote asset to the cache and return the local path.

The local filename is content-addressed by the asset’s bytes, so two URLs serving identical bytes resolve to one file. The extension is a presentational hint for ffmpeg/PIL; the SHA-256 is the address.

Repeat calls are cheap, in three widening circles — this is what makes it safe to call from a loop over 200 shots:

  1. the file is already on disk here (no store lookup, no network) — immutable URLs only, see below;

  2. the bytes are in the content store (no network, or one validating round-trip) — so it still works after fal has expired the URL;

  3. otherwise, one download.

Circle 1 matters on its own: the content store is prunable, so an asset can survive as a materialized file after its blob is gone.

Circle 1 is taken only when the URL cannot change — that is, falaw.content.is_immutable_url() — because reaching it requires trusting the url -> hash index to name the current bytes, and for an arbitrary caller-supplied URL it does not (thorwhalen/falaw#23). A mutable URL goes to circle 2, where falaw.content.content_ref_for_url() revalidates before reusing anything; when the bytes really are unchanged that costs one conditional request and still no download, and when they have changed you get the new file instead of silently getting the old one.

Parameters:
  • url – the remote asset URL. file:// is supported and is used deliberately by downstream packages for locally-rendered media.

  • key_hint – optional human-readable filename prefix.

  • store – injected lacing.ArtifactStore; defaults to falaw.content.default_content_store().

  • fetcher – injected byte source (url -> Iterable[bytes]); defaults to the urllib-based one. The seam for custom transport (auth headers, retries) and for a hermetic test suite.

  • refresh – re-fetch unconditionally, skipping every circle. Rarely needed now: a mutable URL revalidates on its own (falaw#23), so this is for an origin that lies about its validators.

Raises:

falaw.errors.FalAssetFetchError – the bytes could not be retrieved. Unlike a generated-media artifact (which degrades to URL-only — see falaw.plan.execute()), there is nothing to degrade to here: the caller asked for a local file.

falaw.model_constraints(id: str) dict[source]

The capability/limit fields for a model — the “static reminder of limitations” a shot-list builder surfaces. Resolves aliases.

Returns a JSON-able dict; max_clip_seconds etc. are None / empty when unknown for that model.

falaw.parse_response(raw: dict, *, application: str, arguments: dict) Result[source]

Best-effort parser over the common fal response shapes.

fal models return a variety of layouts (lists, single objects, bare URLs). We normalize each into Asset(url, kind, …). Unknown shapes pass through as raw only — callers can read result.raw for anything we miss.

falaw.parse_screenplay(text: str, *, title: str = '', style: str = '', model: str = 'anthropic/claude-sonnet-4.5') Scene[source]

Convert prose screenplay text into a Scene IR via an LLM call.

falaw.pick_model(*, category: str, quality_tier: str = 'balanced') ModelRecord[source]

Pick a sensible fal model for a (category, quality) request.

First-match semantics: when several models share a tier, the earlier entry wins. Curated entries are written first in data/models.json, so they take precedence over corpus-merged additions. If no model has the exact tier, neighboring tiers are tried. KeyError only when the category is empty.

falaw.plan_animate_face(image_url: str, audio_url: str, *, prompt: str = '', quality: str = 'balanced', model_id: str | None = None, duration_s: float | None = None, extra: dict | None = None, metadata: dict | None = None, consult_cache: bool = True) CallPlan[source]

Plan a falaw.animate_face() call (image + audio → talking video).

Note: the default avatar model is known to hang. For production-grade behavior, callers should pass model_id="fal-ai/bytedance/omnihuman/v1.5" or set quality="high" (which picks omnihuman).

falaw.plan_composite_character_in_environment(character_image_url: str, environment_image_url: str, *, prompt: str = '', quality: str = 'balanced', model_id: str | None = None, extra: dict | None = None, metadata: dict | None = None, consult_cache: bool = True) CallPlan[source]

Plan a falaw.composite_character_in_environment() call.

The character image anchors identity; the environment image anchors location, lighting, palette. The default model is Flux Kontext dev.

falaw.plan_dependencies(plan: Plan) tuple[frozenset[int], ...][source]

Per-call set of the call indices it references via "<from N>".

The Plan’s dependency DAG, read straight off the placeholders — one frozenset per call, in plan order, so deps[3] == {1} means call 3 consumes call 1’s output. An empty set means the call is independent and may run concurrently with any other independent call, which is what execute_isolated() schedules on.

Also the plan’s structural validator, and it runs before a cent is spent: a malformed reference used to surface only when execution reached the offending call, i.e. after every call before it had been billed.

Raises:

ValueError – a placeholder that is not "<from N>" for an integer N; an N outside the plan; or an N that does not run before the referencing call (including a self-reference) — the output would not exist yet, so it can only ever be a bug.

>>> a = CallPlan(tool="t", application="m", arguments={}, output_kind="image")
>>> b = CallPlan(tool="t", application="m",
...              arguments={"image_url": "<from 0>"}, output_kind="video")
>>> plan_dependencies(Plan(calls=(a, b)))
(frozenset(), frozenset({0}))
>>> plan_dependencies(Plan(calls=(b,)))
Traceback (most recent call last):
    ...
ValueError: Placeholder '<from 0>' in call 0 references call 0, which does not run before it. ...
falaw.plan_edit_image(image_url: str, prompt: str, *, quality: str = 'balanced', model_id: str | None = None, extra: dict | None = None, metadata: dict | None = None, consult_cache: bool = True) CallPlan[source]

Plan a falaw.edit_image() call (Flux Kontext / SeedEdit / OmniGen).

falaw.plan_from_dict(d: dict) Plan[source]

Rebuild a Plan from a plan_to_dict() dict.

Raises ValueError if d carries an unrecognized schema tag — a plan written by an incompatible future version should fail loudly, not silently lose calls. A missing schema is tolerated (treated as v1) so hand-written plans stay easy.

falaw.plan_generate_audio(prompt: str, *, kind: str = 'ambient', duration_s: float | None = None, model_id: str | None = None, extra: dict | None = None, metadata: dict | None = None, consult_cache: bool = True) CallPlan[source]

Plan a falaw.generate_audio() call (prompt → ambient/SFX/music).

The planning primitive behind an ambient bed or music cue (falaw#10): the user who leaves their editor to hunt a city-night ambience is the user this generates one for — costed, cached and planned like every other call. Mirrors the eager falaw.generate_audio() signature so a planned call and an eager call with identical inputs collapse to the same cache entry. Pure data; no network at plan time.

kind selects the default model (see GENERATE_AUDIO_DEFAULTS for why these are explicit ids). duration_s reaches the model as its integer duration argument where the model takes one (mmaudio does) AND feeds the cost estimate; for models with no duration argument it is estimator-only.

falaw.plan_generate_image(prompt: str, *, quality: str = 'balanced', image_size: str = 'landscape_4_3', model_id: str | None = None, extra: dict | None = None, metadata: dict | None = None, consult_cache: bool = True) CallPlan[source]

Plan a falaw.generate_image() call without executing it.

falaw.plan_generate_image_with_refs(prompt: str, reference_image_urls: list[str], *, quality: str = 'balanced', model_id: str | None = None, extra: dict | None = None, metadata: dict | None = None, consult_cache: bool = True) CallPlan[source]

Plan a falaw.generate_image_with_refs() call.

The planning sibling of the eager op: text-to-image models ignore reference images, so a caller wanting a recurring subject to stay consistent must route to a reference-capable model. This picks the image_edit category (Flux Kontext et al.) and threads the references as image_url (first) + image_urls (all), the same wire shape the eager op uses — so a planned and an eager call with identical inputs collapse to one cache entry.

falaw.plan_hash(plan: Plan) str[source]

Stable, plan-scoped structural idempotency key for a whole Plan.

Answers “does this whole plan match one I already ran?” — the handle a job manager (its first customer, nw.jobs) uses to dedup double-submits and to replay a resumed render for free. It is computed before execution and with <from N> placeholders intact, so it is stable across re-plans of the same structural request.

The digest canonicalizes each call over {app, args, tool} (falaw.canonical.plan_identity_payload()) — matching _synthetic_artifact()’s canonicalization, and deliberately not the per-call content-addressed cache key (falaw.cache._key(), which keys on {app, args} with no tool). plan_hash and the per-call cache key therefore key on different bytes and must not be assumed to agree call-for-call. Both projections live side by side in falaw.canonical with one shared byte-form (falaw.canonical.canonical_blob() — sorted keys, no default=str fallback, no NaN), so an argument the form cannot represent faithfully raises falaw.errors.FalNonCanonicalArgument instead of colliding, and a new identity-bearing field is an explicit decision about both hashes.

Two structurally-identical plans hash equal; changing any call’s app, args, or tool — or the order of calls — changes the hash.

>>> a = CallPlan(tool="generate_image", application="fal-ai/flux/dev",
...              arguments={"prompt": "a tiger"}, output_kind="image")
>>> b = CallPlan(tool="image_to_video", application="fal-ai/svd",
...              arguments={"image_url": "<from 0>"}, output_kind="video")
>>> plan_hash(Plan(calls=(a, b))) == plan_hash(Plan(calls=(a, b)))
True
>>> plan_hash(Plan(calls=(a, b))) == plan_hash(Plan(calls=(b, a)))
False

backend (falaw#15) joins the hashed payload too, so a plan built for one backend never dedups against the structurally-identical plan for another — and, since it is included only when non-default, every plan made of today’s (all-"fal") calls hashes exactly as it did before:

>>> comfy = CallPlan(tool="generate_image", application="fal-ai/flux/dev",
...                   arguments={"prompt": "a tiger"}, output_kind="image",
...                   backend="comfyui")
>>> plan_hash(Plan(calls=(a,))) == plan_hash(Plan(calls=(comfy,)))
False
falaw.plan_image_to_video(image_url: str, prompt: str = '', *, quality: str = 'high', model_id: str | None = None, duration_s: float | None = None, extra: dict | None = None, metadata: dict | None = None, consult_cache: bool = True) CallPlan[source]

Plan a falaw.image_to_video() call.

duration_s is used only for cost estimation when the model is priced per_second; it does not get passed to fal unless the caller puts it in extra (different models have different argument names).

falaw.plan_lipsync(video_url: str, audio_url: str, *, quality: str = 'high', model_id: str | None = None, duration_s: float | None = None, extra: dict | None = None, metadata: dict | None = None, consult_cache: bool = True) CallPlan[source]

Plan a falaw.lipsync() call (existing video + new audio → re-synced video).

falaw.plan_llm_complete(prompt: str, *, system: str = '', model: str | None = None, temperature: float = 0.7, output_kind: str = 'text', extra: dict | None = None, metadata: dict | None = None, consult_cache: bool = True) CallPlan[source]

Plan a falaw.llm_complete() call without executing it.

Routes through fal-ai/any-llm with the exact same application id and argument shape as the eager falaw.llm_complete(), so a planned call and an eager call with identical inputs collapse to the same cache entry.

output_kind is "text" for a free-form completion or "json" when the prompt asks for a strict-JSON response — it tells falaw.execute() what kind of lacing.Artifact to materialize. Either way the LLM response is materialized to a content-addressed cache file (Artifact.path), because LLM output is text, not a URL.

Cost is the fal-ai/any-llm per-call estimate (source="approximate" — real pricing is per-token); pass model to pick the underlying model.

falaw.plan_text_to_speech(text: str, *, quality: str = 'balanced', voice: str | None = None, model_id: str | None = None, duration_s: float | None = None, extra: dict | None = None, metadata: dict | None = None, consult_cache: bool = True) CallPlan[source]

Plan a falaw.text_to_speech() call (text → audio Artifact).

Mirrors the eager falaw.text_to_speech() signature so a planned call and an eager call with identical inputs collapse to the same cache entry. voice semantics are model-specific.

duration_s is an optional hint used only by the cost estimator — the produced audio’s actual duration comes back on the materialized Artifact.

falaw.plan_to_dict(plan: Plan) dict[source]

Convert a Plan to a plain JSON-serializable dict.

The result round-trips through plan_from_dict(). This is the substrate primitive a consumer (a persistence layer, an MCP transport, a plan-diff tool) builds on — falaw owns the wire shape of its own Plan so every consumer agrees on it. Carries a schema tag (PLAN_DICT_SCHEMA) so a future breaking change is detectable.

falaw.prune_assets(*, older_than: float | int | timedelta | None = None, max_bytes: int | None = None, dry_run: bool = True, store=None) PruneReport[source]

Reclaim materialized asset copies — the cheapest disk in the cache (falaw#22).

assets/ holds a copy of each blob rather than a hard link, which is deliberate (falaw.content.write_blob_to_file() explains why a link would let a consumer corrupt the content store) but doubles the on-disk cost of every materialized asset. That makes this the prune to reach for first: while a blob survives, re-materializing its asset is a local copy and costs nothing, so rebillable_entries counts only the copies whose blob is also gone.

Parameters:
  • older_than – drop copies last written more than this ago — seconds, or a timedelta.

  • max_bytes – drop oldest-first until the area fits in this budget.

  • dry_run – report without deleting. Default, deliberately.

  • store – injected lacing.ArtifactStore, used only to ask whether each dropped copy still has a blob behind it; defaults to falaw.content.default_content_store().

Returns:

with area="assets".

Return type:

PruneReport

Raises:

ValueError – neither bound was given.

falaw.prune_content(*, older_than: float | int | timedelta | None = None, max_bytes: int | None = None, dry_run: bool = True, store=None) PruneReport[source]

Reclaim content-addressed blobs — the gigabytes (falaw#22).

This is the expensive prune. A blob is the last copy of an asset once fal has expired its URL (“expired files are permanently deleted and cannot be recovered”), so dropping one can turn a free cache hit into a re-rendered clip. The report says how many entries that applies to before you commit; read rebillable_entries.

Parameters:
  • older_than – drop blobs last written more than this ago — seconds, or a timedelta.

  • max_bytes – drop oldest-first until the area fits in this budget.

  • dry_run – report without deleting. Default, deliberately.

  • store – injected lacing.ArtifactStore; defaults to falaw.content.default_content_store().

Returns:

with area="content".

Return type:

PruneReport

Raises:

ValueError – neither bound was given — see _require_a_bound().

Both bounds may be combined; a blob selected by either is dropped.

>>> report = prune_content(older_than=timedelta(days=90))
>>> report.dry_run
True
falaw.prune_manifests(*, older_than: float | int | timedelta | None = None, max_bytes: int | None = None, dry_run: bool = True) PruneReport[source]

Reclaim cache entries — the fal responses themselves (falaw#22).

Manifests are kilobytes, so this is rarely where the disk is; it is here because a stale entry is its own problem — it pins a model version and a price you may no longer want served from cache.

Unlike prune_content(), the cost is unconditional: every dropped entry re-bills its call on the next run, so rebillable_entries always equals the candidate count.

Parameters:
  • older_than – drop entries stored more than this ago — seconds, or a timedelta. Read from the manifest’s own stored_at, falling back to file mtime.

  • max_bytes – drop oldest-first until the area fits in this budget.

  • dry_run – report without deleting. Default, deliberately.

Returns:

with area="manifests".

Return type:

PruneReport

Raises:

ValueError – neither bound was given.

Only the manifest is removed; blobs are shared by content hash across entries and are never dropped from here — the same rule falaw.drop_cache_entry() follows.

falaw.refresh_full_docs(*, docs_dir: str | None = None, max_workers: int = 16, force: bool = False, journal: bool = True) dict[source]

Re-crawl per-page docs and rebuild fal_ai_docs_full.md.

falaw.refresh_llms(*, docs_dir: str | None = None, journal: bool = True) dict[source]

Refresh llms.txt and llms-full.txt; return a summary dict.

falaw.refresh_model_prices(*, write: bool = False, api_key: str | None = None, http_get: Callable[[str, list[tuple[str, str]], dict], dict | None] | None = None, models_path: str | None = None, today: str | None = None) dict[source]

Refresh models.json cost estimates from fal’s pricing API.

Returns a summary dict; write=False (default) reports what would change without touching the file. models_path and today exist for tests (the fetch date lands in each estimate’s notes).

falaw.refresh_models_from_corpus(*, path: str | None = None, write: bool = False) dict[source]

Merge corpus-discovered models into models.json (additive).

Returns {added, total, from_corpus, write} summary. Setting write=False (the default) reports what would change without touching the file.

falaw.refresh_state() dict[source]

Return the saved per-source refresh state (etags, last fetch times).

falaw.register_tool(**spec_kwargs) Callable[source]

Decorator: register the wrapped function as a falaw tool.

>>> @register_tool(name='echo', description='echo back', tags=('demo',))
... def _echo(x): return x
>>> get_tool('echo').name
'echo'
falaw.remove_background(image_url: str, *, quality: str = 'high', model_id: str | None = None, extra: dict | None = None) Result[source]

Remove the background from an image.

falaw.render_beat(beat: Beat, character: Character, *, tts_quality: str = 'balanced', lipsync_quality: str = 'high', tts_model_id: str | None = None, avatar_model_id: str | None = None, force: bool = False) dict[source]

Render one Beat to a lipsynced video. Returns a small manifest dict.

Parameters:
  • tts_model_id – Override the TTS model. When provided, takes precedence over the character’s voice.model_id and over tts_quality-based pick_model. Use this to force a specific TTS engine for one beat (e.g. eleven-v3 for emotional delivery, multilingual-v2 for consistency).

  • avatar_model_id – Override the avatar/lipsync model (e.g. "fal-ai/bytedance/omnihuman/v1.5" to bypass the default ai-avatar which is known to hang).

falaw.render_scene(scene: Scene, *, tts_quality: str = 'balanced', lipsync_quality: str = 'high', shot_quality: str = 'balanced', shots_as_video: bool = False, force: bool = False, concurrency: int = 1) dict[source]

Render every shot and beat. Returns a manifest dict.

concurrency controls how many shots/beats run in parallel against fal. The work is HTTP-bound, so a thread pool is enough. Default 1 preserves serial behavior. Use iter_render_scene() instead if you want results yielded as each unit completes (for live UI updates).

falaw.render_shot(shot: Shot, *, environment: Environment | None = None, characters: tuple = (), style: str = '', as_video: bool = False, quality: str = 'balanced', image_model_id: str | None = None, image_to_video_model_id: str | None = None, force: bool = False) dict[source]

Render a Shot as a still (default) or a short clip.

Parameters:
  • image_model_id – Override the image-gen model used for the storyboard still (defaults to pick_model(category="image", …)).

  • image_to_video_model_id – Override the image-to-video model used when as_video=True (e.g. "fal-ai/minimax/hailuo-02/pro/image-to-video").

falaw.scene_from_dict(d: Mapping[str, Any]) Scene[source]

Inverse of asdict: reconstruct a Scene from a plain dict.

falaw.storyboard_shot(shot: Shot, *, environment: Environment | None = None, characters: tuple = (), style: str = '', quality: str = 'balanced') Result[source]

Render a storyboard still for a Shot.

falaw.subscribe(callback: Callable[[ProgressEvent], None]) Callable[[ProgressEvent], None][source]

Register callback to receive every emitted ProgressEvent.

Returns the callback unchanged so it can be used as a decorator:

@subscribe
def log_to_file(ev: ProgressEvent) -> None:
    ...
falaw.talking_avatar_from_text(text: str, image_url: str, *, voice: str | None = None, prompt: str = '', tts_quality: str = 'balanced', avatar_quality: str = 'balanced') Result[source]

text + face image → talking video. Two fal calls, one Result.

falaw.text_to_speech(text: str, *, quality: str = 'balanced', voice: str | None = None, model_id: str | None = None, extra: dict | None = None) Result[source]

Synthesize speech. voice semantics are model-specific.

falaw.text_to_video(prompt: str, *, quality: str = 'high', model_id: str | None = None, extra: dict | None = None) Result[source]

Generate a video from a text prompt.

falaw.unsubscribe(callback: Callable[[ProgressEvent], None]) None[source]

Remove a previously subscribe()’d callback. No-op if absent.

falaw.upscale_image(image_url: str, *, scale: float = 2.0, model_id: str | None = None, extra: dict | None = None) Result[source]

Upscale an image.

falaw.using_fal_credentials(key: str | None) Iterator[None][source]

Bind key as the fal credential for every call_fal() in this context.

Intended for server-side bring-your-own-key flows: wrap a unit of work that will make one or more fal calls, and they all authenticate with key instead of the server’s FAL_KEY env var — without any intermediate function needing a credential parameter.

A falsy key is a deliberate no-op (the context is left untouched), so a caller can pass an optional header value straight through without special-casing “no BYO key — fall back to the server/env key”.

Thread/async safe: backed by a contextvars.ContextVar, so the binding is visible only within the entering context (and threads/tasks it spawns), never to concurrent requests.

falaw.using_url_fetcher(fetcher: Callable[[str], Iterable[bytes]]) Iterator[Callable[[str], Iterable[bytes]]][source]

Make fetcher falaw’s default asset transport for the duration.

The public seam for replacing falaw’s network transport wholesale — with an authenticated client, a retrying one, a local mirror, or (the common case) an in-memory fake in a test suite.

Prefer this to reaching for the module’s private default: it covers every entry point in one place, it needs no monkeypatch, and it cannot be invalidated by an internal rename. It is also the only mechanism that reaches a call falaw makes from a thread you did not create — see _DEFAULT_FETCHER for why that matters.

An explicitly passed fetcher= / asset_fetcher= still wins: this changes the default, never an explicit choice.

Nests and restores, so an inner block cannot leak over an outer one:

>>> from lacing import ArtifactStore
>>> store = ArtifactStore.in_memory()
>>> before = default_url_fetcher()
>>> with using_url_fetcher(lambda url: [b"faked"]):
...     content_ref_for_url("https://fal.media/x.png", store=store).bytes_size
5
>>> default_url_fetcher() is before
True

For a ready-made fake with pinned bytes and 404s, see falaw.testing.

falaw.video_model_constraints() list[dict][source]

model_constraints for every video model in the catalog — the data a shot-list builder shows as its model-limits reference.

falaw.voice_clone(reference_audio_url: str, text: str, *, model_id: str | None = None, extra: dict | None = None) Result[source]

Generate speech in a cloned voice.