Omnichar Studio is an experimentation layer for visual artists: a free-form node canvas for
building generative pipelines frame-by-frame. The Inline Core engine (core/) does the actual
image/video generation behind each frame - diffusion models run locally (Z-Image Turbo and others),
plus closed models via fal.ai.
Naming: the project is Omnichar Studio, shortened to Omnichar. It has been renamed twice - Inline Studio, then OpenChar Studio - so "OpenChar" and the old "Storyline" codename are both wrong in anything new (docs, identifiers, UI strings). Legacy
STORYLINE_*env vars and.storylinepaths still exist in code; treat them as deprecated, don't add more.The rename is deliberately incomplete at the code level, and that is not a bug to fix. These stay as they are until a migration is planned, because changing one breaks installs or on-disk data:
Still named for Inline Why it stays INLINE_*env vars (INLINE_HOST,INLINE_MODELS_DIR, …)A rename breaks every existing launch script and RunPod template. The inline_corePython module andcore/src/inline_core/The import path users and extensions already depend on. .inlinestudioproject foldersOn-disk data in the wild; renaming orphans people's projects. .inline-studio-serverandInlineStudioProjectsOn-disk paths an installed app already reads and writes. "name": "inline-studio"inpackage.jsonNothing is published to npm - this package builds the SPA, which ships inside the omnichar-frontendPyPI wheel.What has been renamed: the product name, the GitHub org, this repo, the website (
omnichar.org, whichinlinestudio.artnow redirects to) and the PyPI packages (omnichar-core,omnichar-frontend).Omnichar Studio is the single repo: it holds the UI client (
src/) and the Inline Core Python generation engine (core/, brought in viagit subtree). One process serves both -cd core && python main.py --front-end-root ../dist-webruns Core and serves the built UI on one port.
GitHub org:
omnichar. The org movedinlineresearch->OpenCharAI->omnichar, and this repo moved with it,Inline-Studio->OpenChar->OmniChar. The old URLs still 301-redirect, so a stalegit remotekeeps working - never write aninlineresearch/orOpenCharAI/URL in anything new. Note the other repos kept theirInline-*names; only the org and this repo moved.
Repo What it is omnichar/OmniCharThis repo: the UI client + Inline Core omnichar/Inline-CoreThe engine's own repo (subtree source for core/)omnichar/Inline-RegistryThe published extension index the Available tab reads omnichar/Inline-Studio-Extension-GuideThe reference extension authors copy This app is local-first and needs no account. It runs on the user's own GPU. Do not add a hosted service, a login, a telemetry call or a billing dependency to this repo. Generation lives in
core/and stays there.Hugging Face org:
inlineresearch- published models and datasets trained with the app. This did NOT move; there is noomnicharorg on Hugging Face. Leave these URLs alone.
Hugging Face repo What it is inlineresearch/skin-lora-krea-2-rawSkin-texture LoRA for Krea 2 RAW, trained on the canvas inlineresearch/krea2-skin-loraThe 26 image + caption pairs that LoRA trained on Website: omnichar.org.
Read this file before changing code. It defines the architecture and the non-negotiable rules.
Project → Sequence → Frame → Take[]
- Project - a portable
.inlinestudiofolder (see Storage below). - Sequence / Scene - an ordered group of frames.
- Frame - the atomic unit. A Frame is a slot with a history of takes, never a single file. Its inputs are library assets or another frame's output (the refine/flow link).
- Take - one immutable render of a frame. Generating again adds a new take; nothing is
overwritten. The frame points at its
heroTakeId(the chosen take), which flows downstream. - Moodboard ↔ Timeline - a frame is either pinned on the free-form canvas or surfaced in the Timeline panel. Same frame, different surface.
If you're tempted to treat a frame as a file, stop - the take history is the core value Comfy lacks.
(Note: the domain was renamed shot → frame; some older migrations still reference shot_* tables.)
Omnichar Studio is a web SPA (React, src/) served by Inline Core (the Python engine, core/)
on a single port. One process: core/main.py runs Core, which serves the built UI and is the app's
backend. (The former Electron desktop app + Node web server were retired - the whole backend was
ported to Python. If you find a reference to electron/, server/, window.inlineStudio, or a
preload bridge, it's stale.)
- Renderer (
src/renderer/) - all React UI. Reaches the backend only throughstudio()(lib/studio.ts), an injected HTTP/WebSocket client (lib/webClient.ts) pointed at Core on the same origin: everyInlineStudioApicall is aPOST /rpc {channel, args}; events stream over the/eventsWebSocket; media loads from/media/*; asset uploadsPOST /upload. Never imports Node. - Shared (
src/shared/) - domain types + theInlineStudioApicontract (ipc.ts) that the renderer and Core both honor. This is the frozen wire protocol - change it in lockstep on both sides. - Inline Core - the Python backend, in this repo under
core/(core/src/inline_core/). Owns the project SQLite DB, filesystem, generation, and the ffmpeg timeline. Studio's former backend lives underinline_core/studio/(store,frames,moodboard,assets,generation,fal,timeline) + the/rpc+/events+/media+/uploadroutes ininline_core/server/. Set it up withcd core && uv sync --extra runtime --extra server.
Fal node definitions stay studio-side (src/shared/nodes/): the browser builds each fal request and
Core relays it to queue.fal.run with the API key server-side. Core nodes (e.g. Z-Image) run through
Core's own graph engine.
List inputs. A port of kind image[] accepts several wires, and their order is meaning, not
decoration: FLUX.2 addresses reference images by position ("the jacket from image 2"). Ordering runs
from moodboard.list_board (connectors ordered by created_at) through graph_build._edges_for
(which accumulates on list ports and keeps last-wins everywhere else) to the numbered ReferenceStrip
on the node face. A Load Assets node wired to a list port contributes all of its assets. If you
touch any of those, keep coreInputThumbs.ts in step or the numbers on the card stop matching the
numbers the prompt resolves.
src/
shared/
types.ts domain types (Project/Sequence/Frame/Take/MoodboardItem/...)
ipc.ts IpcChannels + InlineStudioApi (the wire contract)
coreNodes.ts the Core node-descriptor contract (served at /v1/models)
nodes/ fal model defs (NodeDef: resolveEndpoint/buildRequest/parseOutputs)
result.ts Result<T> = Ok | Err
renderer/
web/index.html, web/main.tsx the SPA entry (mounts App with the web client + media resolver)
App.tsx
lib/ studio.ts (backend seam), webClient.ts (HTTP/WS), mount.tsx, media.ts
store/ Zustand stores (moodboardStore, frameStore, generationStore, ...)
views/ feature-foldered screens (ProjectLauncher, Workspace, Moodboard, Library, ...)
components/ shared UI
vite.config.spa.ts builds the SPA -> dist-web/ (the omnichar_frontend PyPI wheel payload)
MyFilm.inlinestudio/
project.db (SQLite - source of truth; "save" is implicit)
assets/ (imported library media, by id)
takes/ (generated outputs, by take id)
thumbs/ (director previews / cached media)
exports/ (hero-take folder exports)
Recents, settings, and the fal API key are app-global under Core's data dir
(~/.inline-studio-server). The browser has no folder picker, so new projects are created under
~/InlineStudioProjects (INLINE_STUDIO_WORKSPACE_DIR).
- Core nodes (Z-Image Turbo, …) - the browser calls
generation:runWorkflow(itemId); Core builds the graph from the canvas closure, runs it through its own engine, saves takes, streams progress. - Fal nodes - the browser builds
{endpoint, body, outputKind}from the NodeDef and callsgeneration:run; Core's relay submits/pollsqueue.fal.run(key server-side), downloads, saves. - Director timelines - resolved from canvas connectors and rendered with ffmpeg
(
inline_core/studio/timeline/), progress over/events. Folder export copies hero takes.
ComfyUI has been fully removed - no embedded webview, comfy.* channels, Generate tab, or workflow
linking. Generation is Core nodes, installed-extension nodes, and fal nodes on the canvas.
- TypeScript strict. No implicit
any, noas anyto silence errors.npm run typecheck. - Typed contract only. Channels live in
src/shared/ipc.ts; the web client (webClient.ts) implementsInlineStudioApigenerically fromIpcChannels; every call returnsResult<T>. Core implements the same channels in Python - change the contract in lockstep on both sides. - Renderer is browser-only. No Node/Electron imports (ESLint-enforced). All "trusted" work
(filesystem, DB, generation, ffmpeg) is Core's, reached over
/rpc; validate payloads in Core. - State. Zustand stores are small and feature-scoped. Components render; stores +
studio()do work. - Backend logic lives in Core, not the renderer. Fal node definitions stay studio-side
(
src/shared/nodes/); their execution is Core's fal relay. - Files & naming. Components
PascalCase.tsx, hooksuseX.ts, one component per file, feature-foldered views. Keep files under ~300 lines without a good reason. - Comments are one line. Not two, not a paragraph, and only for the why a reader can't infer from the code - a non-obvious constraint, a rejected alternative, an ordering that matters. This applies to file and component header comments too: one line, not a block. Never narrate what the code does or restate the line below. If the reasoning needs more, it belongs in a doc, not in the source.
- Icons, never emoji. Never use emoji in the UI (no 🎬/🎵/✂/🔊 as glyphs). Use crisp,
consistent line SVG icons (Lucide-style:
viewBox="0 0 24 24",fill="none",stroke="currentColor") that inherit color/size viacurrentColor+ a size class. Follow the existing icon components (src/renderer/components/icons,CanvasToolbaricons,DirectorNode'sVolumeIcon); reuse or add to those rather than dropping in an emoji. - Tests (Vitest). Cover the logic that matters: fal node input/request resolution, frame-input and hero-take resolution, DB migrations. UI is verified by running the app - don't chase view coverage.
- Arithmetic mirrored from Core lives in
src/shared/and is pinned by a test.clipGrid.tsrestates H3's frame grid so the Trainer can show what a setting resolves to; if the two drift the UI promises a number the run will not honour. - Surface what a setting resolves to, not just what was typed. A control that silently snaps (H3 clip length rounds down onto its frame grid) reads as broken.
- Commits. Conventional Commits (
feat:,fix:,chore:), small and scoped.lint+typecheckrun on pre-commit (husky + lint-staged). - Never commit automatically. Claude (or any AI agent) must not run
git commit/git pushon its own - only when the user explicitly asks. Make the changes, leave them in the working tree, and let the user review and commit.
Every node on the moodboard canvas reads as one card design. New nodes (fal models, Inline Core
nodes, anything) MUST match it - the fal Generate node (nodes/GenNode.tsx) and the Inline Core node
(nodes/GraphNode.tsx) are the reference. The shared parts live in nodes/NodeBadge.tsx; reuse them,
don't re-invent:
- Card chrome: wrap in
NodeFramewithpadded={false}+subtleSelect(quietzinc-600selection border, not the loud accent). Do your own layout inside. - Floating title: a
NodeBadgeRow+NodeBadgepinned above the card - an icon glyph + the node title (+ optionaltone="info"badges like a price). Icons are Lucide-style stroked glyphs fromNodeBadge.tsx(WandIcon,BoxIcon, …); never emoji. Map a node's icon string to a glyph. - Body: an edge-to-edge output preview on a
bg-blackflex-1area (object-covermedia), with a busy overlay = a status pill (top-left) + a 1px bottom progress bar in emerald. - Params live OFF the node face. The face shows no param widgets. A footer Adjust button
(
AdjustIcon, taggeddata-gen-settings-toggle) opens a right-hand settings sidebar that renders the params (GenerateSettingsPanelfor fal,CoreSettingsPanelfor Core; both keyed ingenerationStore, mutually exclusive in the right gutter). This keeps generation one-click. - Footer bar:
border-t border-border bg-surface/90, a small left label, and a right cluster with the Run control (PlayIcon, emerald) + the Adjust button. - Handles:
group !h-3 !w-3 !border-2 !border-surface, colored per port kind, evenly spaced down the edge, with a hover chip naming the port.
npm run dev:web # Vite dev server (HMR), proxying /rpc,/events,/media,/upload,/v1 to Core
npm run build:spa # build the SPA -> dist-web/ (served by Core; the PyPI wheel payload)
npm run typecheck # tsc on the web project (renderer + shared)
npm run lint # eslint, zero warnings allowed
npm run test # vitest
Run the whole app on one port (UI + API):
npm run build:spa # -> dist-web/
cd core && uv run python main.py --front-end-root ../dist-web # add --listen for LAN
See core/CLAUDE.md for the engine internals (nodes, models, device policy).
- New backend call → add the channel +
InlineStudioApisignature insrc/shared/ipc.ts, then implement the handler in Core (inline_core/studio/handlers.py, backed by the domain modules). The web client forwards it automatically. - New screen →
src/renderer/views/<Feature>/, plus a store insrc/renderer/store/if it owns state. - New canvas node type → a component in
src/renderer/views/Moodboard/nodes/registered inMoodboardPanel'snodeTypes, plus anyMoodboardItemTypeinsrc/shared/types.ts. Follow the "Node UI style" section above - match the shared card design (badge,subtleSelect, footer Run+Adjust, params in a sidebar). - New fal model → a
NodeDeffile insrc/shared/nodes/appended toNODE_DEFS(the rest is data-driven off it - it appears in the Add-node picker automatically). - New generation-engine behaviour → Core (
inline_core/); new domain entity →src/shared/types.ts- the Python schema (
inline_core/studio/schema.py, bumpSCHEMA_VERSION+ migration).
- the Python schema (
Hold every change to a high security standard, and review it for security before calling it done.
- Review after each completed task, before reporting it finished. Go through what the change exposes: secrets, keys or tokens in code, logs, errors or fixtures; validation of anything that arrives from outside (files, network, user input, extension code); injection (SQL, shell, path traversal, HTML); and any new network call, file access or permission. Say what was checked and what was found. Fix it or flag it; never leave a finding unmentioned.
- Never expose infrastructure or credentials. No storage URL, account id, internal host or key in anything shipped to a user or a public page.
- Fail closed. A missing secret or a failed check is a refusal, never a fall-through to allowed.