Skip to content

Project Files (.invk)

Workbench projects export to a portable .invk archive. The feature lives in invokeai/frontend/webv2/src/workbench/projects/ and is exposed as Import… on the Launchpad’s Home and Projects pages, Open project… in the editor’s tab bar, and Export in the project switcher and each project card’s action menu.

Import also accepts the .invokeproject.json envelope shipped by earlier webv2 builds. New exports use .invk; the JSON path remains read-only compatibility.

.invk is shared with the previous frontend’s canvas projects, which write version 1 of the same container. Workbench projects write version 2. The two are deliberately not interchangeable — see Versions.

The backend gives each project exactly one private board (projects.board_id is NOT NULL UNIQUE with a foreign key to boards). The board is the project’s workspace: everything it generates lands there, and only the project APIs may touch it.

  • Renaming a project renames its board in the same transaction. The generic PATCH /boards/{id} refuses a claimed board with 409, so the two names cannot commit independently.
  • Deleting a project deletes its board with it, in one transaction. DELETE /boards/{id} refuses a claimed board before touching any media, and the foreign key is the backstop for a board claimed concurrently.
  • The media survives. Deleting a project removes the board and its memberships; the images and videos themselves return to Uncategorized, exactly as they would if the board were deleted without include_images.
  • The document’s projectBoardId is a cache, never the truth. Hydration overwrites it from the project record, and export strips it — along with selectedBoardId — because a board id means nothing on the install a project arrives at. Board ids elsewhere in a document, such as a workflow node’s board input, are deliberately left alone.

The backend keeps project documents opaque, but it owns one piece of compatibility metadata: minimum_canvas_schema_version. Every document read declares max_canvas_schema_version as a query parameter; creates and saves declare it in their request body. A client whose maximum is below the project’s minimum receives 412 Precondition Failed before the document is returned or replaced. Project listings remain readable because they contain metadata, not an editable document, and expose the minimum so clients can identify projects they cannot open.

The floor is monotonic. A save may raise it, but cannot lower it, and SQLite commits the new document, revision, project name, board name, and floor as one transaction. The hierarchical canvas v3 document rides this seam: every save sends the document together with the minimum_canvas_schema_version it requires (3 for any v3 canvas), so a client that still declares a maximum of v2 is refused before it can overwrite that document.

The frontend derives the requested floor from the highest declared version among the live canvas and every queued canvas snapshot. It also retains the server’s monotonic floor in the offline sync map, so importing, duplicating, conflict recovery, and deletion recovery cannot accidentally create a copy with weaker compatibility metadata than the document or its source project requires.

If another client raises the floor while a project is already open, the next save becomes a terminal per-project schema refusal instead of a generic network failure. The local document still writes through to the browser cache, repeated server retries stop, and the project Sync status asks the user to update Invoke. On reload, divergent cached bytes move into the raw refused-project recovery bucket before the ordinary cache is refreshed, so the server refusal cannot erase them. Project listings use the same metadata to label incompatible projects without issuing a document GET that is already known to fail.

Ordinary offline edits have the same no-loss guarantee. Before writing the local snapshot or starting a push, the sync map durably marks that project pending beside its last acknowledged revision. The marker is written first because the two localStorage keys cannot commit atomically: a crash can leave a harmless stale marker, but never new cached bytes falsely marked acknowledged. On reconnect, an unchanged server revision safely replays the cached edit; an advanced revision keeps the server project under its id and turns the cached edit into a recovered project. If a response landed just before a crash, identical server and cache bytes simply clear the stale pending marker.

Requests that omit negotiation fields default to v2 on the server. That keeps an undeclared older client from opening a v3 project: it is treated as v2, not as unrestricted. The frontend’s maximum lives in canvasSchemaVersion.ts; the canvas loader’s forward-version gate and the project API adapter consume the same constant so advertised support cannot drift from actual parsing support.

