nw#
nw — Narrative Workflow.
Application-orchestration framework for audiovisual projects. A project is
a folder; a genre (music video, explainer, podcast clip, slideshow) is a
reusable specialization on top — the first-class successor to what nw
informally called an “app” (see nw.genres and issue #10).
Public surface:
Project— folder facade: read/write spec, character anchors, shot upserts, decision log, typed summary, session-resumption brief.ProjectSummary— typed read view of a project.ResumptionBrief— “where we left off”: decision tail, what the last authored change reaches downstream, recorded spend, deterministic next actions. Itscaveatsfield carries what those numbers do not know.clone_project()— replacescp -rfor sibling experiments.apply_to_projects()— replaces shell for-loops across roots.Schema types:
ProjectSpec,SectionSpec,ShotSpec,CharacterRef,EnvironmentRef,SongInfo.nw.workflow— theprepare→plan→executerender split (Plan/Execute over rendering; records render-result provenance).nw.renderers— render strategies.nw.genres— production genres (the reusable project specialization), their project factories, and the ops a host serves on a genre’s projects (GenreOp,register_genre_ops(),genre_ops_catalogue()).nw.pricing— re-quoting a persisted plan at today’s rates (current_quote(),PlanQuote). Any stored cost figure is an as-of-then fact; reporting one as current under-quotes the run once falaw’s rate tables move, so read it back through here (nw#74).
On rendering provenance and partial re-render (why choices, not just content,
are recorded as linked artifacts), see
misc/docs/Rendering Provenance and Partial Re-render.md.
Functions
|
The ordinal in a spoken reference, or |
|
The one spelling we print. |
Every registered genre as a JSON-able catalog entry (sorted by slug). |
|
|
One genre's catalog entry (raises |
|
The slug of the genre whose |
|
Resolve a genre (+ optional template) to the params for a new project. |
|
Register a resolver for a genre slug; returns it for inline use. |
|
Resolve a genre (+ optional template) to the standard creation envelope. |
|
Register an initializer for a genre slug; returns it for inline use. |
|
Seed a freshly-created |
|
Register a project factory for a genre slug; returns it for inline use. |
True iff a plugged-in project factory is registered for |
|
|
True iff |
|
Create + seed a new project for a PLUGGED-IN |
|
Register the operations a genre offers on its projects; returns them as a tuple. |
|
The ops registered for |
|
The op |
|
The pure-JSON catalogue of |
|
Return every annotation at the given tier across all of the project's stores. |
|
Apply |
|
Clone an nw project to a new root. |
|
Bless a pre-trace project so the verifying-trace rule can read it (nw#58). |
|
Drop verifying traces whose target annotation no longer exists. |
|
Per-shot reports + final-compose inspection in one call. |
|
Return the annotations this one was directly derived from. |
|
Return every annotation whose provenance chain leads back to |
|
Execute a Plan, materialize the result as |
|
Execute |
|
Look up a genre by slug; raises |
|
Look up a strategy by name; raises if unknown. |
|
Look up a Transform instance by name; raises with the known names. |
|
True iff this project has been migrated to the lacing graph. |
|
Walk every annotation in every store under a project (any backend). |
Return all registered genre slugs (sorted). |
|
Return all registered strategy names (sorted). |
|
Return all registered Transform names (sorted). |
|
|
Migrate |
|
Yield an iterator of open stores, one per scope, honouring the backend. |
|
Load the project's storyboard. |
|
Build a Plan that generates a seed image for each panel that lacks one. |
|
Build a |
|
The JSON-able per-call cost rows nw persists in a decision payload. |
|
Re-quote |
|
Rebuild a re-quotable |
|
Today's price for the calls stored in a decision payload. |
|
Today's price for a |
|
A quote for something that could not be re-quoted at all. |
|
Resolve all local inputs for rendering a shot. |
|
The asset_id used for storyboard panel references. |
|
Register a |
|
Register a strategy. |
|
Register a Transform under |
Every registered Transform as a JSON-able capability entry (sorted by name). |
|
|
Fold |
|
The instance id of one fan-out unit: UUIDv5 of |
|
Plan one Transform across |
|
Execute a planned fan-out, one ordinary |
|
Coerce a caller-supplied mapping to |
|
|
|
The exception to re-raise so that nothing it renders carries a secret. |
|
Bind the secrets nw itself knows how to use, for the duration of a block. |
|
Persist a Storyboard into the project's SqliteStore. |
|
Inspect |
|
Every registered check, name-ordered — what a user chooses from. |
|
Order the selection into waves that may each run concurrently. |
|
Add a check to the menu, as a call or as a decorator. |
|
Checks whose |
|
Run |
|
Every annotation that is currently stale, regardless of cause. |
|
Return every annotation that |
|
Classify every annotation downstream of |
|
Classify every derived annotation in the project — the snapshot form. |
|
Return the path to the project's storyboard SQLite store. |
|
Build a one-panel-per-shot draft Storyboard from a project's shots. |
|
Convenience: return a |
Classes
|
A finished thing a person can watch, hear, or download. |
|
|
Default |
|
|
One image associated with a character. |
|
Pointer to a character folder under |
|
Inspection of the project-level final composed video. |
|
Why one reachable annotation was judged stale (or not). |
|
One entry of a project's decision log, flattened for display. |
|
Pointer to an environment folder under |
|
A run of consecutive frames whose pixels don't change. |
|
A gap on the timeline between two shots. |
|
A reusable definition of a production kind over the nw substrate. |
|
A named preset ("subgenre") within a genre — a filled-in default config. |
|
One operation a genre offers on its projects — a row a host builds surfaces from. |
|
A folder-backed nw project. |
|
Typed read/write facade over the project's lacing graph store. |
|
|
|
Body of an unproduced-output record. |
|
The top-level project SSOT, persisted as |
|
Typed read view of a project — what |
|
A "where we left off" snapshot, returned by |
|
A non-overlapping span of the project's master timeline. |
|
Local-only inputs for rendering a single shot. |
|
Inspection of one rendered shot. |
|
A timeline-locked visual unit. |
|
Metadata for the master audio file. |
|
Render-strategy contract. |
|
A swappable, costed function from A-annotations to B-annotations. |
|
The annotations a Transform consumes. |
|
The outputs of a Transform's |
|
An output annotation that was planned but never produced. |
|
Today's price for a persisted plan, with the stale figure alongside. |
|
One unit of a fan-out — the PDG-shaped work item (nw#26). |
|
One planned unit: a work item plus its ordinary Transform plan. |
|
The planned fan-out — pure data, like every plan in this federation. |
|
One unit's outcome, aligned 1:1 with the plan's units. |
|
A fan-out run: one |
|
A read-only |
|
One validation, and everything a scheduler and a menu need to know. |
|
What one check produced, including the case where it could not run. |
|
One thing a check noticed. |
|
Everything a |
Exceptions
|
|
|
|
An op DELIBERATELY declined — no song yet, an unknown clip, an edit that does not hold. |
|
An op stopped because the host asked it to (its |
|
|
Raised by |
- class nw.BaseTransform[source]#
Bases:
objectDefault
Transformimplementation.Subclasses set the class attributes (
name,input_kinds,output_kind, optionallyparams_model) and implementplan(). The defaultexecute()runs the Plan, maps artifacts onto skeletons 1:1 via_complete_annotation(), writes to the project graph, and reports cost. Transforms whose artifact→annotation mapping is not 1:1 (e.g.clips_to_animatic: N inputs → 1 output) overrideexecute().That 1:1 mapping is an invariant, checked before anything is spent:
execute()raises whenlen(skeleton) != len(plan.calls), the same guardnw.storyboard.execute_render_panel_imagesalready applies to its own plan/id pairing. The zip below would otherwise stop at the shorter sequence and drop the surplus with no error and no record — harmless only for as long as the executor returns exactly one artifact per call, which is precisely what per-call failure isolation changes.- generate_when: Literal['static', 'dynamic'] = 'dynamic'#
When this Transform’s fan-out cardinality is knowable —
"static"or"dynamic"(nw#26). The default is"dynamic": fail expensive-looking, so an undeclared shape can never let a cost gate quote a number for a cardinality nobody knows yet. Declare"static"only when the work-item list is derivable from the graph before the run (“one image per panel”).
- impl_version: str = '1'#
Behaviour version — bump on “same interface, changed behaviour”, never rename the registry key for it. See the
TransformProtocol for the full contract. At the default, no cache salt is applied, so every key ever issued stays byte-identical; the first real bump is the first salt.
- exception nw.CacheModeConflict[source]#
Bases:
ValueErroruse_cache=Falseandforce=Truewere passed together.A
ValueErrorsubclass so callers that already catchValueError(and falaw’s own refusal of the same corner) keep working, while a caller that wants to distinguish this one specific contradiction can.
- class nw.CharacterImage(path, *, from_ref=False, from_selected=False, is_anchor=False)[source]#
Bases:
objectOne image associated with a character.
Returned by
Project.list_character_images(). Distinguishes:from_ref: file lives undercharacters/<name>/refs/— a candidate from generation or upload.from_selected: undercharacters/<name>/selected/— curator-picked.is_anchor: this is the file the character card currently points at as the “use this image” anchor (lipsync seed, etc.).
- class nw.CharacterRef(**data)[source]#
Bases:
BaseModelPointer to a character folder under
characters/<name>/.The stable-attribute fields mirror
nw.bodies.CharacterRefBodyV1field-for-field, and that is load-bearing rather than cosmetic:nw.Project.read_spec()builds aCharacterReffrom the graph body andnw.Project.write_spec()writes the body back from theCharacterRef. Any field present on the body but missing here is silently erased by the nextupdate_spec— which is what used to happen toreference_image_urls. Add a field to one, add it to both.
- class nw.Check(name, summary, run, requires=(), parallel_safe=True, cost='cheap', example_requests=(), requires_binaries=())[source]#
Bases:
objectOne validation, and everything a scheduler and a menu need to know.
- name#
dotted and stable — it is what a user selects and what a
requiresrefers to.
- summary#
one line, for the menu.
- run#
(target, context) -> findings. May also return a(findings, produced)pair when other checks depend on it.
- requires#
names of checks that must run first, whose
producedvalues arrive incontext. Cycles raise at plan time.
- parallel_safe#
whether it may run alongside its independent peers.
Falsefor anything that is not thread-safe or that saturates the machine on its own (a full decode).
- cost#
rough wall-clock class —
"free"(no subprocess),"cheap"(seconds),"dear"(a full pass over the media), or"paid"(spends money, e.g. a hosted OCR or transcription)."paid"is never selected bysuggest(); it must be asked for by name.
- example_requests#
things a person actually says that mean they want this check. What lets a menu of forty be navigated, and what an MCP surface matches against instead of exposing an enum.
- requires_binaries#
external programs it shells out to. A missing one makes the check skip with a reason, never silently pass.
- class nw.CheckResult(name, findings=(), skipped='', error='', elapsed_s=0.0, produced=None)[source]#
Bases:
objectWhat one check produced, including the case where it could not run.
skippedanderrorare kept distinct from “found nothing”, because conflating them is how a validation suite comes to report all-clear on a machine where half of it never ran. A missing binary is not a pass.
- class nw.ComposeReport(**data)[source]#
Bases:
BaseModelInspection of the project-level final composed video.
- class nw.DecisionEntry(**data)[source]#
Bases:
BaseModelOne entry of a project’s decision log, flattened for display.
- class nw.Deliverable(path, content_type, filename, artifact_id='', project_id='', genre='', ref=None, title=None, duration_s=None, size_bytes=None, created_at=None, meta=<factory>)[source]#
Bases:
objectA finished thing a person can watch, hear, or download.
pathis server-side and never leaves the host; it is what the transport streams. Everything else exists so the host does not have to guess:content_type— what to serve it as. The genre knows; the host would otherwise re-derive it from a suffix.filename— what it should be called when it lands in someone’s Downloads folder.music_video_test_02-cut-4.mp4beatsb02fc05417ea.ref— the speakable label (seeformat_ref()).artifact_id— the stable, unambiguous id.refis the convenience; this is the truth, and it is what a signed token is minted against.
The optional descriptive fields are what a listing surface renders, and what lets a watch page say “10 seconds, 4.4 MB, made yesterday” without opening the file.
- property kind: str#
'video','audio','image'or'file'— how to present it.Derived from
content_typeso a genre never has to declare it twice.>>> Deliverable(Path('a.mp4'), 'video/mp4', 'a.mp4').kind 'video' >>> Deliverable(Path('a.mp3'), 'audio/mpeg', 'a.mp3').kind 'audio' >>> Deliverable(Path('a.pdf'), 'application/pdf', 'a.pdf').kind 'file'
- class nw.EnvironmentRef(**data)[source]#
Bases:
BaseModelPointer to an environment folder under
environments/<name>/.Mirrors
nw.bodies.EnvironmentRefBodyV1field-for-field, for the same load-bearing reason asCharacterRef— see that docstring.reference_image_urls(the lookbook the FE curates for a location) was erased by everyupdate_specuntil this mirror was completed.
- class nw.FailedOutput(skeleton, status, reason='', error=None, blocked_by=())[source]#
Bases:
objectAn output annotation that was planned but never produced.
Carries the skeleton rather than an id because the skeleton is what the caller planned and what a retry would re-submit — and because a UI needs its body to say which panel is missing, not just that something is.
- error: BaseException | None#
The original exception, for a caller that classifies on falaw’s typed hierarchy (
FalRateLimitedis worth retrying;FalAccountLockedis not).
- reason: str#
upstream panel 47 was filtered”* rather than an unexplained hole.
- Type:
Human-readable cause, from falaw. Renders as *”skipped
- skeleton: Annotation#
The annotation that would have been completed.
- class nw.FanOutItemResult(item, instance_id, status, result=None, error=None, reason='')[source]#
Bases:
objectOne unit’s outcome, aligned 1:1 with the plan’s units.
- class nw.FanOutPlan(transform_name, units)[source]#
Bases:
objectThe planned fan-out — pure data, like every plan in this federation.
Cost arithmetic follows falaw#18’s honest form exactly (same names, same semantics):
known_cost_usdis the priced part, and a correct gate reads it together withunknown_call_count— the true cost is the known sum plus an unknown amount over that many calls, and the gate refuses when the count is nonzero rather than pretending the unknown part is free.
- class nw.FanOutResult(transform_name, items)[source]#
Bases:
objectA fan-out run: one
FanOutItemResultper planned unit, in order.len(result.items) == len(fan_out.units)always — the same alignment guarantee falaw’sExecutionReportgives one level down.- property cost_usd_actual: float#
Observed spend over the units that ran. A lower bound, like
TransformResult.cost_usd_actual(whose caveat about billed- but-failed calls applies per unit).
- property has_unknown_costs: bool#
True when the run’s true spend is not fully known.
Two sources, both counted: a surviving unit whose own report says so, and any failed unit — a unit that raised mid-execute may have been billed for calls its (discarded) report would have carried, so its spend is unknown by construction. Without the second clause, a failed run could read “all costs known, $0.00 spent” — the exact under-report the federation’s unknown-cost rule exists to prevent.
blockedunits never ran and are known-$0.
- to_record()[source]#
The run record — where work items live (never the graph document).
JSON-serializable as returned, provided every item’s
attributesis (their contract; a violation raises here, naming the item). Annotations and artifacts are referenced by id; the annotations themselves were already written to the graph by each unit’s ordinaryexecute, and duplicating their bodies here would make the record a second, driftable copy.failed_count/blocked_countcount outputs within a unit (zero when the unit itself failed — its result isNone); the unit-level outcome isstatus. A consumer counting failed units counts statuses, not these fields.- Return type:
- class nw.FanOutUnit(item, instance_id, plan, skeleton)[source]#
Bases:
objectOne planned unit: a work item plus its ordinary Transform plan.
- class nw.Finding(check, severity, message, where='', remedy=None, evidence=<factory>)[source]#
Bases:
objectOne thing a check noticed.
A finding is never a bare boolean. Whoever reads it — a human deciding whether to publish, or a model deciding what to fix — needs to know where in the work it is and what would make it go away, and a check that cannot say those two things has not finished its job.
- check#
the name of the check that produced it.
- severity#
"info","warn"or"error"; only"error"makes a report notok.
- message#
what is wrong, in one sentence a human can act on.
- where#
where in the work — a timestamp, a frame index, a shot id, a path. Free-form because the checks are, but never empty for anything above
"info".
- remedy#
what would fix it, when the check knows.
Nonewhen it honestly does not.
- evidence#
anything a reader would want to look at — an extracted frame’s path, the numbers behind the verdict.
- class nw.FreshnessVerdict(annotation, is_stale, reason, upstream_id=None)[source]#
Bases:
objectWhy one reachable annotation was judged stale (or not).
Emitted by
stale_verdicts().reasonis one of theREASON_*constants;upstream_idnames the parent that decided it when a single parent did, so “why is this stale?” has an answer that does not require re-deriving the walk by hand.
- class nw.FrozenSegment(**data)[source]#
Bases:
BaseModelA run of consecutive frames whose pixels don’t change.
A short freeze (≤ 0.25s) is usually a model artifact; a long one (≥ 1s) is almost always a bug — Hailuo Pro returning a too-short clip + a tpad fallback that froze the last frame, etc.
- class nw.Genre(slug, title, description='', body_schema_uris=(), transform_names=(), strategy_names=(), projection_entrypoint=None, folder_conventions=<factory>, status='available', templates=(), intake_kinds=(), cost_profile=None, defaults=<factory>)[source]#
Bases:
objectA reusable definition of a production kind over the nw substrate.
Pure data: it references substrate pieces by name rather than owning them, so declaring a genre never touches the engine.
>>> slideshow = Genre( ... slug="slideshow", ... title="Slideshow", ... description="Stills over narration, assembled to a video.", ... transform_names=("clips_to_animatic.ffmpeg",), ... projection_entrypoint="clips_to_animatic.ffmpeg", ... ) >>> slideshow.title 'Slideshow' >>> slideshow.status 'available'
projection_entrypoint, when given, must be one of the genre’s own declared transforms or strategies:>>> Genre(slug="bad", title="Bad", projection_entrypoint="nope") Traceback (most recent call last): ... ValueError: Genre 'bad': projection_entrypoint 'nope' is not among its transform_names or strategy_names
- cost_profile: str | None = None#
A short discriminator slug routing the cost gate to the right estimator (e.g.
"tts"= per-character audio,"per_clip"= per-render video). The real numbers stay in the app; this is only the routing tag.
- defaults: Mapping[str, Any]#
The “start from scratch” params for this genre (same opaque shape as a
Template’sparams) — used when no template is chosen.
- intake_kinds: tuple[str, ...] = ()#
Intake “what are you making?” answers that select this genre (the edge
recommend_genre()walks). App data (e.g. reelee’s intake form) owns the vocabulary; the genre just declares which answers it covers.
- is_ready()[source]#
True iff every declared transform and strategy is registered.
A
plannedgenre may legitimately be not ready; anavailableone that isn’t ready is a wiring bug worth catching in a test.- Return type:
- template(slug)[source]#
Look up one of this genre’s
Templates by slug (KeyError if absent).- Return type:
- to_dict()[source]#
A JSON-able catalog entry — the shape apps serve to a frontend / MCP client.
Templates are emitted with their opaque
params(not flattened), andintake_kinds/cost_profile/defaultsride at the genre level, so a consumer needs no app-specific knowledge to render the catalog.- Return type:
- class nw.GenreOp(name, fn, title, description='', effect='write', runs='now', host_params=(), max_upload_bytes=None, spends=False)[source]#
Bases:
objectOne operation a genre offers on its projects — a row a host builds surfaces from.
fn(project, **params) -> dictdoes the work;paramsand the result are JSON-able.titleis a short plain-language imperative (“Find where each video fits”) — it becomes a button or command title.descriptionis model-facing and defaults tofn’s docstring.effectis one ofGENRE_OP_EFFECTS,runsone ofGENRE_OP_RUNS.Construction validates everything, including that every parameter after the project is annotated with a JSON-describable type — a bad op fails where it is declared, not in front of a user.
host_paramsnames parameters the HOST supplies, never a client: a streamed upload’s temporarypath, its originalfilename. They are left out ofparams_schema(so a client that sends one fails validation — the schema isadditionalProperties: false), they must be keyword parameters offn, andrun()passes them through from itshostargument without validating them — the host produced them. A host readshost_paramsinto_dict()to know which ops take an upload. This is the boundary that stops a generic op route from letting a client name a file on the server.Two host parameters have agreed meanings: an upload’s
path(withmax_upload_bytes, the op’s own ceiling the host enforces WHILE streaming, before the op ever runs) andCANCEL_PARAM(a zero-argument callable the op polls, raisingGenreOpCancelled). A deliberate refusal is aGenreOpRefused.spendssays the op MAY spend money (it can reach a paid API). The federation’s rule is that an unknown cost forces approval, so a host must route aspendsop through its money approval — or not offer it.False(the default) is a claim the genre makes: nothing this op calls bills anyone.>>> def _rename(project, *, title: str, loud: bool = False) -> dict: ... '''Rename the project.''' ... return {"title": title.upper() if loud else title} >>> op = GenreOp("rename", _rename, title="Rename it") >>> op.description, op.effect, op.runs ('Rename the project.', 'write', 'now') >>> sorted(op.params_schema["properties"]), op.params_schema["required"] (['loud', 'title'], ['title']) >>> op.run(None, {"title": "x", "loud": True}) {'title': 'X'}
An op taking an upload, whose
pathonly the host may give:>>> def _ingest(project, *, path: str, name: str = "") -> dict: ... return {"path": path, "name": name} >>> up = GenreOp("ingest", _ingest, title="Add a file", host_params=("path",)) >>> list(up.params_schema["properties"]), up.to_dict()["host_params"] (['name'], ['path']) >>> up.run(None, {"name": "a"}, host={"path": "/tmp/upload"}) {'path': '/tmp/upload', 'name': 'a'}
- property params_model#
The pydantic model of the op’s parameters (
extra="forbid").
- property params_schema: dict#
JSON Schema (an object,
additionalProperties: false) of the parameters.
- run(project, params=None, *, host=None)[source]#
Run the op on
project:params(the CLIENT’s, validated againstparams_schema) plushost(the host’s, passed through as given).hostmay carry only the op’s declaredhost_params; anything else is a host bug and raisesTypeError, as does a required host parameter the host did not supply. A client parameter that is missing, unknown (a host parameter included) or mistyped raisespydantic.ValidationError.- Return type:
- validate_params(params=None)[source]#
paramschecked and coerced againstparams_schema.Raises
pydantic.ValidationError(aValueError) on a missing, unknown or wrongly typed parameter. Only the parameters the caller passed are returned, so the op’s own defaults stay the op’s.- Return type:
- exception nw.GenreOpCancelled[source]#
Bases:
ExceptionAn op stopped because the host asked it to (its
should_cancelreturned True).Not a refusal and not a failure: a host that cancelled a job records it as cancelled. Raised by the op, between steps, when it was given a
CANCEL_PARAMhost parameter that says stop.
- exception nw.GenreOpRefused[source]#
Bases:
ValueErrorAn op DELIBERATELY declined — no song yet, an unknown clip, an edit that does not hold. The base a genre derives its refusal type from (muvid’s
FootageError).A host maps exactly this to a client-facing refusal (reelee:
422); any other exception out of an op — a plainValueErrorincluded — is a bug, reported as one (500with the traceback logged). AValueErrorso code already catching that keeps working.
- class nw.PlanQuote(*, total_usd, status, as_of_total_usd, repriced, reason='')[source]#
Bases:
objectToday’s price for a persisted plan, with the stale figure alongside.
The stale figure is kept — as
as_of_total_usd, explicitly named “as of then” — because an audit surface wants to show the movement. What it must never do is present it as current; that is whattotal_usdandstatusare for.- as_of_total_usd: float | None#
What the persisted plan said,
Noneif it already said unknown.A fact about the moment it was written. Render it labelled as such, or not at all.
- property basis_changed: bool#
True when a rate table moved underneath at least one call.
The audit answer the frozen number could never give: it separates “the price changed because the table changed” from “the price changed because the plan did”. Read it beside
status— achangedwith thisFalseis a caller quoting different quantities, not a repricing event.
- property delta_usd: float | None#
total_usd - as_of_total_usd, orNonewhen either is unknown.Nonerather than0.0: a plan that lost its price did not move by zero.
- property has_unknown_costs: bool#
True when the total cannot be known — the gate’s refusal condition.
The same judgement as
falaw.Plan.has_unknown_costs, made at re-quote time rather than at plan time.
- reason: str#
Why the whole quote is unknown, when that is the situation.
Set by
unquotable()(the thing handed in was not a plan) and byquote_render_decision()(the payload contradicts itself). Empty for an ordinary re-quote, where the per-call reasons live onrepricedinstead.
- repriced: RepricedPlan#
falaw’s per-call diff —
status,basis_changed,reasonper call. Read it for the audit view;total_usdis the headline.
- status: Literal['unchanged', 'changed', 'unknown']#
Which of the three cases this plan fell into — see
QuoteStatus.
- class nw.Project(root, *, auto_migrate=True)[source]#
Bases:
objectA folder-backed nw project.
Construct from a path (must exist + must contain
project.json); useProject.init()to bootstrap a new project on disk.- add_character(name, *, description='')[source]#
Add a character (idempotent: re-adds update the description).
Re-adding updates only the description: any stable attributes already recorded on the character (costume, palette anchors,
do_not_do…) are carried over, so calling this again is not a way to lose them.- Return type:
- classmethod init(root, *, title='', song=None, force=False)[source]#
Create a new project on disk and return the
Projectfacade.- Parameters:
root (
str|Path) – Folder to create. Must not exist (or passforce=Trueto overwrite an empty folder).title (
str) – Optional human-readable title; defaults to the folder name.song (
Union[str,Path,None]) – Optional path to a master audio file. When given, the file is copied into<root>/song/and registered in the spec.force (
bool) – When True, accept an existing folder if it’s empty (noproject.json); refuse if a project already exists there.
- Return type:
- list_character_images(name)[source]#
Return all images associated with a character, with provenance flags.
Walks
characters/<name>/refs/andcharacters/<name>/selected/. Marks the file the card’sreference_image_pathpoints at asis_anchor=True.- Return type:
- log_decision(kind, **payload)[source]#
Record a typed decision in the project graph + the JSONL audit log.
Decisions are project-local provenance: which character anchor was picked, which model overrode the default, why a shot was retried. Both surfaces stay in sync:
The lacing graph (
decisiontier, body schemaannot://schema/decision/v1) is the SSOT — reelee will surface these in inspector / network views..nw/decisions.jsonlcontinues as a tail-grep-able audit trail.
- Return type:
- read_spec()[source]#
Read the project spec, synthesizing from the graph for graph-native fields.
Project-level metadata (title, song, global_style, notes, schema_version) lives in
project.json. Sections, shots, characters, and environments live in the lacing graph and are synthesized into the returnedProjectSpecfor back-compat with code that still reads viaread_spec().- Return type:
- read_summary()[source]#
Return a typed read view of the project — all the facts at once.
- Return type:
- resolved_genre()[source]#
The
{genre, template, params}envelope this project was created as.The read accessor for the envelope
nw.genres.initialize_genre()persists at creation (nw#32) — same shape asnw.genres.resolve_genre()returns, so consumers reuse or diff the effective creation params without re-deriving them.Nonefor a project with no recorded genre (created before nw#32, or not through the genre machinery).
- resumption_brief(*, recent=10)[source]#
Return a “where we left off” snapshot for the start of a session.
Pure data, fully offline: a decision-log tail, what is reachable downstream of the last change, recorded spend, and a deterministic list of suggested next actions. reelee renders it as prose and injects it as the opening context of a session.
Read
ResumptionBriefbefore trusting the numbers — two of them are upper bounds, and the brief says so incaveatsrather than only in a docstring.- Parameters:
recent (
int) – How many decision-log entries to include, most recent last.- Return type:
- set_character_anchor(name, image_path)[source]#
Pick an existing image as the character’s anchor (lipsync seed, etc.).
Returns the updated card. Raises if the image isn’t under the character’s folder, since cross-character anchoring is almost always a mistake.
- set_song(source, *, copy=True)[source]#
Register an audio file as the project’s master song.
Probes duration / sample-rate / bitrate via
mixing.audio.Audioif available, else leaves them at 0 (the spec accepts the SSOT-only path with placeholder metadata).- Return type:
- total_spend_usd()[source]#
Sum the cost recorded on every decision in the project.
Prefers each decision’s actual per-artifact
cost_usdand falls back to itstotal_estimated_cost_usdwhen no artifact costs were recorded.Deliberately not re-quoted. This is money that was billed, and a receipt is not a quote: re-pricing it through
nw.pricing.current_quote()would rewrite history at today’s rates. The consequence is that the estimate-based fallback is an as-of-then figure — falaw’s tables have moved since (0.0.46 tenfold upward on premium LLM calls), so a decision that recorded no artifact costs contributes what it was quoted then, not what the same render would cost now. That is the right answer for “what did this project spend”; it is the wrong one for “what would this cost today”, andnw.pricing.quote_render_decision()is what answers that (nw#74).Walks every store scope (graph, storyboard, alignment), not just the project graph: a decision written to the storyboard scope is money that was spent, and counting only one scope would silently under-report while
caveatsclaims an upper bound.This is an upper bound on money usefully spent. A render that was billed and then failed is recorded exactly like one that succeeded, because nothing in the execution layer records a per-branch outcome yet. When failure isolation lands, this should sum over the produced branches only — and this method is the one place that changes.
- Return type:
- class nw.ProjectGraph(project_root)[source]#
Bases:
objectTyped read/write facade over the project’s lacing graph store.
Use
Project.graph()to get one rather than constructing directly. Each method opens-and-closes the underlying store so concurrent reads/writes from different processes are safe (SqliteStore is file-locked).- add_annotation(ann, *, instance_id=None, call_index=None)[source]#
Write one annotation to the project graph, plus its verifying trace.
Registers
ann.tierif it isn’t a known tier yet —SqliteStoreenforces a foreign key ontier, so writing under a fresh tier (e.g. a Transform output kind) would otherwise fail.add_tieris idempotent, so this is a no-op for the built-in project tiers.This is the single choke point every derived annotation in nw and reelee passes through, which is why the verifying trace (
nw.bodies.verifying_trace) is recorded here rather than inderive_provenance: that helper returns aProvenanceand has no store to write to, and threading one in would change a signature with production callsites in three repos. Writing at persist time also covers the paths that build aProvenanceby hand.Annotations with no
was_derived_fromparents get no trace — there is nothing to verify, and they are nobody’s descendant.It is also where a matching
add_unproduced_output()record is retired (nw#44): a successful write is proof the thing that record described has now been produced.instance_id/call_indexidentify this write’s unit precisely (seenw.bodies.unproduced_output’s module docstring for the key); omitinstance_idonly when it is not known — the retirement then falls back to(transform_name, call_index, upstream), which still cannot distinguish two DIFFERENT units sharing both an upstream set and acall_index(the residual case the module docstring names), so it may retire nothing, or (rarely) the wrong record.**Bypassing this method (a raw
store.add) leaves a matching unproduced-output record in place** — it reads as a live blocker after reload even though this write produced the thing it described.- Return type:
- add_unproduced_output(skeleton, *, transform_name, status, reason='', error=None, blocked_by=(), instance_id=None, call_index=None, was_attributed_to=None)[source]#
Persist why a planned output was never produced (nw#44).
Mirrors one entry of
TransformResult.failed/.blocked(nw.transforms.FailedOutput) — pass itsskeleton,status,reason,errorandblocked_bystraight through. Written undernw.bodies.UNPRODUCED_OUTPUT_BODY_SCHEMA_URI, never underskeleton.body_schema_uri(see the module’s docstring on why that tier is reserved for what was actually produced).instance_id(a fan-out unit’swork_item_instance_id()) andcall_index(this output’s position within itsexecute()call’s skeleton tuple) together form the identityadd_annotation()retires by — seenw.bodies.unproduced_output’s module docstring for the full key. Dedupes on that identity: a record already outstanding for the same key is removed before this one is written, so a unit failing twice leaves one current record, not two.Parentless, like a verifying trace.
reasonnever stores raw exception text — see the module docstring’s “reason never carries raw exception text”. Whenerroris given, the originalreasonis logged (logging.getLogger("nw.graph"),WARNING) and the persistedreasonbecomes a fixed sentence namingerror’s type.- Return type:
- append_decision(body, *, was_attributed_to='user:nw', was_derived_from=())[source]#
Append a decision; never replaces an existing one (the log is append-only).
- Return type:
- genre_envelope()[source]#
The recorded genre envelope, or
Nonefor a genre-less project.The read half of
set_genre_envelope(); consumers should reach it throughnw.Project.resolved_genre(), which returns the plain-dict envelope shapenw.genres.resolve_genre()produces.- Return type:
- remove_annotation(annotation_id)[source]#
Remove one annotation from the project graph, with its verifying traces.
The delete counterpart of
add_annotation()(nw#36): every trace whosefor_annotation_idnamesannotation_idgoes with it, so a deletion never leaves a sidecar behind — an orphaned trace is inert for freshness but grows the store without bound, and if the id is later re-used it can even answer a freshness query from digests recorded for content that is no longer there.Only the project graph store is touched — the store
add_annotation()writes to. Annotations living in the other scopes (storyboard, alignment) are removed by their own facades;collect_orphan_traces()is the project-wide backstop.- Return type:
- Returns:
Whether
annotation_iditself was present (its traces are removed either way).
- set_genre_envelope(body, *, was_attributed_to='agent:nw.genres')[source]#
Record the resolved
{genre, template, params}envelope; return its id.Singleton per project (nw#32): the tier is the identity, so re-initializing replaces the recorded envelope in place — the annotation id is stable across replacements, like every entity upsert. A no-op write (same envelope) writes nothing.
- Return type:
- unproduced_outputs(*, transform_name=None)[source]#
Every unproduced-output record still outstanding, oldest first.
A record disappears the moment
add_annotation()writes a real output for the same identity, or anotheradd_unproduced_output()call for the same identity supersedes it (dedupe) — what this returns is exactly “still missing”, survives a reload, and is not a cache of any in-memoryTransformResult.- Return type:
- upsert_character_ref(body, *, was_attributed_to='user:nw')[source]#
Insert-or-update the character ref with this
name; return its id.The id is stable across edits — see
_upsert().- Return type:
- upsert_environment_ref(body, *, was_attributed_to='user:nw')[source]#
Insert-or-update the environment ref with this
name; return its id.The id is stable across edits — see
_upsert().- Return type:
- class nw.ProjectSpec(**data)[source]#
Bases:
BaseModelThe top-level project SSOT, persisted as
project.json.Field names and order are chosen to round-trip identically with muvid’s ProjectSpec for
schema_version=1, so the_bells_v* fixtures (and any other muvid-shaped project) load and re-save without churn.
- class nw.ProjectSummary(**data)[source]#
Bases:
BaseModelTyped read view of a project — what
muvid statusprinted, but typed.Returned by
Project.read_summary(). Holds the small facts the user most often wants: title, root, song path, counts of characters / shots / sections / output, plus a coarse “stages_done” list naming the lifecycle stages that have been reached.
- class nw.Resolver(*args, **kwargs)[source]#
Bases:
Protocolresolve(email, project_id, artifact_id) -> Deliverable— a genre’s half.artifact_idmay be a raw id OR a reference the genre accepts (seeparse_ref()); resolving both is the genre’s job, because only it knows the ordering that givescut 4its meaning.Raises
KeyErrorwhen nothing resolves (the host answers 404) andPermissionErrorwhen it resolves but is not the caller’s (403). Never let a server path escape in the message.
- class nw.ResumptionBrief(**data)[source]#
Bases:
BaseModelA “where we left off” snapshot, returned by
nw.Project.resumption_brief().Pure data: no fal calls, no LLM, no network. reelee renders it as prose and injects it as the first tool-result of a session.
**The field names are chosen to be honest about what nw can currently measure**, because a confidently wrong number is worse than no number:
downstream_of_last_authored_changeis not “stale”. It isnw.descendants_of— pure provenance reachability, comparing no content and no timestamp — so this set includes everything already regenerated since the change. It is an upper bound on what needs attention, and it is named for what it measures.nw.stale_afteris the narrower answer and it now cuts off early (nw#24), so switching this field to it would return a smaller and correct set. That is deliberately not done here: the field would then be named for the wrong measurement, and which of the two a resumption brief should show is nw#7’s call, not nw#24’s. Callers who want the exact set can callnw.stale_afterwithlast_authored_change_id.The walk starts at the last authored change — the most recent annotation the user wrote (a shot, a section, a character or environment ref), never one a Transform derived. Walking from “the newest annotation” instead would be inverted: the newest node in a provenance graph is by construction a leaf, so its descendant set is empty in exactly the case the field exists for.
total_spend_usdsums every recorded render decision across every store scope. Nothing records per-branch outcomes yet, so a render that failed after being billed is counted here exactly like one that succeeded. Also an upper bound.
caveatscarries those qualifications as data — so a consumer renders them next to the numbers instead of rediscovering them.
- class nw.Secrets(mapping=None, /, **named)[source]#
-
A read-only
{provider_name: key}mapping that never prints or persists.Construct from a mapping, keywords, or both;
None/empty values are dropped (absent means “not supplied”), a non-strkey or value is aTypeError— a secret is text, and an int or a bytes object here is a caller bug worth failing on.
- class nw.SectionSpec(**data)[source]#
Bases:
BaseModelA non-overlapping span of the project’s master timeline.
labelis free-form (“intro”, “verse”, “chorus”, “scene-1”, “act-2”, …) so different apps (music-video, explainer, podcast-clip) can use their own taxonomy.
- class nw.ShotPreparation(project_root, shot, shot_dir, audio_slice_path, audio_slice_url='', character_anchor_paths=<factory>, character_anchor_urls=<factory>, environment_anchor_path=None, environment_anchor_url='', lyric_lines=<factory>, storyboard_prompt='', global_style='')[source]#
Bases:
objectLocal-only inputs for rendering a single shot.
Building a ShotPreparation is a pure-filesystem operation: no fal calls that bill, no network beyond fal-storage uploads (which are free). The upload step happens here so the resulting URLs are stable and the cache key derived from them is honest.
Multiple downstream consumers (the planner, an inspection report, a UI preview) can read this without re-doing the audio extraction.
- audio_slice_url: str#
fal-storage URL of the audio slice (set by
prepare_shot()when a fal API key is available; empty otherwise — strategies that need URLs will raise descriptively).
- class nw.ShotSpec(**data)[source]#
Bases:
BaseModelA timeline-locked visual unit.
[start_s, end_s)is half-open.render_strategyis an open string rather than a closed Literal, so apps can register their own strategies vianw.renderers.register_strategy()(Phase 1b.3) without modifying the schema.
- class nw.SongInfo(**data)[source]#
Bases:
BaseModelMetadata for the master audio file.
Compatible with muvid’s SongInfo by field name and type.
- class nw.Strategy(*args, **kwargs)[source]#
Bases:
ProtocolRender-strategy contract.
- class nw.Template(slug, title, description='', params=<factory>)[source]#
Bases:
objectA named preset (“subgenre”) within a genre — a filled-in default config.
AV-general: the substrate owns the Template’s identity (
slug/title/description) and carries an opaqueparamspayload it does not interpret. The app that owns the genre puts meaning inparams(reelee:{"output_intent": ..., "flavor": ...}; braidio:{"format_id": ...}) and validates/resolves it. Frozen + hashable (paramsis excluded from identity and normalized to an immutable mapping), so a Template can live in aGenre.templatestuple without breaking the genre’s__hash__.>>> t = Template(slug="cinematic_clip", title="Cinematic clip", ... params={"flavor": "fal.cinematic"}) >>> t.params["flavor"] 'fal.cinematic' >>> t.to_dict()["params"] {'flavor': 'fal.cinematic'}
- class nw.Transform(*args, **kwargs)[source]#
Bases:
ProtocolA swappable, costed function from A-annotations to B-annotations.
Implementations usually subclass
BaseTransformrather than satisfying this Protocol directly, but the Protocol is the contract the registry and orchestrator depend on.- execute(project, plan, skeleton, *, use_cache=True, force=False, on_failure='halt', unit_instance_id=None, secrets=None)[source]#
Run
plan, completeskeleton, write to the graph, return result.force=Truebypasses the cache read (the “regenerate this” affordance) and keeps the cache write, so a forced re-run stays reusable instead of billing the next consumer again (nw#72).use_cache=Falsemeans “do not touch the cache at all”; pairing it withforce=TrueraisesCacheModeConflict.on_failureselects the failure policy — seeOnFailure."halt"is the default so no existing caller changes behaviour.Warning
on_failureis newer than some implementations. A Transform that overridesexecute()and predates nw#25 does not accept the keyword, and this Protocol isruntime_checkable, which compares method names and not signatures — soisinstancestill passes and theTypeErrorarrives at call time. reelee has ~18 such overrides (thorwhalen/reelee#299 tracks the migration).Until they are migrated, a caller iterating over arbitrary registered Transforms should pass
on_failureonly to ones it knows accept it, or catchTypeError. Everything inheritingBaseTransform’sexecute— the common case — already does.unit_instance_id(nw#44) is the same accepts-it-or-not shape, newer still:fan_out_execute()passes a fan-out unit’s ownwork_item_instance_id()here, when accepted, as the precise identity an unproduced-output record is retired by. Not a Transform’s concern beyond forwarding it tonw.graph.ProjectGraph.add_unproduced_output()/add_annotation()—BaseTransformalready does.secretsis the third keyword of that shape, and the one that carries a credential: the caller’s per-call, bring-your-own API key(s), as a read-only{provider_name: key}mapping (nw.Secrets; every entry point coerces a plain mapping to it). It is the one input that is deliberately not an input — it never enters the Plan, the skeleton, provenance, the cache identity, a run record, the job index or a log line — and a Transform that spends a caller’s credential reads it here and nowhere else.None(the default, and what every call did before the seam existed) means “resolve from the process environment”.fan_out_execute()andnw.jobs.enqueue()pass it accepts-it-or-not, so an override that has no key to spend never sees it;BaseTransformbinds aFAL_SECRETas the fal credential for the duration of the call, andfan_out_executebinds it around every unit whether or not the keyword is accepted. An override that declares the keyword and is called directly receives whatever the caller passed — run it throughnw.secrets.as_secrets()before logging or formatting it. Seenw.secrets.- Return type:
- generate_when: str#
When this Transform’s fan-out cardinality is knowable (nw#26):
"static"(the work-item list is derivable before the run — a pre-flight estimate is a real number) or"dynamic"(cardinality is known only after an upstream call returns — the only honest pre-flight estimate is unknown, which forces approval). Undeclared defaults to"dynamic": fail expensive-looking. Seenw.transforms.fanout.GenerateWhen.
- impl_version: str#
Behaviour version of this implementation (nw#27).
“Same interface, changed behaviour” — a prompt-template edit, a post-processing change — bumps this **without renaming the registry key** (the name denotes the capability; a different capability gets a different name). It is a lock, not a receipt: it enters provenance (
transform:<name>@<impl_version>) and, when it is notDFLT_IMPL_VERSION, the falaw cache identity of every call executed throughBaseTransform.execute()— so a behaviour change cannot keep serving results minted by the old behaviour. A Transform that overridesexecutemust applystamp_transform_identity()itself; the lock only locks what passes through it.
- input_kinds: tuple[str, ...]#
Body-schema URIs this Transform reads. The first is the primary kind; the rest are context kinds.
- is_batch: bool#
How
plan()consumesinputs.primary.False(one-to-one):plan()operates on a single primary annotation (inputs.primary[0]) — e.g.beat_to_panel, one beat in, one panel out. A caller wanting to apply it across many annotations callsplan()once per annotation and composes the Plans.True(batch):plan()consumes all ofinputs.primaryat once — e.g.extract_characters(every beat → an LLM call) orclips_to_animatic(every clip → one animatic). A caller passes the whole set in a singleplan()call.This is the property an orchestrator needs to fan a Transform across a project’s annotations correctly — it can’t be inferred from
input_kinds.
- name: str#
Globally-unique identifier in
transforms.
- params_model: type#
Pydantic model class for this Transform’s per-call params;
type(None)means no params. On the Protocol — not justBaseTransform— so anything reading a Transform through the contract (the capability catalogue, an MCP tool builder, the CLI dispatcher) can rely on it (nw#27).
- class nw.TransformInputs(primary, context=<factory>)[source]#
Bases:
objectThe annotations a Transform consumes.
primaryis the subject of the operation — a single-element tuple for one-to-one Transforms, many for batch Transforms (e.g.clips_to_animaticconsumes every clip).contextis side material keyed by kind name, so a Transform that declaresinput_kinds=(beat, character-ref)receives the Beat inprimaryand the CharacterRefs incontext["character-ref"].
- class nw.TransformResult(annotations, artifacts=(), cost_usd_actual=0.0, cache_hit_savings_usd=0.0, has_unknown_costs=False, failed=(), blocked=())[source]#
Bases:
objectThe outputs of a Transform’s
execute().- annotations: tuple[Annotation, ...]#
The completed output annotation(s), written to the project graph.
- artifacts: tuple[Artifact, ...]#
The
lacing.Artifacts produced (images, videos, audio, json …). Annotations reference these byartifact_idin their bodies.
- blocked: tuple[FailedOutput, ...]#
Outputs never attempted because an upstream call failed.
- cache_hit_savings_usd: float#
USD not spent because a call was served from cache.
Also observed rather than predicted — this changed source at the same time as
cost_usd_actual, fromPlan.cache_hit_savings_usd(what planning guessed would hit) to what actually hit.
- cost_usd_actual: float#
USD billed during execution, over the calls that **succeeded and were not cache hits** — falaw’s observed
ExecutionReport.estimated_spend_usd. Since falaw#26 the per-Artifactcost_usdis also stamped from the observed outcome, so the two now agree; the report stays the source here because it is the run-level truth (and carrieshas_unknown_costs), not because the artifacts lie anymore.A lower bound, despite the name. falaw runs its converter inside the unit of work, after the billed call, so a call fal charged for can still end as
status="failed"— and a failed call is excluded here, because falaw cannot know whether the vendor billed it and inventing a number would be worse. Under"halt"the run aborted anyway; under"isolate"it continues, so a caller accumulating this across a fan-out with failures will under-count. Readfailedalongside it.
- failed: tuple[FailedOutput, ...]#
Outputs whose own call failed. Empty unless
on_failure="isolate".
- exception nw.UnknownGenreOpError[source]#
Bases:
KeyErrorgenre_opwas asked for a name the genre does not register.A
KeyErrorso a caller that already catches unknown-key lookups keeps working; its message names the known ops, because “no such op” with no menu is a dead end for a model choosing among them.
- class nw.UnproducedOutputBodyV1(**data)[source]#
Bases:
BaseModelBody of an unproduced-output record.
statusmirrorsnw.transforms.fanout.UnitStatus’s two unproduced cases:"failed"(the call itself failed) or"blocked"(an upstream call in the same plan failed first).upstreamis stored for thecall_indexfallback identity (see the module docstring); it is not itself a sufficient key.
- exception nw.ValidationError(report)[source]#
Bases:
AssertionErrorRaised by
ValidationReport.raise_if_failed().
- class nw.ValidationReport(target, results=(), elapsed_s=0.0)[source]#
Bases:
objectEverything a
validate()run produced.- target#
what was validated.
- results#
one per check that was selected, in the order they were run.
- elapsed_s#
wall-clock for the whole run.
- property ok: bool#
No failing findings and nothing that failed to run.
A check that errored is not a pass. Callers gating a publish on this get the conservative answer without having to remember to ask for it.
- raise_if_failed()[source]#
Return self, or raise
ValidationError— for a hard gate.- Return type:
- class nw.WorkItem(**data)[source]#
Bases:
BaseModelOne unit of a fan-out — the PDG-shaped work item (nw#26).
scope_intervalputs time in the demand, not the graph (Nuke’s model): a pipeline that stores frame ranges in nodes must edit the graph to change a range; one that stores them in the request does not. It is an interval rather than a point because lacing’sTimeIntervaladmitsstart == endas a valid point annotation — the point-demand case is already representable, no second demand type needed.- property instance_id: UUID#
This item’s instance id is only defined for a transform — use
work_item_instance_id(). This property exists to raise a helpful error instead of lettingitem.instance_idlook like it could mean something transform-free.
- nw.all_stale(project_root)[source]#
Every annotation that is currently stale, regardless of cause.
stale_verdicts_all()with the fresh verdicts dropped — the snapshot counterpart ofstale_after(), and the primitive a freshness indicator or a “regenerate everything stale” verb should sit on instead of re-deriving its own definition of the word.- Return type:
list[Annotation]
- nw.annotations_at_tier(project_root, tier)[source]#
Return every annotation at the given tier across all of the project’s stores.
Useful for reelee views that lens on a single annotation kind:
annotations_at_tier(root, "shot")returns every shot annotation regardless of which store it lives in (project graph vs. storyboard vs. alignment).Asks each store for the tier rather than deserializing every annotation and filtering.
by_tieris a real indexed query on all four lacing backends and was called by nothing in nw; this walked the whole project to answer a question about one tier. Measured on 2000 annotations with 200 at the tier: 33.5 ms → 3.9 ms, and the gap widens with project size because one is O(all rows) and the other O(matching).- Return type:
list[Annotation]
- nw.apply_to_projects(roots, fn, *, parallel=False)[source]#
Apply
fnto each project atrootsand collect the results.- Parameters:
roots (
Iterable[str|Path]) – Iterable of project roots. Each must point to an existing nw project.fn (
Callable[[Project],TypeVar(T)]) – Callable taking aProjectand returning anything. Use this for per-project operations: parsing a script, estimating cost, rendering, gathering reports.parallel (
bool) – When True, runfnin a thread pool. Useful whenfnis I/O- or API-bound (e.g. a render). When False (default), runs sequentially in submission order — the safest semantics.
- Return type:
- Returns:
A list of
fn(project)results in the same order asroots.
Examples
>>> # Estimate cost of all four sibling experiments without rendering: >>> # totals = apply_to_projects(roots, lambda p: estimate_render_cost(p)) >>> # Apply the same script to all of them after a refactor: >>> # apply_to_projects(roots, lambda p: parse_script(p))
- nw.as_secrets(secrets)[source]#
Coerce a caller-supplied mapping to
Secrets; empty →None.The nw entry points —
nw.BaseTransform.execute(),nw.fan_out_execute(),nw.jobs.enqueue()— run every incomingsecretsthrough this, so below them a Transform only ever sees the redacting type. A Transform that overridesexecuteand is called directly gets whatever the caller passed: an override that logs or formats itssecretsshouldas_secretsfirst (or the caller should hand it aSecrets), because a plaindictprints its values.>>> as_secrets(None) is None True >>> as_secrets({"fal": None}) is None True >>> as_secrets({"fal": "k"}) Secrets(<1 redacted: fal>)
- nw.backfill_traces(project_root, *, execute=False)[source]#
Bless a pre-trace project so the verifying-trace rule can read it (nw#58).
On a project whose annotations predate nw#24’s trace-writing, the verifying-trace rule is a behavior change, not a wrapper: every derived annotation reads stale (
no-trace), and nothing heals them — regen skips non-Transform-produced annotations, and body updates write no trace. This writes, for each derived annotation with no trace, a trace against its parents’ CURRENT content digests: blessing at-rest state as fresh, which is exactly the old timestamp rule’s verdict for at-rest data — semantics-preserving at the moment of migration.Report-only by default. The first thing run against a real user’s projects should be a read; pass
execute=Trueto write. Idempotent either way: an annotation that already has a usable trace is counted inalready_tracedand never rewritten, so a partial run is simply re-run rather than reasoned about. One caveat keeps “a read” honest at the FILE level: stores are opened withmigrate=True, so a store stamped at an older lacing schema is upgraded ON OPEN even under the default — run this only where the build that serves these stores is already the new one (the D-vg-mcp-10 deploy ordering; a pre-migrated file makes an old serving build refuse it).Not blessed, by design — the old rule’s own stale verdicts. A parent edited AFTER the annotation was derived is exactly the pending-regeneration state the old timestamp rule reported stale; blessing it would silently clear a real signal. Such annotations land in
skippedand stay no-trace-stale — same verdict, and a later regen writes the true trace through the chokepoint. (Exact preservation in the other direction is impossible — the trace rule recurses where the old rule was one-hop — but that residual over-reports, the directionnw.freshnessdocuments as the safe one.)What is deliberately NOT blessed, each with a
skippedentry naming the annotation and the reason:a parent that no longer exists — that annotation is genuinely
upstream-missing, and a fabricated trace would hide a real hole;a parent list carrying artifact refs (64-hex asset ids) — the annotation-tier trace cannot cover them (nw#55), and a trace over a subset of the parents reads as stale anyway (“upstream set is not exactly
was_derived_from”), so writing one would be decoration;a parent whose body cannot be digested — broken data at the producer, same rule as
build_verifying_trace().
Parentless annotations are never stale by contract, so they are counted (
parentless) and need nothing.Returns one project’s report — callers migrating a tree of projects loop and get per-project summaries for free:
{"project", "stores_found", "examined", "backfilled", "already_traced", "traced_unusable", "parentless", "skipped": [{"annotation_id", "reason"}, ...], "executed"}.backfilledis the count of traces written whenexecute=True, and of traces that WOULD be written otherwise;executedsays which reading applies. Readstores_foundbefore trusting zeros: a typo’d or empty root reports all-zero COUNTS, andstores_found == 0is what distinguishes “nothing to migrate” from “not a project here”.traced_unusablecounts annotations whose existing trace the freshness rule cannot use (foreign digest scheme, mismatched upstream set) — permanently stale, deliberately not overwritten here; expect it to be zero on genuine pre-trace projects. A broken project (corrupt store, unreadableproject.json) RAISES rather than reporting — catch per root in a tree loop so one damaged project is recorded, not silently averaged away.- Return type:
- nw.can_place_genre_project(slug)[source]#
True iff
slug’s registered factory accepts host placement.The question a host asks before offering “create a project of this genre here”: a genre whose factory predates
PLACEMENT_ARGcan still be created, but only in its own app’s workspace — where the host cannot address it. False for an unregistered genre.- Return type:
>>> def _old(caller, project_id, *, title, template, params): ... return {"project": None} >>> def _new(caller, project_id, *, title, template, params, projects_dir=None): ... return {"project": None} >>> _ = register_genre_project_factory("_place_old", _old) >>> _ = register_genre_project_factory("_place_new", _new) >>> can_place_genre_project("_place_old"), can_place_genre_project("_place_new") (False, True) >>> can_place_genre_project("_place_nope") False >>> del genre_project_factories["_place_old"] >>> del genre_project_factories["_place_new"]
- nw.clone_project(src_root, dst_root, *, preserve=('song', 'lyrics', 'characters'), reset=('script', 'shots', 'output', '.nw'), title=None, force=False)[source]#
Clone an nw project to a new root.
- Parameters:
src_root (
str|Path) – Path to an existing nw project (must containproject.json).dst_root (
str|Path) – Destination path. Must not exist (or passforce=Trueto overwrite).preserve (
Iterable[str]) – Subtrees ofsrc_rootto copy verbatim intodst_root. Default:("song", "lyrics", "characters").reset (
Iterable[str]) – Subtrees ofdst_rootto (re)create as empty after copying. Default:("script", "shots", "output", ".nw").title (
Optional[str]) – New title for the cloned project. Defaults todst_root’s folder name.force (
bool) – When True, overwrite an existingdst_root(refuses by default to avoid clobbering work).
- Return type:
- Returns:
ProjectSummaryof the cloned project.
- nw.collect_orphan_traces(project_root)[source]#
Drop verifying traces whose target annotation no longer exists.
The backstop for deletion paths that do not (or cannot) go through
remove_annotations_with_traces()— a directstore.remove, an external tool, history from before deletions collected traces (nw#36). Walks every store under the project; a trace is an orphan when itsfor_annotation_idresolves in none of them. Idempotent, and safe to run as routine maintenance: an orphaned trace is never consulted bynw.freshness, so removing it changes no freshness answer.A trace whose body cannot be read (not a dict, unparseable target id) is left in place: it may be an orphan, but deleting what we cannot identify is worse than carrying it.
- nw.compose_report(project, *, freeze_sample_fps=4.0, duration_tolerance_s=0.1)[source]#
Per-shot reports + final-compose inspection in one call.
- Return type:
- nw.cost_records(plan)[source]#
The JSON-able per-call cost rows nw persists in a decision payload.
A serialized call cannot be re-quoted from
applicationandargumentsalone — the quantity hints that priced it are estimator-only and never reach the wire arguments. Carryingcost_basisalongside the frozen figure is what makes the row re-quotable later byquote_from_cost_records().cost_basisis omitted when unset, exactly as falaw omits it, so a row written by a caller that records no basis is byte-identical to what nw wrote before nw#74.>>> from falaw import CallPlan, Plan >>> call = CallPlan(tool="t", application="a", arguments={}, ... output_kind="video", estimated_cost_usd=1.0) >>> sorted(cost_records(Plan(calls=(call,)))[0]) ['application', 'cache_status', 'estimated_cost_usd', 'tool']
- nw.create_genre_project(genre, caller, project_id, *, title=None, template=None, projects_dir=None)[source]#
Create + seed a new project for a PLUGGED-IN
genreincaller’s space.The create-counterpart to
resolve_genre()(params) +initialize_genre()(seed): resolve → the genre’s factory (create in the caller’s space) → initialize. All-or-nothing — if seeding fails the just-created project is rolled back (its folder removed) and the error re-raised. Returns the factory’s JSON-able info (minus the liveproject) plus the resolved{genre, template, params}envelope, so the caller can immediately address the new project (e.g. byproject_id). The same envelope is persisted on the project by the initialize step (nw#32) — under the all-or-nothing guarantee, since a failure there rolls the whole create back — so the association survives this call returning; read it back vianw.Project.resolved_genre().projects_diris the placement: the directory to create the project folder in, so a host that will serve the project can put it where its own resolver looks.None(the default) leaves placement to the genre’s app, which is the pre-placement behaviour. Askcan_place_genre_project()first, or handle theTypeErrora pre-placement factory raises here — the request is refused **before any filesystem effect**, never quietly satisfied somewhere else.Raises
KeyErroron an unknown genre/template, or a genre with no registered factory (a host’s own genre is created by the host, not via this path);TypeErrorwhenprojects_diris given for a factory that does not accept it;RuntimeError(after rolling the create back) when a factory accepted a placement and did not honour it.- Return type:
- nw.current_quote(plan, *, pricers={'llm_rates': Pricer(quote=<function _quote_from_llm_rates>, table='falaw/data/llm_rates.json', version=<functools._lru_cache_wrapper object>), 'model_catalogue': Pricer(quote = <function _quote_from_catalogue>, table='falaw/data/models.json', version=<functools._lru_cache_wrapper object>)})[source]#
Re-quote
planat today’s rates and report the result honestly.Pure data —
falaw.reprice_plan()reads the committed rate tables and does arithmetic. No network, no billing API, no cache peek, so this is safe to call anywhere aplan()is (nw invariant #1).- Parameters:
plan (
Plan) – The plan to re-quote — typically one just rebuilt from a stored payload withplan_from_cost_records().pricers (
Mapping[str,Pricer]) – Pricing rules byfalaw.CostBasis.pricer. The seam for a caller with reconciled numbers of their own; seefalaw.reprice.Pricer.
- Return type:
>>> from falaw import Plan >>> current_quote(Plan(calls=())).status 'unchanged'
- nw.derived_from(project_root, annotation_id)[source]#
Return the annotations this one was directly derived from.
Walks
provenance.was_derived_fromone hop only across all of the project’s stores.- Return type:
list[Annotation]
- nw.descendants_of(project_root, ancestor_id)[source]#
Return every annotation whose provenance chain leads back to
ancestor_id.Walks
provenance.was_derived_fromtransitively across all of the project’s lacing stores. This is the operation reelee’s freshness analysis is built on (system overview §7): when a node changes, every annotation in the closure of this set is “downstream of the change.”Deterministic order: (generation time, id) — the same public ordering contract as
nw.freshness.stale_verdicts(). The closure used to be returned in set-iteration (hash-derived) order, which leaked into every consumer’s output (nw#39).- Return type:
list[Annotation]
- nw.describe_genre(slug)[source]#
One genre’s catalog entry (raises
KeyErrorif the slug is unknown).- Return type:
- nw.execute_render(prep, plan, *, on_event=None, use_cache=True, project=None)[source]#
Execute a Plan, materialize the result as
shot_dir/output.mp4.Refuses to execute a plan-only Plan (one whose arguments still contain
<plan-only:...>placeholders) — those exist so the planner can show cost without any uploads, and need to be replaced with real URLs (callprepare_shot()withupload=True) before execute.- Parameters:
prep (
ShotPreparation) – TheShotPreparationthe Plan was built for.plan (
Plan) – Afalaw.Plan(typically fromplan_render_shot()).on_event – Optional event subscriber forwarded to the falaw call layer.
use_cache (
bool) – When True (default), routes viacached_call_falso cache hits skip the network.project (
Optional[Project]) – OptionalProject. When given, a render-decision annotation is appended to the project graph after execution withwas_derived_from = (shot_annotation_id,), so reelee’s freshness queries (descendants_of/stale_after) walk from the shot to its render output.
- Return type:
- Returns:
Path to
shot_dir/output.mp4(trimmed/padded toprep.duration_s).
- nw.execute_render_panel_images(project, storyboard, plan, panel_ids, *, on_event=None, use_cache=True, on_failure='halt')[source]#
Execute
plan, download each artifact, attach a PanelImage.Returns a NEW
Storyboard(inputstoryboardis unchanged) with the materialized seed images attached asrole="seed"PanelImages.Files land under
<project_root>/storyboard/<panel_id>.png. The PanelImage record stores both the project-relative path and the artifact_id (content hash via lacing.Artifact), so downstream consumers can prefer one or the other.on_failureis nw#25’s policy, and this is the function the issue names as nw’s real fan-out shape — onegenerate_imageper panel. Under"isolate"a panel whose call failed is simply left without a seed image; every panel that rendered keeps its own, instead of one content-filtered panel discarding the whole batch."halt"is the default and unchanged.Panels are matched to outcomes by index into the plan, never by position in a shortened artifact list — the latter attaches panel 48’s image to panel 47 the moment one call drops out.
- Return type:
Storyboard
- nw.fan_out_execute(transform, project, fan_out, *, use_cache=True, force=False, on_failure='isolate', secrets=None)[source]#
Execute a planned fan-out, one ordinary
transform.executeper unit.on_failuregoverns both levels symmetrically:within a unit, it is passed to the Transform’s
execute(when the implementation accepts it — a pre-nw#25 override runs with its own halt-like behaviour inside the unit; cross-unit isolation still applies);across units,
"isolate"(the default — it is the point of a fan-out) runs every unit and reports per-unit outcomes, while"halt"stops submitting units after the first raising unit and marks the restblocked.
A unit whose
executereturns is never a halt trigger, even when its result is partial — the Transform already decided those failures were survivable; only a raising unit halts.Two protocol-violation shapes degrade rather than crash, deliberately: an
executethat rejectsuse_cache/force(or returns a non-TransformResult) shows up as per-unitfailedrows carrying theTypeError/AttributeError— N identical rows for one programming error reads worse than one loud raise, but the alternative discards the run record for units that already spent. And a**kwargsoverride that accepts-but-ignoreson_failureruns its internal default within the unit — undetectable by signature inspection in principle; cross-unit policy is still honoured.use_cache/forceare forwarded per unit and mean what they mean onTransform.execute():forceskips the cache read and keeps the write, so re-forcing a 200-unit fan-out does not orphan 200 paid results (nw#72).use_cache=False, force=TrueraisesCacheModeConflictbefore the first unit runs: it is a contradiction decidable from the arguments alone, so it does not get the degradation above — filing one programming error as N identical failed rows is only the lesser evil for the shapes that cannot be checked up front.secrets— the caller’s per-call credentials (nw.Secrets; any mapping is coerced) — reaches each unit two ways, so no registered Transform can silently bill the server’s key. It is passed toexecutewhen the implementation declares the keyword (the same accepts-it-or-not seam ason_failureandunit_instance_id), and it is bound around every unit regardless — a"fal"secret is the fal credential for the call (nw.secrets.using_secrets()) even for an override that predates the seam. It reaches nothing else: not the units, not the run record (FanOutResult.to_record(); a failing unit’sreasonis redacted), not a log line.Units run sequentially. Concurrency within a unit is falaw’s (
execute_plan_isolatedbounds it); concurrency across units is the deferred-scheduler work nw#26 explicitly scopes out, and nothing here forecloses it — units are planned independently and the result is order-aligned, not order-dependent.- Return type:
FanOutResult
- nw.fan_out_plan(transform, project, items, *, inputs_for, params=None)[source]#
Plan one Transform across
items— each unit an ordinaryplan()call.inputs_formaps a work item to theTransformInputsits unit consumes; the item’sattributescarry any per-unit data it needs to build them. No billable calls; pure data out.Duplicate
mapping_keys are refused: two units sharing a key share an instance id, which destroys exactly the per-instance identity the key exists to provide (retry, cost attribution, regenerate-just-this-one all become ambiguous).stamp_transform_identityis applied to each unit plan **here, at plan time** — its own docstring asks orchestrators that hash or persist plans before execution to do so, and a fan-out’s run record is such a persistence. Idempotent, soBaseTransform.execute()re-stamping at execute time changes nothing.- Return type:
FanOutPlan
- nw.format_ref(n)[source]#
The one spelling we print. Input is permissive; output never varies.
- Return type:
>>> format_ref(1), format_ref(42) ('cut 1', 'cut 42')
- nw.genre_catalog()[source]#
Every registered genre as a JSON-able catalog entry (sorted by slug).
This is the generic, app-agnostic catalog an HTTP route / MCP tool serves; see
Genre.to_dict()for the entry shape.
- nw.genre_op(genre_slug, name)[source]#
The op
nameofgenre_slug;UnknownGenreOpErrornaming the known.- Return type:
- nw.genre_ops(genre_slug)[source]#
The ops registered for
genre_slug, in registration order (()if none).- Return type:
- nw.genre_ops_catalogue(genre_slug)[source]#
The pure-JSON catalogue of
genre_slug’s ops ([]for a genre with none).One dict per op —
name,title,description,effect,runs,params_schema,host_params,max_upload_bytes,spends— the shape a host exports to a frontend’s codegen or an MCP tool builder.- Return type:
- nw.get_genre(slug)[source]#
Look up a genre by slug; raises
KeyErrorwith the known slugs.- Return type:
- nw.get_transform(name)[source]#
Look up a Transform instance by name; raises with the known names.
- Return type:
- nw.has_genre_project_factory(slug)[source]#
True iff a plugged-in project factory is registered for
slug.- Return type:
- nw.initialize_genre(genre, project, *, template=None, params=None)[source]#
Seed a freshly-created
projectforgenre(+ optionaltemplate).The side-effecting apply-counterpart to
resolve_genre(). Dispatches to the genre’s registered initializer (register_genre_initializer()) when one exists; when none is registered this is a no-op — the correct default for a genre that seeds nothing on create (e.g. one whose preset is applied at render time).paramsis the resolved creation params (fromresolve_genre()); whenNoneit is resolved from the genre’stemplate/defaultshere, soinitialize_genre(genre, project)seeds a project in the genre’s defaults in one call. RaisesKeyErroron an unknown genre or — uniformly — an unknowntemplateslug (matchingresolve_genre()).The envelope is persisted (nw#32): after the initializer succeeds (or no-ops), the resolved
{genre, template, params}is recorded on the project’s graph — so “what genre is this project?” stays answerable after this call returns, on the host-creates path exactly as oncreate_genre_project()’s. Written after the seed on purpose: a recorded envelope certifies a completed initialization, never a failed one. Aprojectstand-in without agraph(a test double, a factory that returns no live project) skips the recording; read it back vianw.Project.resolved_genre().- Return type:
>>> _ = register_genre(Genre(slug="_noinit_demo", title="Demo")) >>> initialize_genre("_noinit_demo", object()) # no initializer -> no seed >>> del genres["_noinit_demo"]
- nw.is_migrated(project_root)[source]#
True iff this project has been migrated to the lacing graph.
- Return type:
- nw.iter_all_annotations(project_root)[source]#
Walk every annotation in every store under a project (any backend).
- Return type:
Iterator[Annotation]
- nw.migrate_to_graph(project_root, *, backup=True, was_attributed_to='agent:nw.migrate')[source]#
Migrate
project_root’s project.json into the lacing graph.Idempotent: returns
{"already_migrated": 1, ...}with zero writes if the sentinel exists. Otherwise readsproject.json, writes equivalent annotations intoproject.annot.sqlite, drops the migrated arrays fromproject.json, and writes the sentinel.- Parameters:
- Returns:
- ``{“sections”: N, “shots”: N, “characters”: N, “environments”: N,
”decisions”: N}``.
- Return type:
- nw.open_project_stores(project_root)[source]#
Yield an iterator of open stores, one per scope, honouring the backend.
The backend-aware replacement for
for p in all_project_stores(...): SqliteStore(p). Under SQLite it visits each existing per-scope file; under Postgres it visits each scope’s tenant in the shared DB. Use it for both reads (walk.all()) and writes (.remove/.add).Each store is closed before the next opens, so consume each store’s annotations before advancing.
- nw.open_storyboard(project)[source]#
Load the project’s storyboard. Returns an empty one if not present.
- Return type:
Storyboard
- nw.parse_ref(text)[source]#
The ordinal in a spoken reference, or
Noneif it isn’t one.Noneis the signal to fall through to treating the input as a raw artifact id — which is why this never raises: “not an ordinal” is an ordinary, expected answer, not an error.>>> parse_ref("cut 4"), parse_ref("CUT4"), parse_ref(" cut - 4 ") (4, 4, 4) >>> parse_ref("#11"), parse_ref("11") (11, 11) >>> parse_ref("b02fc05417ea") is None, parse_ref("") is None (True, True)
Zero and negatives are not references — deliverables are numbered from 1, so accepting
cut 0would resolve to a neighbour under a naive index:>>> parse_ref("cut 0") is None True
- nw.plan_checks(selection)[source]#
Order the selection into waves that may each run concurrently.
Every check in a wave has all its requirements satisfied by earlier waves, so the waves are the schedule: run each in turn, in parallel within it. A check that is not
parallel_safegets a wave to itself.- Raises:
ValueError – on a dependency cycle, or a requirement that is not registered — both at plan time, before anything has been spent.
- Return type:
Examples
>>> plan_checks(()) ()
- nw.plan_from_cost_records(records)[source]#
Rebuild a re-quotable
falaw.Planfromcost_records()rows.The result is a pricing plan, not an executable one:
argumentsis empty andoutput_kindis a placeholder, because a stored cost row does not carry the wire payload and re-pricing does not read it. Never hand one of these tofalaw.execute_plan()— build a fresh plan for that.Rows missing
cost_basiscome back basis-free, which is exactly what makes them re-price as"no_basis"(unknown) rather than as their frozen number.- Return type:
Plan
- nw.plan_render_panel_images(storyboard, *, quality='balanced', image_size='landscape_16_9', model_id=None, only_missing=True)[source]#
Build a Plan that generates a seed image for each panel that lacks one.
- Parameters:
storyboard (
Storyboard) – Theartful.Storyboard.quality (
str) – image-gen quality tier.image_size (
str) – “landscape_16_9” by default; respects the storyboard’s aspect when it can be mapped to a falaw size, otherwise uses this default.model_id (
Optional[str]) – Override the image-gen model. Defaults to whateverfalaw.pick_model(category="image", quality_tier=quality)picks (e.g. flux/dev at balanced).only_missing (
bool) – When True (default), skip panels that already have arole="seed"image. When False, plan one call per panel regardless.
- Return type:
- Returns:
(plan, panel_ids)— the Plan, and the panel ids in the same order as the Plan’s calls (soexecute_render_panel_images()knows which panel each artifact belongs to).
- nw.plan_render_shot(prep, *, quality='balanced', model_overrides=None)[source]#
Build a
falaw.Planfor rendering a prepared shot.Dispatches on
prep.shot.render_strategyvianw.renderers.- Parameters:
prep (
ShotPreparation) – AShotPreparationfromprepare_shot().quality (
str) – Default quality tier passed to the strategy.model_overrides (
Optional[dict[str,str]]) – Optional mapping of strategy-step → model_id, e.g.{"avatar": "fal-ai/bytedance/omnihuman/v1.5"}to bypass the default avatar model. The keys understood by each strategy are documented on the strategy itself.
- Return type:
Plan- Returns:
A
falaw.Plan. Caller can inspectplan.total_cost_usdand decide whether toexecute_plan(plan).
- nw.prepare_shot(project, shot_id, *, upload=True)[source]#
Resolve all local inputs for rendering a shot.
No billable fal calls. When
upload=True(the default), local files are uploaded to fal-storage so the planner can build a Plan with stable URLs (uploads are free; the cache key derived from those URLs is honest). Whenupload=False(e.g. for tests or dry-run reporting), the URL fields are left empty.Idempotent in spirit but not byte-stable: fal-storage URLs include expiring signatures, so two
prepare_shotcalls on the same project produce different URLs. The local file paths are byte-stable.- Parameters:
project (
Project) – Annw.Projectinstance.shot_id (
str) – The shot’s id, as inproject.read_spec().shots[*].id.upload (
bool) – When True (default), upload local files to fal-storage and populate the*_urlfields. When False, only the local paths are populated.
- Return type:
- Returns:
A
ShotPreparationwith local paths (and URLs ifupload) ready to plan.
- nw.project_asset_id(project)[source]#
The asset_id used for storyboard panel references.
Uses the SHA-256 of the project’s song bytes when available, so the asset_id matches whatever a downstream consumer would compute via
lacing.hash_file(). Falls back to a stable derived id when the song isn’t available yet.- Return type:
- nw.quote_from_cost_records(records, *, pricers={'llm_rates': Pricer(quote=<function _quote_from_llm_rates>, table='falaw/data/llm_rates.json', version=<functools._lru_cache_wrapper object>), 'model_catalogue': Pricer(quote = <function _quote_from_catalogue>, table='falaw/data/models.json', version=<functools._lru_cache_wrapper object>)})[source]#
Today’s price for the calls stored in a decision payload.
The read-back half of
cost_records().Noneor a non-sequence (a payload that recorded no calls at all) yields an empty plan’s quote —total_usd == 0.0,status == "unchanged"— because “no calls” is a known zero, not an unknown.- Return type:
- nw.quote_render_decision(payload, *, pricers={'llm_rates': Pricer(quote=<function _quote_from_llm_rates>, table='falaw/data/llm_rates.json', version=<functools._lru_cache_wrapper object>), 'model_catalogue': Pricer(quote = <function _quote_from_catalogue>, table='falaw/data/models.json', version=<functools._lru_cache_wrapper object>)})[source]#
Today’s price for a
render_shotdecision payload.The counterpart to what
nw.workflow._record_render_decision()wrote. Read this — neverpayload["total_estimated_cost_usd"]— whenever a stored render cost is about to be shown or gated on as a current figure.A payload written before nw#74 carries no per-call basis, so it re-quotes as
"unknown"withtotal_usdNone. That is the point: nobody can say what it costs today, and saying so is better than repeating a number that has since moved.The stored
total_estimated_cost_usdis the payload’s own headline, so it — not the calls’ sum — is reported asPlanQuote.as_of_total_usd. When the two disagree, the whole payload is unknown: a total of $3 over a payload whose calls sum to $0 is a broken record, and answering “$0, unchanged” would report a stored $3 as a known zero. nw’s own writer never produces such a payload; a hand-edited or truncated one can, and unknown is the only honest reading of it.- Return type:
>>> broken = quote_render_decision( ... {"calls": [], "total_estimated_cost_usd": 3.0}) >>> broken.status, broken.total_usd, broken.as_of_total_usd ('unknown', None, 3.0) >>> stale = quote_render_decision( ... {"calls": [{"tool": "t", "application": "a", ... "estimated_cost_usd": 3.0}], ... "total_estimated_cost_usd": 3.0}) >>> stale.status, stale.total_usd, stale.as_of_total_usd ('unknown', None, 3.0)
- nw.recommend_genre(kind)[source]#
The slug of the genre whose
intake_kindscontainskind(first in slug order), orNonewhenkindis falsy / unmatched.>>> g = register_genre(Genre(slug="_rec_demo", title="Rec", intake_kinds=("essay",))) >>> recommend_genre("essay") '_rec_demo' >>> recommend_genre("nope") is None and recommend_genre(None) is None True >>> del genres["_rec_demo"]
- nw.redact(text, secrets)[source]#
textwith every secret value replaced by<redacted:name>.For the places nw persists free text it did not author — an exception message, a failure reason — while holding the values that must not land there. Cheap, exact-substring, and a no-op with no secrets.
- Return type:
>>> redact("boom: key sk-1 rejected", {"fal": "sk-1"}) 'boom: key <redacted:fal> rejected' >>> redact("nothing here", None) 'nothing here'
- nw.redact_exception(error, secrets)[source]#
The exception to re-raise so that nothing it renders carries a secret.
Scrubs
argsand__notes__in place and, whenstr(error)is still not clean — an exception whose message is built from a non-string arg (RuntimeError({"detail": key}),OSError(2, msg, path)) or a custom__str__— rebuilds it astype(error)(scrubbed_text), falling back toRedactedErrorwhen the type will not construct that way or still renders the secret. The cause/context chain is scrubbed the same way. Returns the object to raise: the original when it was already clean.Applied where nw lets an exception escape toward a store it does not own (the job worker: au persists the rendered text) or files it into a record it does (a fan-out unit’s
reason).- Return type:
- nw.register_check(check=None, **kwargs)[source]#
Add a check to the menu, as a call or as a decorator.
As a call:
register_check(Check(name="video.duration", summary="...", run=...))As a decorator on the run function, with the rest as keywords:
@register_check(name="video.duration", summary="...", cost="cheap") def _duration(target, ctx): ...
- nw.register_genre(genre)[source]#
Register a
Genreunder itsslug; returns it for inline use.- Return type:
>>> g = register_genre(Genre(slug="doctest_demo", title="Demo")) >>> get_genre("doctest_demo").title 'Demo' >>> "doctest_demo" in list_genres() True >>> del genres["doctest_demo"] # keep the shared registry clean
- nw.register_genre_initializer(slug, initializer)[source]#
Register an initializer for a genre slug; returns it for inline use.
Called by the genre’s owning app.
initializer(genre, template, project, params) -> Noneseeds a freshly-created project for the chosen genre/template (seeGenreInitializerfor the side-effect contract);initialize_genre()dispatches to it. Independent of genre *registration order* (keyed by the slug string). A genre that seeds nothing on create needs no initializer at all.>>> _ = register_genre(Genre(slug="_init_demo", title="Demo", ... defaults={"look": "plain"})) >>> seen = {} >>> def _seed(genre, template, project, params): ... seen["applied"] = (genre.slug, template, params) >>> _ = register_genre_initializer("_init_demo", _seed) >>> initialize_genre("_init_demo", object()) # params default to the genre's >>> seen["applied"] ('_init_demo', None, {'look': 'plain'}) >>> del genres["_init_demo"]; del genre_initializers["_init_demo"]
- nw.register_genre_ops(genre_slug, ops)[source]#
Register the operations a genre offers on its projects; returns them as a tuple.
Called once by the genre’s owning app, beside its project factory, so a host can serve the genre’s project operations via
genre_ops()without importing the genre’s package. Names must be unique within the genre. Registering the same genre twice raises (the registry refuses conflicts, as every nw genre registry does).- Return type:
>>> def _peek(project) -> dict: ... '''Say hello.''' ... return {"hello": True} >>> _ = register_genre_ops("_ops_demo", [GenreOp("peek", _peek, title="Peek", ... effect="read")]) >>> [op.name for op in genre_ops("_ops_demo")] ['peek'] >>> genre_op("_ops_demo", "peek").run(None) {'hello': True} >>> genre_ops("_nobody") () >>> del genre_ops_registry["_ops_demo"]
- nw.register_genre_project_factory(slug, factory)[source]#
Register a project factory for a genre slug; returns it for inline use.
Called by the genre’s owning app so a host that aggregates the genre can create its projects via
create_genre_project()without knowing its storage. SeeGenreProjectFactoryfor the signature + the caller-space contract.>>> _ = register_genre(Genre(slug="_pf_demo", title="Demo", ... defaults={"format_id": "solo"})) >>> made = {} >>> def _f(caller, project_id, *, title, template, params): ... made.update(caller=caller, project_id=project_id, params=params) ... return {"project": None, "project_id": project_id} >>> _ = register_genre_project_factory("_pf_demo", _f) >>> create_genre_project("_pf_demo", "u@x.com", "p1")["project_id"] 'p1' >>> (made["caller"], made["params"]) ('u@x.com', {'format_id': 'solo'}) >>> del genres["_pf_demo"]; del genre_project_factories["_pf_demo"]
- nw.register_genre_resolver(slug, resolver)[source]#
Register a resolver for a genre slug; returns it for inline use.
Called by the genre’s owning app.
resolver(genre, template) -> paramsmaps a chosen template (orNonefor “start from scratch”) to that app’s bare params payload;resolve_genre()adds the{genre, template, params}envelope. Independent of genre registration order (keyed by the slug string).>>> _ = register_genre(Genre(slug="_resolver_demo", title="Demo", ... defaults={"look": "plain"})) >>> _ = register_genre_resolver("_resolver_demo", ... lambda genre, template: {"look": genre.defaults["look"], "via": "resolver"}) >>> resolve_genre("_resolver_demo") {'genre': '_resolver_demo', 'template': None, 'params': {'look': 'plain', 'via': 'resolver'}} >>> del genres["_resolver_demo"]; del genre_resolvers["_resolver_demo"]
- nw.register_strategy(name, impl)[source]#
Register a strategy. Returns
implso it can be used inline.- Return type:
- nw.register_transform(name, impl=None, *, tags=())[source]#
Register a Transform under
name. Two forms:Direct — pass an instance:
register_transform("clips_to_animatic.ffmpeg", ClipsToAnimatic())Decorator — decorate a class; it is instantiated and the instance is registered (so
get_transform()always returns something callable), and the class is returned unchanged:@register_transform("beat_to_panel.llm.default") class BeatToPanelLLM(BaseTransform): ...
Registration validates the contract the registry’s consumers depend on: an empty
output_kindis refused loudly, in the same spirit as the registry’son_conflict="error"— an agent’s unit of work must have a declared output type, or “the job runs successfully but produces nothing retrievable” becomes invisible to every layer that reports success (nw#27).tagsis passed straight through toxdol.Registry.register()(transforms.keys_with_tag(tag)/transforms.search(tags=...)read it back). The field exists so a licence, a capability class, or a cost class has somewhere to live before the registry opens to third-party registrants — nw#29 stays closed to third parties for now (see that issue andmisc/docs/Transform Registry — third-party extension.md); this is the one piece of that decision worth doing regardless of when, or whether, the registry opens.
- nw.resolve_defaults(genre, template=None)[source]#
Resolve a genre (+ optional template) to the params for a new project.
Returns
{"genre": slug, "template": template_or_None, "params": {...}}— the chosenTemplate’sparamswhentemplateis given, else the genre’sdefaults. RaisesKeyErroron an unknown genre or template. The caller (app) interpretsparams(reelee readsoutput_intent/flavor; braidio aformat_id).- Return type:
- nw.resolve_genre(genre, template=None)[source]#
Resolve a genre (+ optional template) to the standard creation envelope.
Always returns
{"genre": slug, "template": template_or_None, "params": {...}}— ONE stable contract for every host, regardless of whether the genre has a resolver.paramscomes from the genre’s registered resolver (register_genre_resolver()) when one exists, else from the genericresolve_defaults()(the template’s params, or the genre’sdefaults).Raises
KeyErroron an unknown genre or — uniformly, resolver or not — an unknowntemplateslug (the substrate owns template identity; a resolver only interprets params, it doesn’t get to invent template slugs).- Return type:
>>> _ = register_genre(Genre(slug="_rg_demo", title="Demo", ... defaults={"flavor": "cinematic"})) >>> resolve_genre("_rg_demo") # no resolver registered -> generic params {'genre': '_rg_demo', 'template': None, 'params': {'flavor': 'cinematic'}} >>> del genres["_rg_demo"]
- nw.save_storyboard(project, storyboard, *, panel_intervals, was_attributed_to='user:nw', was_generated_by='agent:nw.storyboard')[source]#
Persist a Storyboard into the project’s SqliteStore.
Wipes the existing storyboard panels (under the default tier) so the save is idempotent — re-running with edited panels replaces them rather than accumulating duplicates.
- Return type:
- nw.shot_report(project, shot_id, *, freeze_sample_fps=4.0, duration_tolerance_s=0.1)[source]#
Inspect
shots/<shot_id>/output.mp4and return a typed report.- Parameters:
project (
Project) – Thenw.Project.shot_id (
str) – The shot id.freeze_sample_fps (
float) – How many frames per second to extract for the freeze detector (default 4 fps; a freeze must hold across at least two consecutive samples to count).duration_tolerance_s (
float) – Acceptable difference between actual and target duration before flagging.
- Return type:
- Returns:
A
ShotReport.
- nw.stale_after(project_root, changed_id)[source]#
Return every annotation that
changed_idactually invalidated.The freshness operation.
changed_id’s descendants are walked and each is checked against the upstream value digests it recorded when it was written (nw.bodies.verifying_trace). A descendant whose recorded inputs still match the current ones is not returned — that is the early cutoff, and it is why this is notdescendants_ofunder another name. The full rule, and the four things it deliberately does not catch, are in this module’s docstring.The returned list does NOT include
changed_iditself (it is the source of the change, not a stale derivative).descendants_ofis unchanged and still answers the reachability question — “what is downstream of this?” is legitimate and the two verbs are no longer synonyms. Usestale_verdicts()when you need the reason a given annotation is in (or out of) this set.- Return type:
list[Annotation]
- nw.stale_verdicts(project_root, changed_id)[source]#
Classify every annotation downstream of
changed_id.The explained form of
stale_after(): one verdict per reachable annotation, stale or not, in a deterministic order (generation time, then id).changed_iditself is never included — it is the source of the change, not a derivative of it.Use this when the number is being questioned.
stale_afteris the same walk with the fresh verdicts dropped;stale_verdicts_all()is the same classification with nochanged_id— the whole-project snapshot.- Return type:
- nw.stale_verdicts_all(project_root)[source]#
Classify every derived annotation in the project — the snapshot form.
The question a freshness indicator asks: “what is stale in this project right now?”, with no
changed_idto anchor on. Same verifying-trace classification asstale_verdicts(), over a wider frontier: every annotation with at least one provenance parent.Two boundaries that are the point of this living here rather than each consumer approximating it (nw#39):
Parentless annotations are never stale and stay out of the walk — an imported screenplay must not read as stale forever. (nw-written verifying traces are parentless, so they stay out too.)
Upstream-stale recursion runs over the whole derived set. The scoped walk only recurses into parents inside
reachable(outside it a parent is by construction unaffected by the change); with no change there is no such boundary. The cycle guard covers termination.
Legacy projects read all-stale, by design. A derived annotation written before verifying traces existed classifies
no-trace→ stale, and unlike the scoped walk (which only surfaces it downstream of an actual change) the snapshot reports it always, until it is rewritten through the trace-writing path. A consumer replacing its own weaker snapshot with this one is making a behavior change on pre-trace projects, not installing a pure wrapper.- Return type:
- nw.stamp_transform_identity(plan, transform)[source]#
Fold
transform.impl_versioninto every call’s cache identity.The reader that makes
impl_versiona lock instead of a receipt (nw#27): a bumped version lands in each call’s falawkey_extra, so a cached result minted by the old behaviour cannot be reused. AtDFLT_IMPL_VERSIONnothing is stamped — every key ever issued stays byte-identical, and the first real bump is the first salt.Each stamped call’s
cache_statusis reset to"unknown": the plan-time peek keyed without the salt, so its prediction (typically “hit” — the old-behaviour result is cached, invalidating it is the point) would make cost gates quote $0.00 for a full re-bill.BaseTransform.execute()applies this automatically. **A Transform that overrides**execute()**must apply it itself** — the lock only locks calls that pass through it (a registry-wide conformance test is the honest guard). An orchestrator that hashes or caches plans before execution (e.g. a job idempotency key overfalaw.plan_hash) should apply it at plan time so those keys see the version too. Idempotent — stamping twice writes the same value.- Return type:
Plan
- nw.storyboard_db_path(project)[source]#
Return the path to the project’s storyboard SQLite store.
- Return type:
- nw.storyboard_from_shots(project, *, title=None, style=None)[source]#
Build a one-panel-per-shot draft Storyboard from a project’s shots.
Each panel’s caption defaults to the shot’s description, framing and camera carry over, and the panel’s
shot_idpoints back at the shot. No images are attached yet — useplan_render_panel_images()to generate them.Returns
(storyboard, panel_intervals)so the caller can feed both intosave_storyboard().
- nw.suggest(request, *, include_paid=False)[source]#
Checks whose
example_requestslook like what the user just asked for.Deliberately crude — a word-overlap score, not a model call — because this runs on every request and its job is to narrow forty items to a handful that a human or a model then confirms.
"paid"checks are never suggested: money is asked for by name.Examples
>>> suggest("") ()
- nw.summarize_all(roots)[source]#
Convenience: return a
ProjectSummaryfor each project.Equivalent to
apply_to_projects(roots, lambda p: p.read_summary()).- Return type:
- nw.transform_catalog()[source]#
Every registered Transform as a JSON-able capability entry (sorted by name).
The typed-capability surface an HTTP route / MCP tool builder / agent serves or selects from, mirroring
nw.genre_catalog()(nw#28): a consumer needs no registry-internal knowledge to render or compose. Entry shape:{name, input_kinds, output_kind, is_batch, generate_when, impl_version, params_schema}
nameis the registry key (the addressable name).params_schemais the params model’s JSON Schema —{}for a Transform with no params — and is what an MCP tool definition is built from. The whole list is JSON-serializable as returned.
- nw.unquotable(reason)[source]#
A quote for something that could not be re-quoted at all.
For the caller who was handed a plan-shaped thing that turned out not to be a plan — an unparseable payload, an object of the wrong type. The honest answer is
None(unknown), not the frozen figure that came with it, and not0.0.PlanQuote.repricedis an emptyfalaw.RepricedPlan: there were no calls to diff. ReadPlanQuote.reasonfor what went wrong.- Return type:
>>> q = unquotable("params['plan'] is not a falaw Plan") >>> q.status, q.total_usd, q.has_unknown_costs ('unknown', None, True)
- nw.using_secrets(secrets)[source]#
Bind the secrets nw itself knows how to use, for the duration of a block.
Today that is
FAL_SECRET: when present it becomes the fal credential (falaw.using_fal_credentials()) so everycall_falinside the block authenticates with the caller’s key instead of the server’sFAL_KEY. Anything else insecretsis left for the Transform that declared it. With no fal secret this is anullcontext, so thewithshape stays uniform.- Return type:
- nw.validate(target, *, checks=(), max_workers=4, on_error='report')[source]#
Run
checksagainsttargetand report.- Parameters:
target (
Any) – whatever the checks understand — a path to a rendered file, aProject, a(video, annotations)pair. This module does not care; it is the checks that agree with their caller.checks (
Iterable[str|Check]) – names orCheckobjects. Requirements are pulled in automatically. Empty means empty: validation is placed, never assumed.max_workers (
int) – concurrency within a wave.on_error (
Literal['report','raise']) –"report"records a raising check as an erroredCheckResultand carries on, so one broken plugin cannot hide the findings of the other nine."raise"is for developing a check.
- Return type:
- Returns:
A
ValidationReport. Note thatreport.okisFalsewhen a check errored, not only when one failed: a suite that could not run has not said the work is good.
Examples
>>> validate("x.mp4").ok True
- nw.work_item_instance_id(transform_name, mapping_key)[source]#
The instance id of one fan-out unit: UUIDv5 of
(transform_name, mapping_key).A pure function, deliberately: pure is async-safe by construction (no ambient counter for a suspended coroutine to corrupt — the ComfyUI
GraphBuilderrace) and stable under insertion (adding an item never changes any other item’s id). The same (transform, key) pair yields the same id on every machine, every run, forever — which is what makes per-instance retry, cost attribution, and “regenerate just this one” addressable across runs.- Return type:
>>> a = work_item_instance_id("panel_to_image.fal", "scene_1/panel_2") >>> a == work_item_instance_id("panel_to_image.fal", "scene_1/panel_2") True >>> a != work_item_instance_id("panel_to_voiceover", "scene_1/panel_2") True
Modules
Freshness with early cutoff — what is actually out of date. |
|
QA helpers — typed reports about rendered shots. |
|
The checks nw itself ships — a small, honest default menu. |
|
Body schemas for nw's project-graph annotations. |
|
What a genre hands back when a human wants to hold what it made. |
|
Experiment helpers — clone projects, apply operations across siblings. |
|
A typed dict-backed plugin registry. |
|
The project annotation graph — read/write helpers + reelee-style traversals. |
|
Config-driven backend selection for nw's annotation graph stores. |
|
nw.jobs — a project-scoped async job facade over |
|
Idempotent migration: project.json (sections/shots/refs) → lacing graph. |
|
Re-quoting a persisted plan at today's rates (nw#74). |
|
Project facade: a folder on disk → typed reads, typed writes, typed summary. |
|
Render strategies — pluggable, plan-producing, shot-typed. |
|
Schema for an nw project — narrative-workflow SSOT data shapes. |
|
|
|
Execution secrets — credentials that reach |
|
Storyboard ↔ Project bridge. |
|
A typed dict-backed plugin registry. |
|
Pluggable validation of finished work — the seam, not the checks. |
|
Workflow: prepare → plan → execute, for a video shot. |