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
reprof the underlying exception whenokis 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.
entriescounts the area’s own unit — cache entries formanifests, blobs forcontent, files forassetsandurl_index— not files on disk, which is why it is reported next tobytesrather than derived from it.pathisNonewhen the area has no directory of its own. That is the case formanifests(scattered across two-character shard directories at the cache root) and forother. It is deliberately not the cache root: a caller reaching forrmtree(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.
- 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(noline) 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_bytesalone cannot tell you whether you are looking at gigabytes of irreplaceable blobs or gigabytes ofassets/copies that cost nothing to regenerate.- area(name: str) AreaUsage[source]
The
AreaUsagenamedname.- Raises:
KeyError – no such area. The valid names are
AREA_NAMES.
- property total_bytes: int
Every byte under the cache root.
Equal to a full walk of the root, because
otherabsorbs 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.indexis the call’s position inplan.callsand is the identity a caller retries or re-plans by — a report always carries exactly one outcome per call, in plan order, soindexis 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.
- 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.
applicationandargumentsare the exact tuplecached_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 onbackend.
- 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) — seefalaw.backends. Defaults tofalaw.canonical.DFLT_BACKEND("fal"), the only backend until a second one lands. Enters the per-call cache key andplan_hashonly when it is not the default, so every call falaw has ever planned or cached keeps its exact pre-#15 digest — seefalaw.canonical.
- property billable_cost_usd: float
Cost that will actually be billed (0 on cache hit, estimate otherwise).
Returns
0.0(notNone) on cache hit or unknown estimate so sums are well-defined; useestimated_cost_usdis Noneto check unknown status explicitly.
- cache_status: Literal['hit', 'miss', 'stale', 'unknown']
Whether the cache will short-circuit this call.
"hit"meansexecutewon’t bill, soPlan.total_cost_usdandPlan.cache_hit_savings_usdreflect that.
- estimated_cost_usd: float | None
Predicted cost in USD.
Nonewhen the model has nocost_estimatepopulated (callers can distinguish “free” from “unknown”).
- expected_duration_s: tuple[float, float] | None
(min, max)duration the model can produce, orNoneif no duration contract is known. Plan-level validators can check that the requested duration fits this range and raiseFalDurationOutOfRangebefore 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 ANDplan_hash, under omit-if-empty (an empty dict leaves every existing key byte-identical). Unlikemetadata, 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 Transformimpl_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 fromapplicationbecause 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_hashis the SHA-256 hex digest of the bytes — the valuelacing.Artifact.asset_idis 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_callis the simplest: constant per invocation regardless of size.per_secondapplies to video / TTS where output length matters.per_imagecovers batch image gen.per_megapixelcovers high-res image generation.per_tokencovers LLM-style endpoints.- Type:
Literal[‘per_call’, ‘per_image’, ‘per_second’, ‘per_token’, ‘per_megapixel’]
- amount
USD (or
currency) per unit defined bykind.- 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().
- 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
CallOutcomeper 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 againstoutcomesand 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 offalaw.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
failedto 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 reasonestimated_spend_usdmust never be read on its own: an unpriced call contributes0.0to the sum, so a report reading$0.00means 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
outcomesfor anything positional.
- property succeeded: tuple[CallOutcome, ...]
Outcomes that produced an artifact, in plan order.
- 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.contentwhen 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 whoseasset_idis not the SHA-256 of any bytes would breaklacing.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_sthat the model’s declaredexpected_duration_rangecannot satisfy. Callers can catch this to split the shot, repeat it, or pick a different model.
- 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.canonicalwhen 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).pathnames 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_sis parsed from theRetry-Afterheader if present, elseNone(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.
- single_character_recommended: bool
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 + breturns a new Plan witha.callsfollowed byb.calls.Plan(calls=())is the identity. Plans are frozen, so edits return new Plans (usewith_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_usdcoerces unpriced calls to$0.00so 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 withunknown_call_count: the true cost isknown_cost_usdplus 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_usdacross all calls.
- property unknown_call_count: int
How many billable calls carry no price at all.
The countable form of
has_unknown_costs— seeknown_cost_usdfor the budget-gate arithmetic it enables.
- 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_falinvocation share the samecall_id.- Type:
str
- message
Free-form text. For
"log"events this is the log line; for"error"it’srepr(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_modifiedisNonewhen the age could not be determined — a non-filesystem blob backend, or a manifest too corrupt to read. Such a candidate is never selected byolder_than(an unprovable age is not evidence of staleness) and is evicted last undermax_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_entriesis 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 theurl -> hashindex) 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 predicatefalaw.plan.execute()applies to a hit from the built-in converter. It is therefore an upper bound, not an exact population: with a customartifact_converter=the entry is never dropped (it returns a broken artifact instead), and withfetch_bytes=Falsedropping 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_candidatesis the other half of the story, and onlycontentpopulates 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-renderedfile://media in the same store, and those never had a fal response behind them. Counting them asrebillablewould 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, notcandidates. 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.
- 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.
- 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
namefor 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 asfalaw.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 sinceexecute_plan(concurrency=N): two calls of one Plan that are structurally identical land on the same key, and an interleavedjson.dumpwould 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.
areasis 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 ofassets/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 infalaw.prune.size_bytesis every byte under the cache root, unchanged in meaning from before the breakdown existed: theotherarea 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_prunepins 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 syntheticcache_hitevent 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_eventfor 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 viausing_fal_credentials()is used; when that is also unset, the fal SDK’s ownFAL_KEYenv-var lookup applies (the historical behaviour). A resolved key is used per-call via a dedicatedfal_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
CallPlanfrom acall_plan_to_dict()dict.arguments/metadataare copied (a deserialized plan owns its own data);expected_duration_sis re-tupled.backenddefaults toDFLT_BACKENDwhen 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
CallPlanto a plain JSON-serializable dict.The inverse of
call_plan_from_dict().expected_duration_s(atuple) 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 aPLAN_DICT_SCHEMAbump: it is always written, butcall_plan_from_dict()defaults it toDFLT_BACKENDwhen 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_urlis given, we skip face generation and use that image directly. Otherwise we run text-to-image with the description (+ optionalstylesuffix), 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.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). Passmodel_idto 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 intostoreand 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):
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.Mutable URL with a remembered hash: falaw revalidates — a conditional
GETreplaying the recordedETag/Last-Modified(forfile://, a(mtime, size)comparison). A304costs one round-trip and no payload; a200means the bytes really changed and the new ones are stored, so the content hash changes with them.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 todefault_content_store().fetcher – Injected byte source; defaults to
default_url_fetcher()(the built-inurllibtransport unlessusing_url_fetcher()has installed another). To support revalidation, a custom transport exposes aconditional_fetch(url, validators) -> ConditionalOutcomeattribute; 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.
Truerestores the old unconditional trust (use for a host you mint yourself, and prefer adding it toIMMUTABLE_URL_HOSTS);Falseforces 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_keyargument tocall_fal()wins over this context value, which in turn wins over the fal SDK’s ownFAL_KEYenv-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_DIRlike 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-inurllib-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()andfalaw.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 isuse_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
Nonewhen the cost is unknown, so callers can distinguish “free” from “we can’t say”. That happens when the record carries nocost_estimateat 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.0for a missing quantity would be actively dangerous: aper_secondclip 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.Noneinstead propagates toCallPlan.estimated_cost_usdand lights upPlan.has_unknown_costs, which exists for exactly this case. Callers that know the quantity should pass it;per_secondcallers can fall back torecord.max_clip_secondsfor an upper bound.per_megapixelis 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_modelsemantics the renderer uses, then sums per-call costs. Returns a structuredCostRollupwith per-line breakdowns, plus a list of “skipped” entries the caller should surface (typically: a model with nocost_estimatepopulated).shot_secondsis the assumed clip length used to priceshots_as_video. AShotcarries 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 inCostRollup.skippedrather than silently priced at $0.00. Pass the length you expect (the beat path has the same knob asestimated_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). Useexecute_isolated()when one bad call must not discard the rest of a fan-out; it returns anExecutionReportwith 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_idandurl=None. Useful for exercising downstream composition without an API key.use_cache – When True (default), executes via
cached_call_falso cache hits skip the network. When False, every call is fresh.artifact_converter – Per-CallPlan converter from raw fal response to
lacing.Artifact. WhenNone(default), a built-in converter handles the common shapes ({images: [{url}]},{video: {url}},{audio: {url}}). Mutually exclusive withcontent_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 owncost_usd: the executor stamps it from the observed run outcome after conversion, overwriting whatever the converter set (falaw#26).content_store – Injected
lacing.ArtifactStorethat media bytes are materialized into. Defaults tofalaw.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_idis the SHA-256 of its bytes. Defaults toDFLT_FETCH_BYTES(true), overridable process-wide viaFETCH_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 meansasset_idis not a content hash, in violation oflacing.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 tofalaw.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 onfalaw.content.using_url_fetcher()— because a suite reaching falaw through its own public API has noexecutecall site to pass this to. Passing it here still wins over any installed default. ($FALAW_FETCH_ARTIFACT_BYTES=0also silences the network, but by turning content addressing off — seeFETCH_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 callN. 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), soconcurrencymultiplies 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:
urlis kept,bytes_sizestays 0,asset_idis a digest of the response and is not claimed to be a content hash, and aUserWarningis emitted. Downstream key resolution readsbytes_size == 0and 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 integerN) 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.ArtifactperCallPlaninplan.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(). Whereexecuteraises on the first failure — discarding every artifact produced before it, each of which fal has already billed — this returns anExecutionReportcarrying oneCallOutcomeper 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 againstreport.outcomesand stay aligned.- Parameters:
halt_on_failure – When True, stop submitting work as soon as any call fails; everything not yet started is reported
blockedwith a run-level reason. This is whatexecute()uses, and atconcurrency=1it reproduces the historical sequential behaviour exactly. Note that atconcurrency > 1calls 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
failedandblockedare 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 callNdid 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_sizeids (the endpoint caps atMAX_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 theimage_editcategory (Flux Kontext et al.) and threads the references asimage_url(first) +image_urls(all) — the same wire shape image-edit models understand.Pass
model_idto 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"}. Withconcurrency=1results arrive in submission order (shots before beats). Withconcurrency > 1they 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
CallPlanand (optionally) check the cache.When
consult_cache=True(the default), the cache is peeked using the same key the eventual call would produce;cache_statusis set to"hit"if a cached entry exists,"miss"otherwise. This makesPlan.total_cost_usdhonest: a fully-cached Plan reports $0.When
consult_cache=False(e.g. for unit tests or “what would a fresh run cost?” reporting),cache_statusis"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’scache_hit_savings_usdon every chained call, which under prepaid billing (a quote that may be deducted) is a billing bug.cache_statusis"unknown"here instead, exactly the caseCacheStatusdocuments 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:
the file is already on disk here (no store lookup, no network) — immutable URLs only, see below;
the bytes are in the content store (no network, or one validating round-trip) — so it still works after fal has expired the URL;
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 theurl -> hashindex 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, wherefalaw.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 tofalaw.content.default_content_store().fetcher – injected byte source (
url -> Iterable[bytes]); defaults to theurllib-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_secondsetc. areNone/ 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 setquality="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
frozensetper call, in plan order, sodeps[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 whatexecute_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 integerN; anNoutside the plan; or anNthat 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
Planfrom aplan_to_dict()dict.Raises
ValueErrorifdcarries an unrecognizedschematag — a plan written by an incompatible future version should fail loudly, not silently lose calls. A missingschemais 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.kindselects the default model (seeGENERATE_AUDIO_DEFAULTSfor why these are explicit ids).duration_sreaches the model as its integerdurationargument 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_editcategory (Flux Kontext et al.) and threads the references asimage_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 notool).plan_hashand 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 infalaw.canonicalwith one shared byte-form (falaw.canonical.canonical_blob()— sorted keys, nodefault=strfallback, no NaN), so an argument the form cannot represent faithfully raisesfalaw.errors.FalNonCanonicalArgumentinstead 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, ortool— 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_sis used only for cost estimation when the model is pricedper_second; it does not get passed to fal unless the caller puts it inextra(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-llmwith the exact same application id and argument shape as the eagerfalaw.llm_complete(), so a planned call and an eager call with identical inputs collapse to the same cache entry.output_kindis"text"for a free-form completion or"json"when the prompt asks for a strict-JSON response — it tellsfalaw.execute()what kind oflacing.Artifactto 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-llmper-call estimate (source="approximate"— real pricing is per-token); passmodelto 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.voicesemantics are model-specific.duration_sis 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
Planto 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 aschematag (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, sorebillable_entriescounts 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 tofalaw.content.default_content_store().
- Returns:
with
area="assets".- Return type:
- 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 tofalaw.content.default_content_store().
- Returns:
with
area="content".- Return type:
- 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, sorebillable_entriesalways equals the candidate count.- Parameters:
older_than – drop entries stored more than this ago — seconds, or a
timedelta. Read from the manifest’s ownstored_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:
- 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.txtandllms-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.jsoncost estimates from fal’s pricing API.Returns a summary dict;
write=False(default) reports what would change without touching the file.models_pathandtodayexist for tests (the fetch date lands in each estimate’snotes).
- 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. Settingwrite=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-basedpick_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 defaultai-avatarwhich 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.
concurrencycontrols how many shots/beats run in parallel against fal. The work is HTTP-bound, so a thread pool is enough. Default1preserves serial behavior. Useiter_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
callbackto 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.
voicesemantics 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
keyas the fal credential for everycall_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
keyinstead of the server’sFAL_KEYenv var — without any intermediate function needing a credential parameter.A falsy
keyis 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
fetcherfalaw’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_FETCHERfor 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.