Webv2 exports font dependencies in the manifest’s optional fonts array. Each dependency records a SHA-256 content hash, readable family and face labels, source reference IDs, and an optional fonts/<hash>.<extension> entry. The Canvas schema compatibility floor protects the newer editable text fields; the ZIP manifest remains version 2.

Include font files is unchecked by default. When selected, export authenticates and verifies every required font download, bundles each original file once, and fails if a requested font is missing or changed. Variable fonts travel as their original files, preserving editable axes. Export does not infer redistribution rights from metadata.

Import verifies dependency paths and hashes before any mutation, preflights font validity, then installs embedded files into the recipient’s private library. Exact private duplicates are reused; font references are remapped by content hash, never by family name. A quota failure can be retried explicitly with references only. Cleanup tracks newly created font IDs and never deletes pre-existing duplicates or resources whose project creation outcome is unknown.

.invk files are ZIP archives. The current manifest version is 2.

TargetRequiredDescription
manifest.jsonyesArchive version, app version, creation timestamp, project name, and optionally the source project id and the cover entry’s path.
project.jsonyesThe whole project document — the same payload the backend stores as the project record’s opaque data.
board.jsonyes*The project board’s visible contents as the exporting server enumerated them: {version: 1, items: [{kind, category, name, starred}]}.
cover.<ext>noThumbnail of the project’s newest result. The extension varies by what the server served, so the entry path is recorded in the manifest.
images/<image_name>noImage bytes, keyed by the exact image_name the exporting server used.
videos/<video_name>noVideo bytes, keyed the same way.

* Every archive this app writes carries board.json. A reader still treats it as optional, because dev builds from before project boards wrote archives without it; its absence means “this file names no board”, which is different from a board that was empty.

Assets are filed by kind rather than pooled. Images and videos are separate backend namespaces with separate fetch and upload routes, so import has to know which an entry is before it can restore it — the folder states that, where a shared one would leave it to be guessed from a file extension. Adding videos/ needed no version bump: readInvkArchive ignores entries it does not recognize, so a reader predating it reads such an archive and simply leaves those references dangling, exactly as it would for an absent image.

The manifest is written indented and the document compact: the manifest is the one entry someone might open by hand, and the document is the largest thing in the archive before compression. Text entries are deflated; image entries are stored, since PNG and WEBP are already compressed.

A project’s media comes from two places that overlap, and the difference between them decides everything about how a transfer treats an item.

Board membership is what the project’s gallery shows: everything it has produced, including results the canvas never used. It comes from GET /projects/{id}/board-snapshot and is written to board.json. Enumerating it is fatal if it fails — an archive whose board.json silently said “empty” would be a lie the reader has no way to detect.

Document references are the pixels the project draws with. They come from collectLiveAssetRefs(), exactly as they always did.

An item can be either or both, and the union is fetched once. What travels in board.json is only what the gallery would show for that board: the four visible categories (general, control, mask, user) and nothing intermediate. The canvas’s private other category never travels as board membership — the media a document points at is the document, not gallery content — though a document reference under any category still travels as a bundled asset.

Which document references is decided by collectLiveAssetRefs() in projects/projectAssets.ts. It walks the document collecting every string found under imageName and image_name as an image, and under video_name as a video — the only keys in the document that hold an asset name. Collecting by key rather than by path is what makes it complete: a new control-adapter kind or node field is picked up without touching the collector.

video_name reaches a document through an imported workflow whose node value was authored elsewhere (VideoField in the backend’s fields.py, consumed by video_frame_extract and friends). webv2 cannot author one yet — VideoField is absent from STATEFUL_FIELD_TYPE_NAMES — but it round-trips one verbatim, so a project file has to carry it.

Installation state is excluded. stripInstallationState() removes the gallery’s projectBoardId and selectedBoardId along with its selection, at any depth, before the document is serialized. Both describe this install: the board ids mean nothing elsewhere, and the receiving server assigns its own.

The gallery selection is excluded, of either kind. selectedImage, selectedImageName, selectedImageNames and compareImage are pointers into per-install gallery content, not things the document renders; a project arriving on another machine should open with nothing selected rather than drag a stranger’s gallery in behind it.

Exclusion is two things, not one. The collector skips those keys so their pixels are never bundled, and stripInstallationState() removes them from the document before planInvkExport() serializes it. Skipping alone would not be enough: an unbundled reference still travels, it just travels broken — and because import collects with the same skip list, the restore pass cannot even report it as dangling. Every reader of these values already parses them with typeof/Array.isArray guards and falls back to no selection, so removing the key is the same as clearing it.

The skip is by key on purpose, because the members got there differently. selectedImage and the name keys were already missed — GalleryItem spells its name field name, which no collector key matches — but that is an accident of naming that a rename would have undone. compareImage is a GeneratedImageContract carrying imageName, so it was genuinely being bundled until the exclusion became explicit.

History is excluded. The walker skips the queue, graphHistory and events roots, and the snapshot, snapshots and recentImages keys at any depth. The root skip list also recognizes legacy documents, which may still contain those fields. New project files strip queue history, graph history and session events; historical references are compatibility input and are never bundled as live project assets.

readInvkArchive() unpacks and validates; restoreProjectMedia() — shared with duplication — puts the media back.

Import deliberately orders its work as read → mint identity → validate and canonicalize → stage a board → restore → remap → create. It first reads the archive without mutating the server, then assigns the imported project a fresh id and name. The candidate is rehydrated through the Workbench reducer and serialized back to its canonical document before any asset is restored. Only then are missing assets restored, their server-assigned names remapped throughout that canonical document, and the new project created. A malformed document therefore cannot leave restored orphan assets behind, and an import can never overwrite the project whose id was recorded in the archive.

Creating the project is the commit point. When an archive carries board media, the restore first creates an unclaimed private staging board and uploads onto it, then passes that board’s id to POST /projects, which claims and renames it in the same transaction that writes the remapped document. Creating the project first would mean posting the old names and then updating with the remapped ones — making the update the commit point, where a failure leaves a real project full of references to media that never arrived. A v2 archive or a legacy JSON document has no board to stage, so the server simply creates an empty one, exactly as it does for a project made from scratch.

The legacy JSON path follows the same fresh-identity validation and canonicalization boundary, then creates the project directly. A JSON envelope carries no bundled media, so it performs no existence checks, uploads, restoration or remapping.

board_images has PRIMARY KEY (image_name): an image sits on exactly one board. A restore that saw its own name already on the destination and reused it would not be restoring the project’s board — it would be moving somebody else’s image onto it, and deleting either project’s board would then take that image from both. So every board item is uploaded (or, when duplicating, copied) unconditionally, with no existence check, and the document is rewritten to the new name. Its archived category and starring are restored: the category on upload, the starring through the same POST /images/star the gallery uses, so import and duplication share one starring path.

Document references outside the board keep the v2 behaviour: an identity the destination already has satisfies them, so nothing is uploaded twice. That is what makes re-importing your own export nearly free.

A failed board item is forced dangling. If an item that is both board membership and a document reference cannot be restored, its reference must not be left on the archived name. On the same server — every duplication, every re-import — that name is already taken, by the source project’s own image, so the project would open showing the right picture from the wrong owner, and deleting the original would break it with no explanation. Such a reference is remapped to a name derived from the new project id, which resolves to nothing and renders as a missing layer, exactly as a dangling v2 reference already does.

Restoration checks which referenced assets the server already has, uploads only the missing ones out of images/ and videos/, and returns an old-to-new mapping per kind — the namespaces are separate, so a single flat map would let an image rewrite a video that happened to share its name. remapAssetRefs() then rewrites the document — the whole document this time, including the history the export left behind, so nothing keeps pointing at a pre-import name.

The existence check is the one place the two kinds are not symmetric. Images have a bulk POST /images/images_by_names; videos have no equivalent, so that check fans out one GET /videos/i/{name} per name behind the same concurrency limit of 5. For video lookup, only 403 and 404 mean the asset is absent; every other non-success status aborts the import rather than being mistaken for a missing video. This is what hydrateVideoRefs in the gallery’s data layer already does for the same reason. A project with no video references never issues the video check at all.

Restored assets upload with the category 'other', the canvas’s private category: they appear in no gallery view and no board count. The previous frontend used general, which empties a stranger’s project into your gallery.

Duplicating a project is the same restore with the bytes taking a shortcut. Both projects live on one server, so POST /images/copy and POST /videos/copy mint the new identities in place: no bytes reach the browser, and a 2 GiB board costs no traffic rather than 4 GiB and two requests per item. Everything else — the planning, the remapping, the forced-dangling rule, the issue reporting, the rollback ledger — is the code import uses; only the materialization step differs. Document-only references are not copied at all, because the identities they name already exist here.

An open project is flushed to the server before it is read, and a failed flush aborts the duplication: copying what the server last acknowledged would silently drop everything the person can still see.

Restoration records the authoritative server names returned by every successful upload or copy, as it goes rather than at the end, so a restore that is cancelled part way through is still cleanable. If project creation then rejects while the importing account scope is still current, import checks the client-chosen project id and sends those names to the image and video batch-delete routes only when the server confirms that id is absent with 404. A found project or an inconclusive lookup is an ambiguous create, so cleanup is skipped rather than deleting assets that the project may already reference. Cleanup is best-effort and never replaces the primary create error. It is also skipped after a create has returned and after a cross-account rotation: issuing destructive deletes with a new account’s credentials would be unsafe. That safety boundary means a rotation after upload can still leave private-category media behind when the old account can no longer authorize cleanup.

The staging board is deleted last, and with include_images=false, so anything that wandered onto it while the import ran — a generation that finished in the meantime — survives as Uncategorized rather than being collected by a cleanup that never meant it.

An asset that is neither on the server nor in the archive is reported as dangling and its reference is left alone, so the project opens with one broken layer rather than not at all. Both fetches and uploads are concurrency-limited to five by mapWithConcurrency(), over one queue spanning both kinds — the limit exists to be kind to the backend, and would not be if each kind got its own.

Import never overwrites. The document always lands under a freshly minted project id, never the one in the file, so exchanging a project cannot collide and re-importing your own export cannot replace the original.

Cancellation is not a skip. An unservable asset is skipped, but an aborted signal makes every asset unservable at once — so a cancelled export would otherwise pack an archive of nothing and hand it to the browser as a finished download. isRequestCancellation() tells the two apart where the request fails. The signal is checked after fetching and again after packing, immediately before the browser download, so cancellation during ZIP creation cannot produce a completed-looking download.

Export from the project switcher flushes Generate’s debounced prompt drafts before it reads the active project. An immediate prompt edit is therefore part of the serialized document rather than arriving after export has already started.

A project file is the one operation here whose duration is set by how much someone has drawn: a few hundred full-resolution layers is a few hundred round trips and hundreds of megabytes. Every entry point runs through useProjectFileActions.ts, which owns the scope capture, the picker, the try/catch, and one live toast per operation (projectFileToasts.ts). Keeping the sequence in one place is what stops the five surfaces drifting — they already had, with the import sites checking the account scope before showing an error and the export sites not, so cancelling an export by signing out produced a toast written for a developer.

Both directions can half-succeed: export skips assets the server will not serve, import leaves references dangling. Both are correct, and both are things the person holding the file needs to know before handing it to someone else — so a lossy run finishes as a warning naming the count, and only a clean run finishes as a plain success. An operation cancelled by the account going away has no verdict to show and dismisses its toast instead.

Losses are counted apart, because they cost different things. A missing board item is a result still findable elsewhere; a missing document reference is a hole in the canvas. One combined number meant anything from “you will not notice” to “the project is broken”, so ProjectTransferIssues carries boardItemIssues and documentReferenceIssues separately, each entry typed with a reason (fetch-failed, missing-entry, upload-failed, star-failed). An item that was both appears in both arrays — it genuinely failed in both roles. Only the two counts reach the toast; the typed detail stays on the returned outcome.

A media failure is never fatal. The failures that do abort are the ones that would produce a lie or a half-built project: an unreadable archive, a document that will not rehydrate, an enumeration that could not be fetched, a cancelled signal, an expired account.

Complete export versus media-only download. A project board’s menu offers both. Export project (.invk) is the project — its document, its board and the bytes. Download Board is the pixels as a ZIP of images, with the count of videos it omits stated in the label. They are different things and neither replaces the other.

ProjectSummary.coverUrl comes from a per-user index in the client-state KV (projects/covers.ts), not from the archive and not from the document. The library lists projects through GET /projects, which returns summaries with no document, so deriving a cover there would mean fetching every project’s document to render a page of thumbnails.

The index is written whenever a project is serialized for a save, and on import. It is an index and not a truth: a project saved by an older build has no entry and shows the folder glyph, which is also what a project that has produced nothing shows.

Each cover write is a complete replacement of that one index value, so writes are serialized. A failed write leaves the optimistic snapshot dirty; a later save or identical cover record retries that complete dirty snapshot instead of silently treating it as clean. Before the index has loaded, pending changes retain both new cover names and clears (null), so clearing a cover before load still removes the matching retained server entry once the read succeeds.

A successful load is also final for that account epoch. Later library refreshes reuse the loaded snapshot instead of issuing another blind GET that could overwrite an optimistic cover mutation; a failed initial read remains retryable.

On import the cover prefers an image the restore already put on this server — the archive’s cover entry is a thumbnail of an image the document also references, and the cover URL asks for a thumbnail anyway, so reusing it is the same picture without a second copy. The bundled bytes are the fallback for a cover whose source image is dangling. Uploading the entry unconditionally left one orphan per import: covers go up under the canvas’s private 'other' category, which appears in no gallery view and no board count, so nobody could find or delete them.

The whole index is one value, so a write must not precede a read. setClientStateValue replaces the blob outright, so recording a cover from a store that has not loaded would delete every entry but that one — and autosave reaches recordProjectCover() from the editor, which loads the index only when the project switcher or the Open dialog opens. Anyone who reloads straight into a project and generates hits exactly that ordering. Records made before the load are held and applied on top of the server’s answer once it arrives. A read that fails is not a read: the store stays unloaded and the pending records wait, because merging onto a blank answer is the same destructive write by another route.

Adding board.json did not bump the manifest version. A version tells a reader what it may assume about a file somebody else wrote, and webv2 has not shipped — no archive exists anywhere that predates the entry except the ones dev builds produced, and those are read exactly as what they are: an archive that names no board, whose import restores document references and lets the server create the new project an empty board. Inventing an empty board for such a file would claim knowledge it does not contain, which is why “absent” and “empty” stay distinguishable in the reader.

The v2 manifest may carry minimumCanvasSchemaVersion. This is the source project’s monotonic compatibility floor, not merely the version of its live canvas. It may conservatively retain a higher requirement inherited from a legacy server record whose queue history is removed during canonical export. Export copies the acknowledged server floor, and import creates the new project with the greater of that floor and the requirement derived from the canonical imported document. An archive above this client’s maximum is refused before a staging board or any media upload exists. Older v2 archives omit the field and continue to derive their requirement from their document.

A malformed board.json is a different matter and is refused as damaged: half an enumeration is not a board. The entry carries its own version: 1, which is the file’s version and not the API’s — the snapshot endpoint returns {items} and nothing else, so neither format is pinned to the other.

Manifests parse through a zod discriminated union on version, so a version 1 archive is recognized and refused as what it is — InvkFormatError carries a reason (legacy-canvas-project, unsupported-version, not-a-project, damaged, too-large) that describeProjectFileError() maps to a translated string. There is no translation layer from legacy canvas state to CanvasStateContractV3.

The legacy reader is the mirror image: it pins version: 1 and refuses everything this app writes.

Future format changes should add a new member to the union rather than widening version 2, so that every version this app has ever written can be named precisely when refused.

archive.ts is the only module that imports fflate, and it does so with await import() so the ZIP codec never enters an initial chunk — the architecture budget fails on a new package appearing in a route’s source-owner set. Both directions are bounded by INVK_MAX_ARCHIVE_BYTES and INVK_MAX_ENTRIES, because an .invk is a file someone was handed.

The input File.size is rejected before arrayBuffer() allocates its bytes. On export, one shared response reader checks declared Content-Length values and streams every asset through the same fixed 2 GiB allocation budget, including concurrent responses. It cancels a response when its declared length, streamed bytes or cumulative retained bytes cross the ceiling. The accumulated entries are checked before packing and the packed ZIP is checked again afterward, because compression can change the final output size.

On the way in, the budget is enforced through fflate’s entry filter (createExpansionBudget()), not over the result. unzip is fully buffered: by the time it returns a record of entries, every one has already been inflated, so a total measured there would describe an out-of-memory crash rather than prevent it. The filter is consulted per entry with its compression method, compressed size and uncompressed originalSize. Deflated entries allocate from originalSize; stored entries allocate from size, so stored entries must declare both sizes identically and the actual allocation size is what counts against the cumulative ceiling. The refusal is raised after unzip settles rather than thrown from inside it, so too-large stays distinguishable from not-a-project — a throw inside fflate’s walk arrives as a decode failure.

INVK_MAX_ARCHIVE_BYTES is 2 GiB — deliberately well under 4 GiB, where ZIP32’s 32-bit sizes and offsets stop being able to describe the file. fflate reads zip64 records but zip() never writes them, so an archive at that boundary would be produced structurally invalid rather than refused. A single video can be hundreds of megabytes, so this ceiling is reachable in practice; it fails cleanly into the too-large reason.

The mock-backed browser journey exercises the real export action and file chooser end to end. It opens a fixture project whose board deliberately carries every case — a result the canvas also draws with, one it never used, an asset per visible category, a video, a starred item, an intermediate and an other item that must not travel, plus references that live outside the board — exports it, inspects the manifest, board.json and the binary entries, then resets the backend preserving deliberate name collisions and imports into a fresh browser context.

The collisions are the point: the destination is seeded with images and a video under the archived names, so the journey can prove that board media takes new identities while a document-only reference is satisfied by the identity already there. It then duplicates the imported project and asserts the same rules with no bytes moving, and finally imports a deliberately damaged archive to check that the two warning counts are reported separately and differ.

This verifies the request and persistence contracts, including server-assigned asset names, not binary fidelity between archived and restored bytes. Generation metadata does survive a real round trip: image_files_disk.save writes it into the PNG’s text chunks and extract_metadata_from_image reads it back, which is how POST /images/copy carries provenance without any extra plumbing — pinned by TestProvenanceRoundTrip in tests/app/services/image_files/test_image_files_disk.py.

Earlier webv2 builds exported *.invokeproject.json files containing a version 1 invokeai-project envelope. Import detects that exact extension, validates the envelope, mints a fresh identity, canonicalizes the embedded document and creates it without attempting media restoration. The picker offers both .invk and .invokeproject.json.

The archive stores references to models, LoRAs and other generation resources, not the model files themselves. Loading a project on another install restores its canvas and images, but missing models still need to be installed or replaced.

Not exported: models and LoRAs, library templates themselves, queue history, intermediates, the canvas’s private other media except where the document references it, gallery boards other than the project’s own, and account preferences. A project file carries one project’s workspace, not the install around it.

Workflows travel with the project. A schema-3 document carries the project’s whole workflow collection (workflows.entries, each with its document and optional source and last-run metadata) and its active selection, so an archive holds every workflow the project owns, active or not, and every media reference they name is bundled like any other document reference. Source metadata names the library template a copy came from; import clears it, because a template id on one install names nothing on another and a portable file must never inherit a library write target. Same-server duplication keeps it. Older archives that carry a schema-2 projectGraph load through the same migration as older server documents: that graph becomes the project’s first and only workflow.

This site was designed and developed by Aether Fox Studio.